Adding a Git Submodule to a Repository and Using It in CI/CD

Adding a Git Submodule to a Repository and Using It in CI/CD

Development
2026-08-11 8 min read

Introduction

Git submodules are useful when a project needs to include content or code maintained in a separate Git repository.

A common example is a website that keeps its blog content in a dedicated repository while the website source code remains in another repository.

This guide explains how to:

  • Add a Git repository as a submodule.
  • Configure the submodule inside a specific directory.
  • Understand how Git tracks a submodule commit.
  • Configure GitHub Actions to clone a private submodule.
  • Use separate authentication for the parent repository and the submodule repository.
  • Include the submodule contents in the CI/CD build.
  • Troubleshoot common authentication and checkout errors.

Security note: Never publish personal access tokens, passwords, private keys, repository credentials, or other secrets in a public blog post. Use placeholders when documenting private infrastructure.


1. Understand the Repository Structure

The setup described in this guide contains two repositories.

Parent Repository

The parent repository contains the main application or website:

Parent Repository
└── website-project

Submodule Repository

The submodule repository contains content or code that needs to be included inside the parent project:

Submodule Repository
└── blogs

After adding the submodule, the structure becomes:

website-project/
├── .github/
│   └── workflows/
├── src/
│   └── contents/
│       └── blogs/          ← Git submodule
├── package.json
└── ...

The blogs/ directory is not an ordinary directory tracked directly by the parent repository. It is a reference to a specific commit in another Git repository.


2. Why Use a Git Submodule?

A submodule allows the parent project and content repository to have separate Git histories.

For example:

Parent Repository
        │
        └── src/contents/blogs
                    │
                    ▼
             Submodule Repository

This provides a clean separation:

  • The parent repository contains application code.
  • The submodule repository contains independently managed content.
  • The content repository can have its own contributors and release history.
  • The parent repository controls exactly which submodule commit is used during a build.

This last point is important: a submodule is commit-pinned.

If the submodule's main branch moves to a new commit, the parent repository does not automatically start using that commit.


3. Add the Repository as a Submodule

From the root of the parent repository, run:

git submodule add <submodule-repository-url> src/contents/blogs

For example:

git submodule add https://github.com/<account>/<content-repository>.git src/contents/blogs

The second argument specifies where the submodule should be placed.

After running the command, Git creates:

.gitmodules

and registers:

src/contents/blogs

as a submodule.


4. Check the `.gitmodules` File

The .gitmodules file should contain configuration similar to:

[submodule "src/contents/blogs"]
    path = src/contents/blogs
    url = https://github.com/<account>/<submodule-repository>.git
    branch = main

The important fields are:

Field Purpose


path Location of the submodule inside the parent repository url Git repository containing the submodule branch Branch used when updating the submodule

Do not put authentication tokens inside .gitmodules.

The repository URL can remain a normal HTTPS URL.


5. Verify the Submodule

Run:

git submodule status

A successful result looks similar to:

fdbd28b... src/contents/blogs (heads/main)

The commit hash represents the exact version of the submodule currently referenced by the parent repository.

You can also check:

git status

The submodule should appear as a single Git entry rather than a list of individual files.


6. Commit the Submodule Configuration

After adding the submodule:

git add .gitmodules src/contents/blogs

Then commit:

git commit -m "chore: add blogs as submodule"

Push the parent repository:

git push origin main

The parent repository now records the submodule reference.


7. Understanding Commit Pinning

Suppose the submodule repository has:

A → B → C
        ↑
       main

and the parent repository points to:

B

If a new commit is pushed:

A → B → C → D
            ↑
           main

the parent repository still points to B.

The parent repository must explicitly update its submodule reference.

To update it:

cd src/contents/blogs
git checkout main
git pull origin main

Then return to the parent repository:

cd ../../..

Check the changed submodule reference:

git status

Commit the new reference:

git add src/contents/blogs
git commit -m "chore: update blogs submodule"
git push origin main

This approach makes builds reproducible because the parent repository explicitly determines which submodule commit is used.


8. The CI/CD Problem

A local checkout may work because your computer already has credentials that allow access to the submodule repository.

A GitHub Actions runner is a new environment.

If the submodule repository is private, the runner needs permission to clone it.

There are two separate repositories involved:

Parent Repository
        │
        │ GitHub Actions built-in token
        ▼
Submodule Repository
        │
        │ Personal Access Token / secret
        ▼
Private Content

The authentication requirements should be treated separately.

Parent Repository

GitHub Actions can use the built-in:

GITHUB_TOKEN

to access the repository where the workflow is running.

Private Submodule

A separate GitHub secret can be used to authenticate against the private submodule repository.

For example:

SUBMODULE_TOKEN

The token should have only the permissions required to read the submodule repository.


9. Create a Read-Only Token for the Submodule

For a private submodule, create a fine-grained GitHub personal access token.

Configure the token with:

Repository access:
    Only selected repositories
        → Submodule repository

Repository permissions:
    Contents: Read-only

Avoid granting unnecessary permissions.

The token should not have write access unless the workflow actually needs to modify the repository.


10. Add the Token to GitHub Actions Secrets

In the parent repository, open:

Repository
    → Settings
        → Secrets and variables
            → Actions
                → New repository secret

Create:

Name:
SUBMODULE_TOKEN

Paste the token as the secret value.

Do not put the token directly into:

.github/workflows/*.yml

and do not commit it to the repository.


11. Checkout the Parent Repository

The parent repository should be checked out normally:

- name: Checkout Repository
  uses: actions/checkout@v4

This uses the GitHub Actions authentication available to the workflow.

Do not replace the parent repository's authentication with a token intended only for the private submodule.


12. Authenticate and Checkout the Submodule

After checking out the parent repository, configure Git to use the secret when accessing the submodule repository.

Example:

- name: Checkout Submodule
  env:
    SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
  run: |
    git config --global url."https://x-access-token:${SUBMODULE_TOKEN}@github.com/<account>/".insteadOf "https://github.com/<account>/"
    git submodule update --init --recursive

The authentication flow is then:

GitHub Actions
      │
      ├── GITHUB_TOKEN
      │       │
      │       ▼
      │  Parent Repository
      │
      └── SUBMODULE_TOKEN
              │
              ▼
        Private Submodule

The token is never stored in .gitmodules.


13. Example GitHub Actions Workflow

A simplified workflow can look like this:

name: Build and Deploy

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Checkout Submodule
        env:
          SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
        run: |
          git config --global url."https://x-access-token:${SUBMODULE_TOKEN}@github.com/<account>/".insteadOf "https://github.com/<account>/"
          git submodule update --init --recursive

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install Dependencies
        run: npm ci

      - name: Build Website
        run: npm run build

The important part is that the submodule is initialized before the application build runs.


14. Verify the Submodule During CI

While troubleshooting, add a temporary verification step:

- name: Verify Submodule
  run: |
    git submodule status
    echo "Submodule directory:"
    ls -la src/contents/blogs

A successful result should show:

<commit-sha> src/contents/blogs

followed by the files inside the directory.

Once the deployment works correctly, the diagnostic step can be removed.


15. Test Repository Access Separately

Before debugging the complete deployment pipeline, test whether the secret can access the private submodule:

name: Test Submodule Access

on:
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Test Submodule Repository Access
        env:
          TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
        run: |
          git ls-remote "https://x-access-token:${TOKEN}@github.com/<account>/<submodule-repository>.git"

A successful result should contain references similar to:

<commit-sha>    HEAD
<commit-sha>    refs/heads/main

This confirms that the token can read the submodule repository.

However, this test only proves repository access. It does not verify the complete Git submodule checkout.


16. Common CI/CD Errors

Error: Repository not found

remote: Repository not found.
fatal: repository 'https://github.com/.../' not found

First determine which repository failed.

If it is the parent repository:

Parent Repository
        ↓
Checkout failed

the problem is with the parent checkout or repository permissions.

If it is the submodule:

Parent Repository
        ↓
Checkout successful
        ↓
Submodule checkout failed

the problem is usually with the submodule URL, token, or repository permissions.


Error: Submodule directory is empty

If the build cannot find files under:

src/contents/blogs

verify that the workflow contains:

git submodule update --init --recursive

and that the step runs before the build.


Error: Token works in a test but deployment fails

A token test such as:

git ls-remote ...

only proves that the token can access the specific repository used in that test.

Make sure the deployment is using the same secret and that the checkout operation is using the appropriate authentication for each repository.


Error: `git submodule status` is empty

Check whether the submodule has actually been initialized:

git submodule update --init --recursive

Then run:

git submodule status

Also verify that .gitmodules exists:

cat .gitmodules

17. Deployment Architecture

Once configured correctly, the deployment flow becomes:

                     Git Push
                         │
                         ▼
                Parent Repository
                         │
                         ▼
                  GitHub Actions
                         │
              ┌──────────┴──────────┐
              │                     │
              ▼                     ▼
       Checkout Parent       Checkout Submodule
         GITHUB_TOKEN          SUBMODULE_TOKEN
              │                     │
              └──────────┬──────────┘
                         ▼
                  Install Dependencies
                         │
                         ▼
                    Build Website
                         │
                         ▼
                   Deployment

The application build sees the submodule as a normal directory:

src/
└── contents/
    └── blogs/
        ├── article-1.md
        ├── article-2.md
        └── ...

This means the application does not need special logic to understand Git submodules. Git handles the repository checkout before the build begins.


A clean project structure can look like:

website-project/
├── .github/
│   └── workflows/
│       ├── deploy-alpha.yml
│       └── deploy-main.yml
├── src/
│   ├── contents/
│   │   └── blogs/              ← submodule
│   └── ...
├── public/
├── package.json
├── .gitmodules
└── ...

Keep the submodule at a location that matches how the application loads its content.

For example, if the application imports Markdown files from:

src/contents/blogs

the submodule should be mounted at exactly that path.


Conclusion

Git submodules provide a clean way to keep independently maintained content or code inside another project.

The important concepts are:

  1. The parent repository owns the application.
  2. The submodule repository owns the separately maintained content.
  3. .gitmodules defines where the submodule is located and where it comes from.
  4. The parent repository records a specific submodule commit.
  5. A private submodule requires CI/CD credentials.
  6. Use the built-in GitHub Actions token for the parent repository.
  7. Use a restricted read-only secret for the private submodule.
  8. Initialize the submodule before running the application build.
  9. Do not store credentials inside .gitmodules or source code.
  10. Update the submodule reference in the parent repository whenever a newer submodule commit should be included in a deployment.

The resulting separation is simple:

Parent Repository
    │
    ├── Application
    ├── CI/CD
    └── Submodule reference
              │
              ▼
       Submodule Repository
              │
              └── Content / Code

This approach keeps repositories independently maintainable while still allowing the submodule's content to become part of the parent application's build.