A Git submodule embeds one repository inside another, pinned to a specific commit. It’s used for shared libraries, themes (for example a Hugo theme), or vendor code you want to track separately. Submodules are powerful but have sharp edges: the commands below cover the everyday workflow, and the common traps.

How submodules work

  • The parent repository stores the submodule’s URL (in .gitmodules), and an exact commit (as a special entry in the tree).
  • Cloning the parent doesn’t fetch submodule contents automatically, unless you ask.
  • The submodule doesn’t follow a branch automatically. It stays on the pinned commit until you update it and commit the change in the parent.

Add a submodule

git submodule add https://github.com/example/theme.git themes/mytheme
git commit -m "Add mytheme as submodule"
cat .gitmodules
[submodule "themes/mytheme"]
    path = themes/mytheme
    url = https://github.com/example/theme.git

Clone a repository with submodules

git clone --recurse-submodules https://git.example.com/team/site.git
# already cloned without them?
git submodule update --init --recursive

Forgetting this is the number one submodule problem: the directory exists, but it’s empty.

Make git pull also update submodules:

git config --global submodule.recurse true

Update a submodule to a newer version

cd themes/mytheme
git fetch
git checkout v2.3.0            # or: git pull origin main
cd ../..
git add themes/mytheme
git commit -m "Update mytheme to v2.3.0"

Or update all submodules to the latest commit on their tracked branch:

git submodule update --remote --merge
git add . && git commit -m "Update submodules"

Set the tracked branch in .gitmodules with branch = main, if needed.

Making changes inside a submodule

A submodule checkout is often in detached HEAD state. Before editing, switch to a branch:

cd themes/mytheme
git switch main
# edit, commit, push in the submodule repo
git push
cd ../..
git add themes/mytheme && git commit -m "Bump mytheme"

Push the submodule before pushing the parent, or others get a reference to a commit that doesn’t exist remotely.

CI and deployments

  • GitHub Actions: actions/checkout with submodules: recursive. Private submodules need a token or deploy key with access.
  • GitLab CI: GIT_SUBMODULE_STRATEGY: recursive.
  • Manual deploys on a VPS: git pull && git submodule update --init --recursive.

See GitHub Actions deploy to VPS and GitLab CI deploy over SSH.

Private submodules over SSH: use relative URLs (url = ../shared-lib.git) in .gitmodules, so they resolve with the same host and credentials as the parent.

Remove a submodule

git submodule deinit -f themes/mytheme
git rm -f themes/mytheme
rm -rf .git/modules/themes/mytheme
git commit -m "Remove mytheme submodule"

Alternatives

Approach Good for
Package manager (npm, pip, Composer, Go modules) Libraries with versioned releases. Usually the best choice
git subtree Copying another repo’s content into yours, with history; no extra steps for users
Monorepo Tightly coupled code changed together. See monorepo vs polyrepo
Submodules Pinning an external repo exactly, while keeping it separate

Quick reference

Task Command
Add git submodule add <url> <path>
Clone with submodules git clone --recurse-submodules <url>
Init after clone git submodule update --init --recursive
Update to remote git submodule update --remote --merge
Status git submodule status
Remove deinit -f → git rm → delete .git/modules/<path>

Related: Git LFS and Git commands cheat sheet.

  • Git
  • DevOps
  • Command Line