Skip to content

Repository files navigation

deploy-forge

CI Release License: MIT Shell: Bash

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.

The deployment model

/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.

scripts/deploy.sh

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.

scripts/preflight.sh

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 /var is invisible to df /.
  • Free inodes. A node_modules-heavy release exhausts inodes long before bytes, and the symptom is No space left on device while df shows plenty free. Genuinely unpleasant to diagnose under pressure.
  • Write permission, tested by writing — mode bits miss read-only mounts and SELinux denials.
  • Dangling current symlink, 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.

scripts/rollback.sh

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 20260823T161500Z

It 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.

examples/

  • 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_root for SCRIPT_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.

workflows/deploy-ssh.yml

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:

  • concurrency with cancel-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 to ssh-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.

docker/Dockerfile.php

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.

Server setup

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.sh

Point your web server's root at /var/www/myapp/current/public and put the real .env in /var/www/myapp/shared/.env.

Scope

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.

Licence

MIT

About

Zero-downtime deployment toolkit for SSH-managed servers — atomic release swaps, health-checked automatic rollback, manual rollback, and a preflight that catches deploy failures before they start.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages