Zero-downtime deployment tooling for servers you actually SSH into. Atomic release swaps, automatic rollback on a failed health check, and a preflight that catches the boring failures before they happen mid-deploy.
Built for the case Kubernetes is overkill for: a VPS or a cPanel box running Laravel, Django, or a Node app, deployed from GitHub Actions.
/var/www/myapp/
├── releases/
│ ├── 20260823T161500Z/ ← previous (kept for rollback)
│ └── 20260823T171200Z/ ← just deployed
├── shared/ ← survives every release
│ ├── .env
│ └── storage/
└── current -> releases/20260823T171200Z
The web server's document root points at current. Deploying means building a
new release directory beside the old one and repointing one symlink. That swap
is a rename(2), so it is atomic: no request ever sees a half-updated
document root, and rolling back is repointing the symlink at the previous
directory — which is why the old releases are kept.
The naive version of this — ln -sfn straight onto the live link — unlinks
before it recreates, and any request landing in that window gets a 404.
atomic_symlink() in scripts/deploy.sh does it properly.
APP_ROOT=/var/www/myapp \
REPO_URL=git@github.com:you/app.git \
BRANCH=main \
BUILD_CMD="composer install --no-dev -o && npm ci && npm run build" \
RELOAD_CMD="sudo systemctl reload php8.3-fpm" \
HEALTH_URL=https://example.com/up \
./deploy.sh| Variable | Default | Purpose |
|---|---|---|
APP_ROOT |
required | Deployment root holding releases/, shared/, current |
REPO_URL |
— | Clone from git. Omit and set STAGING to deploy a prepared directory |
STAGING |
— | Directory to copy from, for CI that already built the artifact |
BRANCH |
main |
Branch to deploy |
BUILD_CMD |
— | Runs inside the new release, before it goes live |
RELOAD_CMD |
— | Runs after the swap (systemctl reload php-fpm, pm2 reload) |
HEALTH_URL |
— | Must return 2xx after the swap, or the deploy rolls back |
HEALTH_TIMEOUT |
30 |
Seconds to keep retrying the health check |
SHARED_PATHS |
.env storage |
Paths symlinked into shared/ so they survive releases |
KEEP_RELEASES |
5 |
Old releases retained for rollback |
What happens when it fails. A clone or build failure aborts before the swap,
so the old release never stops serving. A failed health check after the swap
triggers a rollback: current goes back to the previous release, RELOAD_CMD
runs again, and the broken release is deliberately left on disk so you can
look at what shipped. Exit code is 1 either way.
Pruning runs last and excludes the currently-linked release explicitly rather than assuming it's the newest — after a rollback, the live release is an old one.
Verifies a server can accept a deploy before one starts. Every check maps to a real way deploys fail halfway and leave you debugging on a live box.
APP_ROOT=/var/www/myapp \
HEALTH_URL=https://example.com/up \
TLS_HOST=example.com \
./preflight.sh- Disk space on
APP_ROOT's filesystem specifically — a full/varis invisible todf /. - Free inodes. A
node_modules-heavy release exhausts inodes long before bytes, and the symptom isNo space left on devicewhiledfshows plenty free. Genuinely unpleasant to diagnose under pressure. - Write permission, tested by writing — mode bits miss read-only mounts and SELinux denials.
- Dangling
currentsymlink, which means the site is already serving nothing. - TLS expiry. Renewal automation fails silently more often than expected; the usual first symptom is a customer email.
Run it on a cron and these become tickets instead of incidents.
deploy.sh rolls back on its own when a health check fails. This is for the
other case: the deploy passed, and the problem surfaced ten minutes later.
APP_ROOT=/var/www/myapp ./rollback.sh --list # what's available
APP_ROOT=/var/www/myapp ./rollback.sh --dry-run # what it would do
APP_ROOT=/var/www/myapp ./rollback.sh # back one release
APP_ROOT=/var/www/myapp ./rollback.sh --to 20260823T161500ZIt walks the release list backwards from the live release rather than taking the second-newest, so it still does the right thing when the live release is already an old one from a previous rollback. Nothing is deleted, so rolling forward again is just another rollback in the other direction.
If the rollback target is also unhealthy it says so and stops, rather than swapping again — automatically flapping between two broken releases helps nobody.
nginx.conf— vhost for this layout. Two details that actually bite:disable_symlinks off, or nginx caches the resolved path and keeps serving the old release after a swap; and$realpath_rootforSCRIPT_FILENAME, or PHP-FPM's OPcache keys every release to one shared entry and a deploy serves a mix of old and new bytecode.deploy.service+myapp.env— systemd oneshot unit, for when the deploy user shouldn't have a login shell and CI triggers a narrowly-scoped sudo rule instead.
A reusable GitHub Actions workflow. Build on the runner, rsync the artifact, swap on the server:
jobs:
production:
uses: RootX22/deploy-forge/.github/workflows/deploy-ssh.yml@main
with:
environment: production
host: web-01.example.com
app-root: /var/www/myapp
health-url: https://example.com/up
build-command: "composer install --no-dev -o && npm ci && npm run build"
secrets:
ssh-key: ${{ secrets.DEPLOY_SSH_KEY }}
known-hosts: ${{ secrets.KNOWN_HOSTS }}Two details that matter:
concurrencywithcancel-in-progress: false. Two merges in quick succession would otherwise race on the symlink swap, and the second can finish first. Cancelling a half-finished deploy is worse than queueing behind it.- Set
known-hosts. Without it the workflow falls back tossh-keyscan, which is trust-on-first-use every run — a DNS or routing compromise redirects the deploy and the deploy key to an attacker's host. The workflow warns when the secret is missing.
Multi-stage PHP-FPM image. Composer and Node live in build stages, so neither
reaches the runtime image. Dependency manifests are copied before application
source, so editing a controller doesn't invalidate the composer install layer
— the difference between a 10-second rebuild and a four-minute one.
Runs as a non-root user, ships production OPcache settings with
validate_timestamps=0, and health-checks the FPM socket rather than an HTTP
URL — this container has no web server, so an HTTP check would be testing nginx.
sudo mkdir -p /var/www/myapp/{releases,shared,bin}
sudo chown -R deploy:deploy /var/www/myapp
curl -fsSL https://raw.githubusercontent.com/RootX22/deploy-forge/main/scripts/deploy.sh \
-o /var/www/myapp/bin/deploy.sh
chmod +x /var/www/myapp/bin/deploy.shPoint your web server's root at /var/www/myapp/current/public and put the real
.env in /var/www/myapp/shared/.env.
Deliberately small. If you're on Kubernetes you already have rolling updates and
readiness probes and none of this is for you. This is for the very large number
of applications that run on one or two boxes and are deployed by SSH, where the
realistic alternative is git pull in the document root.
MIT