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:

  1. Copy your key: pbcopy < ~/.ssh/id_ed25519.pub
  2. Paste it into GitLab Settings > SSH Keys.
  3. 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:

  1. Fetch the GitLab-specific data:
git fetch git@gitlab.com:hemroda/zettelkasten.git main
  1. 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 first url. A commit made directly on GitLab (a web edit, a merge request merged there) never reaches your machine with git 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 push output, not just the last line.
  • The --allow-unrelated-histories merge 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 main segments 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.

  1. 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/workflows folder).
  2. In GitLab, go to Settings > Repository > Mirroring repositories and add https://github.com/username/repo.git with direction Push, your GitHub username, and the token as the password.
  3. 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.git when GitHub is the primary.
  • Make the mirror read-only. On the mirror host, protect main so 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

Links checked on 2026-10-20.