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) orsudo 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
- Create the user that will own both the repository and the deployed files:
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /srv/git /srv/app
sudo chown -R deploy:deploy /srv/git /srv/app- Create the bare repository. A bare repo has no working tree, which is exactly what you want on the receiving end of a push:
sudo -u deploy git init --bare /srv/git/app.git- Confirm the layout:
sudo -u deploy ls -la /srv/git/app.gitYou 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:
sudo -u deploy git --git-dir=/srv/git/app.git symbolic-ref HEAD refs/heads/main- On your workstation, add the remote and push your existing code:
git remote add deploy deploy@203.0.113.10:/srv/git/app.git
git push deploy mainThe 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.
- Create the hook file:
sudo -u deploy nano /srv/git/app.git/hooks/post-receive- Paste this content:
#!/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- Make it executable. A hook that is not executable is silently ignored, which is the most common reason a deploy "does nothing":
sudo -u deploy chmod +x /srv/git/app.git/hooks/post-receive- If the hook restarts a service, the
deployuser needs permission to do that without a password. Create a sudoers drop-in:
sudo visudo -f /etc/sudoers.d/deploy-restartAdd one line, replacing the unit name with yours:
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.serviceTip: 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
- Create the target directory and give the deploy user ownership:
sudo mkdir -p /srv/app/current
sudo chown -R deploy:deploy /srv/app- Put environment files and secrets in place before the first deploy. They stay untouched because Git only overwrites tracked files:
sudo -u deploy nano /srv/app/current/.env
sudo chmod 600 /srv/app/current/.env- 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.
- 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
- From your workstation, push to the deploy remote:
git push deploy main- 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- If the push is rejected, pull or rebase first, then push again:
git pull --rebase origin main
git push deploy mainWarning: 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
- Confirm the deployed commit matches what you pushed:
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.
- Check the service state and recent logs:
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'- Request the app over HTTP to confirm it serves the new build:
curl -I https://example.com- Make a trivial change, commit it, push again and confirm the hash in
/srv/app/currentchanges. 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 withchmod +x, and checkjournalctl -u sshfor hook errors. remote: fatal: not a git repository— the hook ran with the wrong working directory. Always pass absolute--git-dirand--work-treepaths, as in the script above.sudo: a terminal is required to read the password— the deploy user cannot runsystemctlnon-interactively. Add the NOPASSWD sudoers line and validate withsudo -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
WorkingDirectoryin the unit file with/srv/app/current. - Permission denied on checkout — the repository and the target directory must both be owned by
deploy. Fix withsudo chown -R deploy:deploy /srv/git /srv/app.
Next steps
- Put the deployed app behind a process manager: Deploy a Node.js app with PM2 behind Nginx.
- Running Python instead: Deploy a Python app with Gunicorn, systemd and Nginx.
- Container-based releases: Deploy a Self-Hosted App with Docker Compose from Scratch.
- Keep secrets out of Git: Manage secrets and environment variables in Docker Compose.
