Mirroring Your Code to GitHub and GitLab Simultaneously
Update, October 2026: I'm keeping this article as I wrote it in 2022. Some things have changed since then, so read Before You Try This Today before you copy the commands.
Context
In a professional environment, relying on a single Git hosting provider introduces availability, policy, and vendor-lock risks. In my workflows, I adopted a multi-platform Git strategy to ensure redundancy, portability, and CI/CD continuity across providers.
This document captures a real-world implementation, including edge cases and failure modes encountered during setup.
The Objective
To configure a single local Git repository to push to both GitHub and GitLab using one command (git push origin main), ensuring redundancy and synchronized CI/CD across platforms. Moving from a single-cloud setup to a "Multi-Cloud Git" setup is a rite of passage for many developers. This tutorial shows how to do that, including avoiding tricky roadblocks.
The Setup
Step 1: Establish your "Origin"
First, ensure your primary remote (usually GitHub) is set up.
git remote add origin git@github.com:username/repo.git
Example:
zettelkasten on main
❯ git remote add origin git@github.com:hemroda/zettelkasten.git
error: remote origin already exists.
Step 2: The "Stacking" Technique
To push to two places, you don't add a new remote name; you add a push URL to the existing one.
git remote set-url --add --push origin git@gitlab.com:username/repo.git
In our example:
zettelkasten on main
❯ git remote set-url --add --push origin git@gitlab.com:hemroda/zettelkasten.git
The "First" Gotcha: When you add a push URL for the first time, Git stops using the default "fetch" URL for pushing. Indeed if you check the remote URLs, you'll see only the push URL for GitLab:
zettelkasten on main
❯ git remote -v
origin git@github.com:hemroda/zettelkasten.git (fetch)
origin git@gitlab.com:hemroda/zettelkasten.git (push)
You must explicitly re-add your GitHub URL to the push list so both are present:
git remote set-url --add --push origin git@github.com:hemroda/zettelkasten.git
Now, if you check the remote URLs again, you'll see both GitHub and GitLab URLs:
zettelkasten on main
❯ git remote -v
origin git@github.com:hemroda/zettelkasten.git (fetch)
origin git@gitlab.com:hemroda/zettelkasten.git (push)
origin git@github.com:hemroda/zettelkasten.git (push)
Step 3: Configure Authentication (The SSH Shortcut)
Using SSH for both is the smoothest experience. To add your key to GitLab quickly on macOS:
- Copy your key:
pbcopy < ~/.ssh/id_ed25519.pub - Paste it into GitLab Settings > SSH Keys.
- Test it:
ssh -T git@gitlab.com
Step 4: Resolving the "Diverged History" Conflict
When you try to push to GitLab, you might encounter a "Diverged History" conflict.
zettelkasten on main
❯ git push
To gitlab.com:hemroda/zettelkasten.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to 'gitlab.com:hemroda/zettelkasten.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
Everything up-to-date
If you created your GitLab repo with a README or .gitignore, or other files, it has a history your local machine doesn't know about. A standard push will be rejected.
The Fix:
- Fetch the GitLab-specific data:
git fetch git@gitlab.com:hemroda/zettelkasten.git main
- Merge it into your local branch:
git merge FETCH_HEAD --allow-unrelated-histories
(The --allow-unrelated-histories flag is the secret sauce here; it forces Git to combine two projects that didn't start from the same commit.)
Summary of "Gotchas"
| Gotcha | Why it happens | The Fix |
|---|---|---|
| The "Replacement" Bug | Adding one push URL often hides the default one. | Always verify with git remote -v and re-add the primary URL. |
| Protocol Mismatch | Using HTTPS for one and SSH for the other causes inconsistent login prompts. | Stick to SSH for both for a "silent" push experience. |
| Push Rejected | GitLab often has an initial commit (README) that your local Git doesn't have. | Use fetch followed by merge --allow-unrelated-histories. |
The Final Result
Now, your workflow is simply:
git add .
git commit -m "Feature complete"
git push origin main
The terminal will show two separate progress bars—one for GitHub and one for GitLab.
Before You Try This Today
Added on 2026-10-20. The article above is unchanged since 2022-01-31; these are the gotchas you may hit if you follow it today.
- Fetch and pull only talk to GitHub. Git pushes to every
pushurl, but it only fetches from the firsturl. A commit made directly on GitLab (a web edit, a merge request merged there) never reaches your machine withgit pull. Merge on one side only, or fetch GitLab explicitly. - The two remotes can drift apart silently. Each push URL is a separate push. If one fails (expired key, protected branch, outage), the other still succeeds. Read the whole
git pushoutput, not just the last line. - The
--allow-unrelated-historiesmerge goes to both sides. The merge commit and GitLab's README end up on GitHub too. To avoid it, create the GitLab project empty (untick "Initialize repository with a README"). - Your prompt icons won't render for everyone. The
mainsegments in the terminal snippets come from a Nerd Font, so most readers see an empty box there.
The Fix: One Primary Host and an Automatic Mirror
Both gotchas come from pushing to two places yourself. What I'd do today is pick one host as the source of truth and let a server copy every change to the other. You push and pull only from the primary. The other host is a read-only copy.
If GitLab is the primary: use GitLab's built-in push mirroring, available on GitLab Free.
- On GitHub, create a fine-grained personal access token with read and write access to the repository's contents (and Workflows, if the repo has a
.github/workflowsfolder). - In GitLab, go to Settings > Repository > Mirroring repositories and add
https://github.com/username/repo.gitwith direction Push, your GitHub username, and the token as the password. - Every push to GitLab now reaches GitHub, usually within five minutes.
If GitHub is the primary: GitLab's pull mirroring is a paid feature, so let a GitHub Action push to GitLab instead. Add .github/workflows/mirror.yml:
name: Mirror to GitLab
on: push
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git push --force "https://oauth2:${GITLAB_TOKEN}@gitlab.com/username/repo.git" "HEAD:${GITHUB_REF}"
env:
GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }}
Store a GitLab access token with the write_repository scope as the GITLAB_TOKEN repository secret. This copies every branch and tag you push. It doesn't copy branch deletions.
Then, whichever host you chose:
- Remove the second push URL so your clone talks to the primary only, for example
git remote set-url --delete --push origin git@gitlab.com:username/repo.gitwhen GitHub is the primary. - Make the mirror read-only. On the mirror host, protect
mainso that only the mirror's token can push, and don't merge pull or merge requests there. Nobody committing on the copy is what keeps the two from drifting.
References
- git-push documentation:
pushurlaffects every push, but fetch uses only the firsturl. - GitLab: Push mirroring: the built-in alternative, including mirroring to GitHub.
- git-merge
--allow-unrelated-histories: what the flag allows.
Links checked on 2026-10-20.