Accessing a Private GitHub Submodule from GitHub Actions (and Every Option You Have)

My blog is split across two repositories: blog-app holds the React SPA and infrastructure; blog-content holds the posts. The blog-app repository has a scheduled GitHub Actions workflow that clones the latest content, builds the site, and deploys it to S3. Content lives in a private repo because, whilst blog posts are meant to be publicly readable, I don't want to hand over the whole corpus as a ready-to-steal package.
The workflow used git submodule update --init --remote web/content. When I first tried to run it, I got:
git@github.com: Permission denied (publickey).
fatal: clone of 'git@github.com:nakomis/blog-content.git' failed
And when I switched to HTTPS to avoid the SSH key issue:
fatal: could not read Username for 'https://github.com': No such device or address
Both failures boil down to the same thing: the GitHub Actions runner has no credentials to access a private repository it wasn't checked out from. The GITHUB_TOKEN available in every workflow is scoped to the workflow's own repository — it won't open doors elsewhere.
So what are the options?
Option 1: Make the repository public
The simplest fix and occasionally the right one. If there's genuinely no sensitive content, public removes the problem entirely. In my case the content is copyrighted, so a public repo felt like an open invitation. Skipped.
Option 2: Personal Access Token (PAT)
Create a classic PAT with repo scope, add it as a secret, use it to authenticate the clone. It works, but a classic PAT is tied to your personal GitHub account, has broad permissions, never expires unless you set it to, and is a single credential that can access everything your account can access. This is the option most tutorials reach for and the one I'd recommend against.

Option 3: Fine-grained PAT
GitHub's newer token type. You can scope it to specific repositories and specific permissions — read-only on contents for blog-content, nothing else. Much better than a classic PAT. Still tied to your personal account, so it breaks if you transfer the repo or add collaborators who need to rotate it. Reasonable for a personal project; not what I'd reach for in a team.
Option 4: Deploy keys
An SSH key pair. The public key goes on blog-content as a read-only deploy key; the private key goes into blog-app as an Actions secret. The workflow loads the private key into the SSH agent before running the submodule clone.
- name: Load blog-content deploy key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.BLOG_CONTENT_DEPLOY_KEY }}
- name: Pull latest blog-content
run: git submodule update --init --remote web/content
Deploy keys are repository-scoped by design. They have no relationship to any user account. You can revoke and rotate them independently. The webfactory/ssh-agent action adds the key to the SSH agent transparently — the submodule clone just works via the normal SSH URL.
This is also the most IaC-friendly option short of a GitHub App. The Terraform GitHub provider has github_repository_deploy_key and github_actions_secret resources, so the entire setup is expressible as code with no manual steps.
This is what I went with.
Option 5: GitHub App
A GitHub App is a first-class entity, not tied to any user account. You install it on specific repositories, grant it specific permissions, and generate short-lived tokens at workflow runtime using actions/create-github-app-token:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ vars.BLOG_CONTENT_APP_ID }}
private-key: ${{ secrets.BLOG_CONTENT_APP_KEY }}
repositories: blog-content
- name: Pull latest blog-content
run: |
git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/"
git submodule update --init --remote web/content
Tokens expire after one hour. A leaked workflow log doesn't expose a reusable credential. The app survives account transfers. This is the architecturally correct answer for teams and organisations.
The catch: you can't create a GitHub App via the API or Terraform — it requires a one-time click through the GitHub UI. Once created, installations and secrets can be managed via Terraform's github_app_installation_repository. For a personal project, the setup overhead outweighs the benefit over deploy keys.
Option 6: Octo STS
Octo STS is a public GitHub App that mimics AWS STS — your workflow exchanges a GitHub OIDC token for a scoped installation token without storing any private key. Trust is defined as a policy file committed to the target repository:
# .github/chainguard/blog-app.sts.yaml (in blog-content)
issuer: https://token.actions.githubusercontent.com
subject: repo:nakomis/blog-app:*
permissions:
contents: read
No secrets anywhere. This is the closest GitHub equivalent to the OIDC federation I already use between GitHub Actions and AWS — and the Ministry of Justice's guidance points here. The trade-off is a dependency on a third-party app you don't control.
Option 7: CloudFormation GitHub Resource Providers
AWS maintains a set of CloudFormation resource types for GitHub — GitHub::Repositories::Repository, GitHub::Actions::Secret, and others. Once activated in your account (aws cloudformation activate-type), you can manage GitHub resources from CDK alongside your AWS infrastructure.
For my setup, where the publish workflow is already managed via a CDK BlogGithubStack, this would be the natural fit for a fully unified IaC story. The activation step and the required GitHub credentials stored in Secrets Manager add enough friction that I'll save it for when I have a stronger reason.
What I chose and why
Deploy keys. They solve the problem completely, are scoped to exactly the repositories involved, have no user account dependency, and are fully manageable via Terraform if I want to formalise the IaC later. The setup took about two minutes:
# Generate key pair
ssh-keygen -t ed25519 -C "blog-app-ci@github-actions" -f /tmp/blog_content_key -N ""
# Add public key to blog-content (read-only by default)
gh repo deploy-key add /tmp/blog_content_key.pub --repo nakomis/blog-content --title "blog-app-ci"
# Add private key as secret to blog-app
gh secret set BLOG_CONTENT_DEPLOY_KEY --repo nakomis/blog-app < /tmp/blog_content_key
One thing worth noting: the gh repo deploy-key add command doesn't have a --read-only flag — read-only is the default. Write access requires --allow-write. I discovered this by passing --read-only and getting an error.
Closing the loop: automated embedding regeneration
Once the workflow could clone blog-content, I noticed a gap in the search pipeline. The blog's RAG search (covered in the previous post) uses a blog-embeddings.json file in S3 to serve semantic search results. That file was regenerated manually — meaning after a new post published, search wouldn't know about it until I remembered to run the ingestion script locally.
With the publish workflow now reliable, the fix was straightforward: run the ingestion script as the final step after deploying the site.
- name: Deploy
run: bash scripts/deploy.sh
working-directory: web
- name: Set up Python for ingestion
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install ingestion dependencies
run: pip install boto3
- name: Regenerate search embeddings
run: python scripts/ingest-blog.py
The ingestion script reads from the deployed S3 bucket — which already contains only published posts, filtered by publish_date during the build step. So there's no risk of unpublished content leaking into the search index; the filtering happens earlier in the pipeline.
The deploy role needed two additional permissions:
// Ingestion: write embeddings to the private bucket
const privateBucket = s3.Bucket.fromBucketName(this, 'PrivateBucket', 'nakom.is-private');
privateBucket.grantPut(deployRole);
// Ingestion: embed via Bedrock Titan Embed v2
deployRole.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeModel'],
resources: ['arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0'],
}));
The pipeline is now fully hands-off: push a post, wait for the scheduled publish, and both the site and the search index update automatically.
Martin Harris connects cloud infrastructure to the real world. If you've ever wondered why the GITHUB_TOKEN doesn't work for other people's repos, you've arrived at the right post.
If you find this blog useful, please consider a donation.
