Docker & DevOps

Git push-to-deploy with a bare repository and post-receive hook

Set up a bare Git repository on your Linux VPS and a post-receive hook so that every push checks out your code into the live directory and restarts the service.

IntermediateLinux server6 min read

A git push to deploy setup turns git push into your release command: the server receives the push, checks the code out into the live directory and restarts your service. This guide builds that on a Linux VPS using a bare repository and a post-receive hook, with no CI runner and no third-party service in the middle.

You need shell access to the server, Git installed on both ends, and a rough idea of where your app should live. The example uses the host 203.0.113.10, the SSH user deploy, the app path /srv/app/current and the branch main.

Before you start

  • A Linux VPS running Ubuntu 22.04/24.04, Debian 12 or AlmaLinux/Rocky 8–9, with root or sudo access.
  • Git on your workstation and on the server: sudo apt install git (Debian/Ubuntu) or sudo dnf install git (AlmaLinux/Rocky).
  • A dedicated system user for deploys. Do not push as root.
  • A service to restart after deploy. The examples use myapp.service; if you do not have one yet, see the Node.js and Python guides linked at the end.
  • The server's IP address and SSH access on port 22 (or your custom SSH port — this is separate from the RDP port used on Windows plans).

Warning: The hook below runs git checkout -f, which overwrites tracked files in the deploy directory. Keep anything you edit by hand — .env, uploads, local config — outside version control or in a path the checkout does not touch.

Create the deploy user and the bare repository

  1. Create the user that will own both the repository and the deployed files:
Bash
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /srv/git /srv/app
sudo chown -R deploy:deploy /srv/git /srv/app
  1. Create the bare repository. A bare repo has no working tree, which is exactly what you want on the receiving end of a push:
Bash
sudo -u deploy git init --bare /srv/git/app.git
  1. Confirm the layout:
Bash
sudo -u deploy ls -la /srv/git/app.git

You should see HEAD, config, objects, refs and a hooks directory. The default branch in a fresh bare repo may be master; if your project uses main, set it now:

Bash
sudo -u deploy git --git-dir=/srv/git/app.git symbolic-ref HEAD refs/heads/main
  1. On your workstation, add the remote and push your existing code:
Bash
git remote add deploy deploy@203.0.113.10:/srv/git/app.git
git push deploy main

The push succeeds, but nothing is deployed yet — the code only sits in the repository objects. The hook is next.

Write the post-receive hook

The post-receive hook runs on the server after the push is accepted. It reads the old revision, new revision and ref name from standard input, one line per pushed ref.

  1. Create the hook file:
Bash
sudo -u deploy nano /srv/git/app.git/hooks/post-receive
  1. Paste this content:
Bash
#!/bin/bash
set -euo pipefail

GIT_DIR=/srv/git/app.git
TARGET=/srv/app/current
BRANCH=refs/heads/main

while read -r oldrev newrev refname; do
  if [ "$refname" != "$BRANCH" ]; then
    echo "Skipping $refname"
    continue
  fi

  echo "Deploying $newrev to $TARGET"
  mkdir -p "$TARGET"
  git --git-dir="$GIT_DIR" --work-tree="$TARGET" checkout -f main

  cd "$TARGET"
  if [ -f package.json ]; then
    npm ci --omit=dev
  fi

  sudo systemctl restart myapp.service
  echo "Deploy finished for $newrev"
done
  1. Make it executable. A hook that is not executable is silently ignored, which is the most common reason a deploy "does nothing":
Bash
sudo -u deploy chmod +x /srv/git/app.git/hooks/post-receive
  1. If the hook restarts a service, the deploy user needs permission to do that without a password. Create a sudoers drop-in:
Bash
sudo visudo -f /etc/sudoers.d/deploy-restart

Add one line, replacing the unit name with yours:

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service

Tip: Keep the hook short and put build logic in a script inside the repository, for example deploy.sh, and call it from the hook. That way the deploy steps are versioned and reviewable like any other code.

Prepare the deploy directory and service

  1. Create the target directory and give the deploy user ownership:
Bash
sudo mkdir -p /srv/app/current
sudo chown -R deploy:deploy /srv/app
  1. Put environment files and secrets in place before the first deploy. They stay untouched because Git only overwrites tracked files:
Bash
sudo -u deploy nano /srv/app/current/.env
sudo chmod 600 /srv/app/current/.env
  1. Point your systemd unit, Nginx site or process manager at /srv/app/current. If you are still choosing a stack, the Node.js with PM2 and Nginx guide and the Python with Gunicorn and systemd guide both assume this directory layout.
  1. If the app runs in containers instead, keep the checkout as the source of truth and rebuild from it — see Deploy a Self-Hosted App with Docker Compose.

Push and watch the deploy run

  1. From your workstation, push to the deploy remote:
Bash
git push deploy main
  1. The terminal shows the hook output as remote: lines, including the commit being deployed and the restart result:
remote: Deploying 4f1c9a2... to /srv/app/current
remote: Deploy finished for 4f1c9a2...
To 203.0.113.10:/srv/git/app.git
   a91b3d0..4f1c9a2  main -> main
  1. If the push is rejected, pull or rebase first, then push again:
Bash
git pull --rebase origin main
git push deploy main

Warning: If you ever need git push --force, remember the hook checks out whatever the branch points to. A force push to main will overwrite the live directory with the older tree.

Check it works

  1. Confirm the deployed commit matches what you pushed:
Bash
ssh deploy@203.0.113.10 'git --git-dir=/srv/git/app.git rev-parse main'
ssh deploy@203.0.113.10 'cd /srv/app/current && git log -1 --oneline'

Both should print the same hash.

  1. Check the service state and recent logs:
Bash
ssh deploy@203.0.113.10 'systemctl status myapp.service --no-pager'
ssh deploy@203.0.113.10 'journalctl -u myapp.service -n 50 --no-pager'
  1. Request the app over HTTP to confirm it serves the new build:
Bash
curl -I https://example.com
  1. Make a trivial change, commit it, push again and confirm the hash in /srv/app/current changes. That proves the hook fires on every push, not just the first.

Troubleshooting

  • Push succeeds but nothing changes on disk — the hook is not executable or has a shebang typo. Run ls -l /srv/git/app.git/hooks/post-receive, fix with chmod +x, and check journalctl -u ssh for hook errors.
  • remote: fatal: not a git repository — the hook ran with the wrong working directory. Always pass absolute --git-dir and --work-tree paths, as in the script above.
  • sudo: a terminal is required to read the password — the deploy user cannot run systemctl non-interactively. Add the NOPASSWD sudoers line and validate with sudo -u deploy sudo systemctl restart myapp.service.
  • error: failed to push some refs / non-fast-forward — your local branch is behind the remote. Rebase onto the remote branch and push again; avoid force pushes to a branch that is already deployed.
  • Site still serves old code after a successful deploy — the service was not restarted, or it runs from a different path. Compare WorkingDirectory in the unit file with /srv/app/current.
  • Permission denied on checkout — the repository and the target directory must both be owned by deploy. Fix with sudo chown -R deploy:deploy /srv/git /srv/app.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.