---
title: "Accessing a Private GitHub Submodule from GitHub Actions (and Every Option You Have)"
date: "2026-03-30"
approved: true
publish_date: "2026-04-20"
excerpt: "I needed my blog's publish workflow to clone a private submodule. A ten-minute problem turned into a tour of every cross-repository trust mechanism GitHub offers — deploy keys, PATs, fine-grained PATs, GitHub Apps, Octo STS, and CloudFormation resource providers."
tags: ["github", "github-actions", "ci-cd", "devops", "security"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-03-30-github-actions-private-submodule"
---

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

![A robot courier presenting a brass key to a vault door set into library shelves of repositories](./images/github-actions-private-submodule-header.png)

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.

![Robot couriers passing a key through doorways each secured by a different lock](./images/github-actions-private-submodule-1.png)

## 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.

```yaml
- 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](https://registry.terraform.io/providers/integrations/github/latest) 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`:

```yaml
- 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](https://github.com/octo-sts/app) 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:

```yaml
# .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](https://user-guidance.analytical-platform.service.justice.gov.uk/github/accessing-private-repositories-from-github-actions.html) 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](https://github.com/aws-ia/cloudformation-github-resource-providers) — `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:

```bash
# 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](/rag-search-static-blog)) 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.

```yaml
- 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:

```typescript
// 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.*


{{donate}}