Skip to content

guide

Deploy with git push and atomic releases

You don't need a deploy platform or an agent for this. A bare repository, a hook and a symlink give you reproducible releases and a one-command rollback on any Linux VPS.

The layout

Everything lives under one directory owned by a non-root deploy user:

/srv/app/
  repo.git/                  bare repository you push to
  releases/
    20261027T101500Z/        one directory per deploy
    20261027T143212Z/
  shared/
    .env                     secrets, never in git
    uploads/                 user files that survive deploys
  current -> releases/20261027T143212Z

Your web server or systemd unit points at /srv/app/current. A deploy builds a new directory under releases/, links in the shared files, then repoints current.

1. Create the deploy user and repository

sudo adduser --disabled-password --gecos '' deploy     # Debian/Ubuntu
# sudo useradd -m deploy                               # AlmaLinux/Rocky
sudo mkdir -p /srv/app/{releases,shared}
sudo chown -R deploy:deploy /srv/app
sudo -u deploy git init --bare /srv/app/repo.git
sudo -u deploy mkdir -p /home/deploy/.ssh
sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys < your_key.pub

Locally, add the remote:

git remote add production [email protected]:/srv/app/repo.git

2. The post-receive hook

Git runs this after every push, with the pushed refs on stdin. It deploys only the main branch.

#!/bin/bash
# /srv/app/repo.git/hooks/post-receive   (chmod +x, owned by deploy)
set -euo pipefail
APP=/srv/app
KEEP=5

while read -r old new ref; do
  [ "$ref" = "refs/heads/main" ] || { echo "skip $ref"; continue; }

  REL="$APP/releases/$(date -u +%Y%m%dT%H%M%SZ)"
  echo "==> building $new into $REL"
  mkdir -p "$REL"
  git --work-tree="$REL" --git-dir="$APP/repo.git" checkout -f "$new"

  ln -sfn "$APP/shared/.env"    "$REL/.env"
  ln -sfn "$APP/shared/uploads" "$REL/uploads"

  cd "$REL"
  # build step: pick yours
  npm ci --omit=dev && npm run build
  # python -m venv .venv && .venv/bin/pip install -r requirements.txt
  # composer install --no-dev --optimize-autoloader

  ./bin/migrate          # must be safe for the previous release too

  echo "==> switching current"
  ln -sfn "$REL" "$APP/current.tmp"
  mv -Tf "$APP/current.tmp" "$APP/current"

  sudo /bin/systemctl reload app.service

  ls -1dt "$APP"/releases/*/ | tail -n +$((KEEP + 1)) | xargs -r rm -rf
  echo "==> deployed $(git --git-dir="$APP/repo.git" log -1 --format='%h %s' "$new")"
done

Two details carry the whole design:

  • set -euo pipefail: if the build or migration fails, the script stops before the switch, and the old release keeps serving.
  • ln -sfn to a temp name then mv -T: rename() is atomic, while ln -sfn straight onto current briefly removes the link. Requests never see a missing directory.

Grant the reload, and only the reload, with sudo:

# /etc/sudoers.d/deploy   (edit with visudo -f)
deploy ALL=(root) NOPASSWD: /bin/systemctl reload app.service

3. Push

$ git push production main
remote: ==> building 4f1c2ab into /srv/app/releases/20261027T143212Z
remote: added 212 packages in 6s
remote: ==> switching current
remote: ==> deployed 4f1c2ab Add invoice export

Build output streams back to your terminal, so a failure is visible where you pushed from.

Rollback

cd /srv/app
ls -1t releases | head -3
ln -sfn releases/20261027T101500Z current.tmp && mv -Tf current.tmp current
sudo systemctl reload app.service

Seconds, no rebuild. Wrap it in a bin/rollback script so the panicked version of you does not have to type it.

Reload without dropping requests

The symlink switch is only half the job; the running process must pick up the new code.

  • PHP-FPM: systemctl reload php8.3-fpm and set opcache.revalidate_freq or reload, because OPcache caches by real path. Point nginx's root at $realpath_root so each request resolves the link. See the PHP-FPM guide.
  • Gunicorn and Puma: both re-exec workers on HUP or USR2; set ExecReload= in the unit accordingly, and start them with --chdir /srv/app/current.
  • Node: run two instances behind nginx and restart them one at a time, or use a process manager with graceful reload.

Deploying from CI instead

If tests should pass before anything ships, let CI do the push. Generate a key just for CI and restrict it on the server:

# /home/deploy/.ssh/authorized_keys
restrict,command="git-receive-pack '/srv/app/repo.git'" ssh-ed25519 AAAA... ci-deploy

That key can push to the repository and do nothing else: no shell, no port forwarding. In the pipeline:

- name: Deploy
  if: github.ref == 'refs/heads/main'
  run: |
    install -m 600 /dev/null ~/.ssh/id_ed25519
    echo "$DEPLOY_KEY" > ~/.ssh/id_ed25519
    echo "$KNOWN_HOSTS" >> ~/.ssh/known_hosts
    git push [email protected]:/srv/app/repo.git HEAD:main

Pin known_hosts from a value you captured yourself with ssh-keyscan; skipping host key checks in CI defeats the point of the key. If you run your own runners, a CI runner on a London VPS keeps the push inside the same region.

Pitfalls

  • Building on the production box uses its CPU and RAM. On a 2 GB Spark, a large frontend build can trigger the OOM killer; build in CI and push artefacts, or size up to Boost.
  • Anything written into the release directory at runtime vanishes on the next deploy. Logs, caches and uploads belong in shared/ or /var.
  • Pushing a force-rewritten main still deploys. Protect the branch at the source.

Questions

Why use a symlink instead of git pull in place?

A pull in place changes files while the app is serving requests, and a failed build leaves a half-updated tree. Building in a fresh directory and switching a symlink makes the change a single rename: the old release is complete until the instant the new one is.

Can CI deploy without storing a root key?

Yes. Create a dedicated deploy user, give the CI job an SSH key that only that user accepts, and restrict the key with a forced command in authorized_keys. The deploy user owns the releases directory and nothing else.

How do I handle database migrations?

Run them from the hook after the build succeeds and before the symlink switch, and write migrations that the previous release can also live with. That way a rollback of code never needs a rollback of schema.

How many releases should I keep?

Five is plenty. Enough to roll back past a bad deploy that went unnoticed for a day, few enough that disk usage stays predictable.