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 -sfnto a temp name thenmv -T:rename()is atomic, whileln -sfnstraight ontocurrentbriefly 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-fpmand setopcache.revalidate_freqor reload, because OPcache caches by real path. Point nginx'srootat$realpath_rootso each request resolves the link. See the PHP-FPM guide. - Gunicorn and Puma: both re-exec workers on
HUPorUSR2; setExecReload=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
mainstill deploys. Protect the branch at the source.