Push to GitHub and your own git server
Push one commit to GitHub and a bare repository on your own VPS at the same time: two push URLs on one remote, what mirror does, and what it does not copy.
Push to GitHub and your own git server at once
You can push to GitHub and your own git server in one command. Git lets a single remote hold more than one push URL, so one git push sends the same objects to both places. What lands on the second machine is a complete copy of your history. Issues, pull request threads, Actions runs and releases stay on GitHub, because none of them live inside the repository you pushed.
There is one plain reason to do this: a git repository on a host you control is a copy that no account suspension, no billing lapse and no accidental delete on the other side can take from you. If you are not yet sure which parts of your work live in git and which parts live in the web application built on top of git, the split between git, GitHub and a git server is the thing to settle first, because it decides exactly what this setup protects.
Create the bare repository on your VPS
The receiving repository must be bare. A bare repository has no working tree, so an incoming push cannot disagree with checked out files. Push to a branch that a normal repository currently has checked out and git refuses the update, because the files on disk and the index would no longer match HEAD. A bare repository is simply the directory that would otherwise be called .git, stored on its own.
Give these repositories their own user. That user needs no interactive shell, only the ability to run git commands over SSH.
sudo apt update && sudo apt install -y git
sudo useradd --create-home --home-dir /srv/git --shell /usr/bin/git-shell git
sudo install -d -m 700 -o git -g git /srv/git/.ssh
sudo install -m 600 -o git -g git /dev/null /srv/git/.ssh/authorized_keysAppend your workstation's public key to /srv/git/.ssh/authorized_keys. That file is the whole access list for this server, so treat it the way you treat any other credential store: one key per machine, and delete the line when a machine is retired. The wider rules are in managing the SSH keys that guard both remotes.
Now create the repository itself, and check what it reports.
sudo -u git git init --bare --initial-branch=main /srv/git/project.git
sudo -u git git -C /srv/git/project.git rev-parse --is-bare-repository
ls /srv/git/project.gitrev-parse should report that the repository is bare, and the listing should show HEAD, config, objects and refs at the top level rather than a .git directory. --initial-branch=main matters more than it looks, because that flag is what writes refs/heads/main into the bare repository's HEAD, and sudo -u git git -C /srv/git/project.git symbolic-ref HEAD reads the name back. Once the repository holds commits, that name is the branch a fresh git clone checks out, so keeping it equal to the branch you actually push is what spares later clones a warning about a remote HEAD naming a branch nobody created. While the repository is still empty the name decides nothing: a clone taken before your first push reports that it cloned an empty repository and starts on whatever init.defaultBranch says on the cloning machine, not on the name stored on the server. Push once and that gap closes.
One more check before you move on. ssh git@vps.example.com with no command should be refused, with a message saying the interactive git shell is not enabled, while git operations through that same account keep working. git-shell accepts the handful of commands git needs and nothing else, so a stolen key cannot become a login.
Add your own git server as a second remote
From your existing working clone, add the VPS and push to it once.
git remote add vps ssh://git@vps.example.com/srv/git/project.git
git push vps main
git remote -v
git ls-remote --heads vpsTwo URL spellings reach the same place. ssh://git@host/srv/git/project.git carries an absolute path. The shorter git@host:project.git is relative to the login user's home directory, which here is /srv/git, so it means the same repository. Pick one spelling and use it everywhere, because git compares configured URLs as strings and treats the two forms as different remotes.
git remote -v now lists origin and vps, each with a fetch line and a push line. git ls-remote --heads vps asks the server which branches it actually holds. Compare the object id it prints for main against the output of git rev-parse main on your machine. Matching ids mean the push landed. This is the check to reach for whenever you are unsure, because it reads the server rather than trusting what the last push claimed.
One remote with two push URLs
Two remotes means two commands, and the second command is the one that eventually gets skipped. Collapse them. A remote can hold several push URLs, and git push origin main then contacts each one in turn.
git remote remove vps
git remote set-url --add --push origin git@github.com:you/project.git
git remote set-url --add --push origin ssh://git@vps.example.com/srv/git/project.git
git remote -v
git config --get-all remote.origin.pushurlRead that first set-url --add --push line again, because it is the step people skip and the failure is silent. A remote's push URL falls back to its fetch URL only while no push URL is set at all. The moment you add one, the fetch URL stops being used for pushing. Add only the VPS and every push goes to the VPS alone, GitHub quietly stops receiving anything, and git push still reports success for the one server it reached. So GitHub has to be added back explicitly, and first.
After both lines, git remote -v shows one (fetch) line and two (push) lines for origin, and git config --get-all remote.origin.pushurl prints both URLs in the order git will use them. That order is the order they sit in .git/config. To change it, run git config --unset-all remote.origin.pushurl and add them again in the order you want.
Fetching is untouched. Git fetches from remote.origin.url, which is still GitHub, so git fetch and git pull behave exactly as before.
You will see a shorter form written elsewhere: git remote set-url --add origin <vps url>, with no --push. It works, because a remote holding several url values and no push URL pushes to all of them while fetching from the first. Prefer the --push form anyway. It states what it does, and it keeps fetch pointed at exactly one server.
What happens when one side rejects the push
Git contacts the push URLs one at a time, and a failure at the first does not stop the second. So the normal result of a rejected push is not that nothing happened. One server took your commits, the other did not, and git push exits with a nonzero status after printing the rejection under the URL that produced it. Read the whole output, not the last line.
The usual cause is a non-fast-forward update: the remote branch holds a commit you do not have, so accepting yours would drop it. Git marks the ref as rejected and tells you to fetch first. A colleague pushing to GitHub is the common way this arrives, and your VPS never sees those commits, because nothing ever fetches into it.
Do not fix this by forcing. git push --force applies to every push URL on the remote, so a force meant to repair GitHub also rewrites the copy on your own server, which is the copy you were keeping in case something went wrong. Fetch, integrate, push again.
git fetch origin
git rebase origin/main
git push origin mainThe second push updates GitHub and does nothing on the VPS, which reports that it is already up to date. When you want the real state of all three copies, ask each one directly.
git rev-parse main
git ls-remote --heads git@github.com:you/project.git main
git ls-remote --heads ssh://git@vps.example.com/srv/git/project.git mainRehearse it locally before you trust it
Git does not care whether a remote is a URL or a directory, so you can reproduce the whole arrangement on one machine with no network at all.
Two push URLs, and a one-sided rejection, on local paths
cd /tmp && rm -rf demo && mkdir demo && cd demo
git init --bare --initial-branch=main hub.git
git init --bare --initial-branch=main vps.git
git init --initial-branch=main work
cd work
git config user.email you@example.com
git config user.name 'You'
git commit --allow-empty -m 'first commit'
git remote add origin /tmp/demo/hub.git
git remote set-url --add --push origin /tmp/demo/hub.git
git remote set-url --add --push origin /tmp/demo/vps.git
git push origin main
git ls-remote --heads /tmp/demo/hub.git
git ls-remote --heads /tmp/demo/vps.gitThe work repository is created with git init rather than cloned, because cloning a bare repository that has no commits yet gives you a branch named after your own init.defaultBranch instead of the one on the server. Both listings should report the same object id for refs/heads/main. Now make the two sides disagree, the way a colleague pushing to GitHub would. This second clone is taken after the first push, so hub.git has history and the clone lands on main.
cd /tmp/demo
git clone hub.git other
cd other
git config user.email them@example.com
git config user.name 'Them'
git commit --allow-empty -m 'made elsewhere'
git push origin main
cd /tmp/demo/work
git commit --allow-empty -m 'mine'
git push origin main || echo "push exit status: $?"Read what git prints for each URL, then list both repositories again. hub.git refused the update and kept the other commit. vps.git accepted yours. Your two copies of history now hold different commits, and the exit status is nonzero even though half of the push succeeded.
How --mirror differs from a normal push
git push origin main sends the objects reachable from your local main and asks each server to move its main to that commit. It refuses unless the move is a fast-forward, and it removes nothing. No other ref on the remote is touched.
git push --mirror origin is a different operation wearing the same word. It uses the refspec +refs/*:refs/*, and that changes four things:
- Everything under
refs/is sent, including tags, notes and your own remote-tracking refs. - Every update is forced, so a non-fast-forward does not stop it.
- Any ref that exists on the far side and not in your repository is deleted.
- It ignores
push.defaultand refuses to accept a refspec.
So never run --mirror against the remote you just gave two push URLs. It applies to both of them, and one mirror push from a slightly stale clone deletes branches on GitHub that nobody asked it to delete.
The safe shape for a one-way copy is a mirror clone that lives on the VPS and pulls from GitHub on its own.
sudo -u git git clone --mirror git@github.com:you/project.git /srv/git/project-mirror.git
sudo -u git git -C /srv/git/project-mirror.git remote update --prunegit clone --mirror sets the fetch refspec to +refs/*:refs/* and sets remote.origin.mirror to true, so the clone tracks every ref instead of the branches you asked for. Run the second line from a systemd timer and the copy follows GitHub, including branch deletions, which is what --prune is for. A private repository needs credentials for that user, usually a read-only deploy key on GitHub with the private half under /srv/git/.ssh/. One warning: remote.origin.mirror also means a plain git push inside that clone mirrors back to GitHub. Do not push from a mirror clone unless overwriting the origin is exactly what you want.
Why refs, tags and branches drift apart
Tags do not travel with an ordinary push. git push origin main sends commits, so a tag named v1.0.0 sits on your laptop and on neither server. git push --follow-tags origin main adds the annotated tags reachable from what you pushed, and lightweight tags still need git push --tags. Set git config --global push.followTags true and the first behaviour becomes your default.
Branches that exist only on GitHub never reach your server. A branch created in the web interface, or the merge commit GitHub writes when it merges a pull request, arrives in your clone as a remote-tracking ref such as origin/feature. Pushing does not send remote-tracking refs, so the VPS never learns of it. Check the branch out locally and push it, or accept that the second copy holds only the branches you personally work on.
Deletes are lopsided. git push origin --delete feature removes the branch from both push URLs at once, while git branch -d feature removes it from neither. The two operations look similar and do opposite amounts of damage.
GitHub also creates refs you cannot write back. A mirror clone of a GitHub repository picks up refs/pull/*, one ref for each pull request. Copy that mirror to your own server and those refs come with it. Try to push them back to GitHub and the update is rejected, because that namespace is generated by GitHub and not writable by you.
The habit that keeps both copies honest is to pick one direction and stay in it. Either your workstation is the source and every push reaches both remotes, or the VPS mirrors GitHub on a timer and nobody pushes into it. Running both patterns against the same repository is how refs drift, and the drift is silent until the day you need the second copy.
What lives only on GitHub
Your repository holds commits, branches, tags and the files you committed, which includes your .github/workflows definitions. Everything the web application stores around the repository is separate: issue threads, pull request discussions and review comments, Actions run history and secrets, release notes and uploaded release binaries, branch protection rules, webhooks and deploy keys. A second git copy holds none of it. The wiki is the one easy exception, because GitHub serves it as an ordinary git repository at project.wiki.git, which you can clone and push to your VPS like any other. For issues and pull requests, export through the GitHub API on a schedule or accept that they are gone.
Hosting deserves its own note. If GitHub Pages is serving your site from the repository, your VPS copy protects the source and not the site, so the pages stop when the account does. The same applies to the quotas described in what a free GitHub account actually includes: storage and Actions minutes belong to the account, not to your history, and your own copy inherits none of them.
If you want issues and a web interface on your own side too, a bare repository is not the answer, because a bare repository is storage and nothing more. That job belongs to a forge, and the options for running a git forge on your own VPS covers what each one costs you in memory and upkeep. The two push URLs still work underneath it, with the forge's SSH URL in place of the raw path.
FAQ
Does one git push really reach GitHub and my own server?
Yes, as long as the remote holds both URLs. Run git config --get-all remote.origin.pushurl and count the lines. It must print two. If it prints one, the push URL default was replaced by that single entry and the other server has been receiving nothing, with no warning anywhere. Confirm from the far end with git ls-remote --heads <url> main against each URL and compare both object ids against git rev-parse main.
What happens if GitHub rejects the push but my own server accepted it?
Git pushes to each URL in turn and does not stop at the first failure, so that outcome is normal rather than a bug. The command exits nonzero and prints the rejection under the URL that produced it, while the other copy already holds your commit. Recover with git fetch origin, then rebase or merge, then push again. Avoid git push --force here, because a force applies to every push URL and rewrites the backup copy along with the one you meant to fix.
Should I use git push --mirror to keep the second copy in sync?
Not against a remote that also points at GitHub. --mirror forces every ref and deletes any ref the far side has that you do not, so a single push from a stale clone can remove branches on GitHub. For a one-way copy, run git clone --mirror on the VPS against the GitHub URL and refresh it with git remote update --prune on a timer. Never run a plain git push inside a mirror clone, because remote.origin.mirror makes that push overwrite the origin.
Does this back up my issues and pull requests?
No. A push copies commits, branches, tags and committed files. Issue threads, pull request reviews, Actions run history and secrets, release notes and uploaded assets live in GitHub's database rather than in the repository, so they are not in the copy on your server. The wiki is the exception, since GitHub exposes it as a separate git repository at project.wiki.git. Export the rest through the GitHub API if losing it would hurt.