Mastering Git Submodules in CI/CD Workflows & GitHub Actions
Original Content Disclaimer: This article is based on my practical experience with Git submodules and CI/CD workflows. All code examples and configurations are original and created for educational purposes.
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:
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
āāā website-project
Submodule Repository
The submodule repository contains content or code that needs to be included inside the parent project:
<span class="vs-class">Submodulespan> <span class="vs-class">Repositoryspan>
āāā blogs
After adding the submodule, the structure becomes:
website-project/
āāā .github/
ā āāā workflows/
āāā src/
ā āāā contents/
ā āāā blogs/ ā <span class="vs-class">Gitspan> 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:
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
āāā src/contents/blogs
ā
ā¼
<span class="vs-class">Submodulespan> <span class="vs-class">Repositoryspan>
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:<span <span class="vs-keyword">classspan>="vs-comment">//github.com/<account>/<content-repository>.git src/contents/blogsspan>
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:<span <span class="vs-keyword">classspan>="vs-comment">//github.com/<account>/<submodule-repository>.gitspan>
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/<span class="vs-function">blogsspan>(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:
<span class="vs-class">Aspan> ā <span class="vs-class">Bspan> ā <span class="vs-class">Cspan>
ā
main
and the parent repository points to:
<span class="vs-class">Bspan>
If a new commit is pushed:
<span class="vs-class">Aspan> ā <span class="vs-class">Bspan> ā <span class="vs-class">Cspan> ā <span class="vs-class">Dspan>
ā
main
the parent repository still points to <span class="vs-class">Bspan>.
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:
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
ā <span class="vs-class">GitHubspan> <span class="vs-class">Actionsspan> built-in token
ā¼
<span class="vs-class">Submodulespan> <span class="vs-class">Repositoryspan>
ā
ā <span class="vs-class">Personalspan> <span class="vs-class">Accessspan> <span class="vs-class">Tokenspan> / secret
ā¼
<span class="vs-class">Privatespan> <span class="vs-class">Contentspan>
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:
<span class="vs-class">Repositoryspan> access:
<span class="vs-class">Onlyspan> selected repositories
ā <span class="vs-class">Submodulespan> repository
<span class="vs-class">Repositoryspan> permissions:
<span class="vs-class">Contentsspan>: <span class="vs-class">Readspan>-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:
<span class="vs-class">Repositoryspan>
ā <span class="vs-class">Settingsspan>
ā <span class="vs-class">Secretsspan> and variables
ā <span class="vs-class">Actionsspan>
ā <span class="vs-class">Newspan> repository secret
Create:
<span class="vs-class">Namespan>:
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: <span class="vs-class">Checkoutspan> <span class="vs-class">Repositoryspan>
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: <span class="vs-class">Checkoutspan> <span class="vs-class">Submodulespan>
env:
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
git config --global url."https:<span class="vs-comment">//x-access-token:${SUBMODULE_TOKEN}@github.com/<account>/".insteadOf "https://github.com/<account>/"span>
git submodule update --init --recursive
The authentication flow is then:
<span class="vs-class">GitHubspan> <span class="vs-class">Actionsspan>
ā
āāā GITHUB_TOKEN
ā ā
ā ā¼
ā <span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
āāā SUBMODULE_TOKEN
ā
ā¼
<span class="vs-class">Privatespan> <span class="vs-class">Submodulespan>
The token is never stored in .gitmodules.
13. Example GitHub Actions Workflow
A simplified workflow can look like this:
name: <span class="vs-class">Buildspan> and <span class="vs-class">Deployspan>
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: <span class="vs-class">Checkoutspan> <span class="vs-class">Repositoryspan>
uses: actions/checkout@v4
- name: <span class="vs-class">Checkoutspan> <span class="vs-class">Submodulespan>
env:
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
git config --global url."https:<span class="vs-comment">//x-access-token:${SUBMODULE_TOKEN}@github.com/<account>/".insteadOf "https://github.com/<account>/"span>
git submodule update --init --recursive
- name: <span class="vs-class">Setupspan> <span class="vs-class">Nodespan>.js
uses: actions/setup-node@v4
<span class="vs-keyword">withspan>:
node-version: <span <span class="vs-keyword">classspan>="vs-number">22span>
cache: npm
- name: <span class="vs-class">Installspan> <span class="vs-class">Dependenciesspan>
run: npm ci
- name: <span class="vs-class">Buildspan> <span class="vs-class">Websitespan>
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: <span class="vs-class">Verifyspan> <span class="vs-class">Submodulespan>
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: <span class="vs-class">Testspan> <span class="vs-class">Submodulespan> <span class="vs-class">Accessspan>
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: <span class="vs-class">Testspan> <span class="vs-class">Submodulespan> <span class="vs-class">Repositoryspan> <span class="vs-class">Accessspan>
env:
<span class="vs-class">TOKENspan>: ${{ secrets.SUBMODULE_TOKEN }}
run: |
git ls-remote "https:<span class="vs-comment">//x-access-token:${<span class="vs-class">TOKENspan>}@github.com/<account>/<submodule-repository>.git"span>
A successful result should contain references similar to:
<commit-sha> <span class="vs-class">HEADspan>
<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: <span class="vs-class">Repositoryspan> not found.
fatal: repository 'https:<span <span class="vs-keyword">classspan>="vs-comment">//github.com/.../&#<span <span class="vs-keyword">classspan>="vs-number">039span>; not foundspan>
First determine which repository failed.
If it is the parent repository:
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
<span class="vs-class">Checkoutspan> failed
the problem is with the parent checkout or repository permissions.
If it is the submodule:
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
<span class="vs-class">Checkoutspan> successful
ā
<span class="vs-class">Submodulespan> 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:
<span class="vs-class">Gitspan> <span class="vs-class">Pushspan>
ā
ā¼
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
ā¼
<span class="vs-class">GitHubspan> <span class="vs-class">Actionsspan>
ā
āāāāāāāāāāāā“āāāāāāāāāāā
ā ā
ā¼ ā¼
<span class="vs-class">Checkoutspan> <span class="vs-class">Parentspan> <span class="vs-class">Checkoutspan> <span class="vs-class">Submodulespan>
GITHUB_TOKEN SUBMODULE_TOKEN
ā ā
āāāāāāāāāāāā¬āāāāāāāāāāā
ā¼
<span class="vs-class">Installspan> <span class="vs-class">Dependenciesspan>
ā
ā¼
<span class="vs-class">Buildspan> <span class="vs-class">Websitespan>
ā
ā¼
<span class="vs-class">Deploymentspan>
The application build sees the submodule as a normal directory:
src/
āāā contents/
āāā blogs/
āāā article-<span <span class="vs-keyword">classspan>="vs-number">1span>.md
āāā article-<span <span class="vs-keyword">classspan>="vs-number">2span>.md
āāā ...
This means the application does not need special logic to understand Git submodules. Git handles the repository checkout before the build begins.
18. Recommended Project Structure
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:
- āļøThe parent repository owns the application.
- āļøThe submodule repository owns the separately maintained content.
- āļø
.gitmodulesdefines where the submodule is located and where it comes from. - āļøThe parent repository records a specific submodule commit.
- āļøA private submodule requires CI/CD credentials.
- āļøUse the built-in GitHub Actions token for the parent repository.
- āļøUse a restricted read-only secret for the private submodule.
- āļøInitialize the submodule before running the application build.
- āļøDo not store credentials inside
.gitmodulesor source code. - āļøUpdate the submodule reference in the parent repository whenever a newer submodule commit should be included in a deployment.
The resulting separation is simple:
<span class="vs-class">Parentspan> <span class="vs-class">Repositoryspan>
ā
āāā <span class="vs-class">Applicationspan>
āāā <span class="vs-class">CIspan>/<span class="vs-class">CDspan>
āāā <span class="vs-class">Submodulespan> reference
ā
ā¼
<span class="vs-class">Submodulespan> <span class="vs-class">Repositoryspan>
ā
āāā <span class="vs-class">Contentspan> / <span class="vs-class">Codespan>
This approach keeps repositories independently maintainable while still allowing the submodule's content to become part of the parent application's build.