Automated Zero-Downtime Deployments with GitHub Actions and SSH
Building an automated continuous delivery pipeline that pulls images, validates health, and rotates containers cleanly.
1. The Anatomy of a Flawless Deployment Pipeline
A deployment pipeline that merely runs docker compose up on every push to main is not a deployment pipeline, it is a scheduled outage generator. The gap between 'it builds' and 'it deploys safely' is filled by a sequence of concrete, verifiable steps: build and tag an immutable image, push it to a registry, SSH into the target host, pull the new image, run migrations if needed, swap containers with a healthcheck gate, and verify the new version actually serves traffic before declaring success.
The single most important design decision is using immutable, digest-pinned images rather than deploying by re-pulling a floating :latest tag. Tag every build with the Git SHA (and optionally the run number), push that exact tag, and have the server-side deploy script pull that specific tag rather than latest. This makes every deployment traceable to an exact commit, makes rollback a one-line change (redeploy the previous SHA's tag), and eliminates an entire class of 'it worked in CI but the server pulled a different image than I think it did' confusion.
GitHub Actions handles the build-and-push half naturally with its Docker Buildx integration and GHCR (GitHub Container Registry), which requires no separate registry account and integrates with the repository's existing permissions. The deploy half — actually connecting to the VPS and swapping containers — is best done over SSH with a dedicated deploy key stored as a repository secret, invoking a deploy script that already lives on the server rather than shipping deployment logic inline in the workflow YAML.
Keeping deployment logic in a server-side script rather than the workflow file also means you can test and iterate on it directly via SSH without pushing a commit and waiting on CI each time, and it keeps the workflow YAML itself small and stable, changing only when the high-level pipeline shape changes rather than every time a deploy step's implementation detail needs a tweak.
2. Safe Database Migrations During Deployments
Migrations are the single riskiest part of any automated deploy, because unlike swapping a stateless container, a migration mutates shared state that the currently-running old version and the incoming new version both touch simultaneously during the rollout window. Running migrations as a build step or inside the application's own startup code means every replica racing to start applies the migration concurrently, which for anything beyond the simplest schema change risks lock contention or partial application.
The safe pattern is running migrations as a distinct, single-execution step in the deploy script, before the new containers start serving traffic, using a tool that supports advisory locking so a second concurrent invocation waits rather than corrupting state. Prisma Migrate, Drizzle's migrator, and node-pg-migrate all support this; the deploy script should run migrate deploy (or equivalent) as one-shot command against the production database, check its exit code, and abort the deployment entirely if it fails, rather than proceeding to swap containers on top of a half-migrated schema.

