If you've ever clicked deploy
and watched a checkout page 502 for 90 seconds while users typed their card numbers, you already know why this question gets asked. The good news: setting up zero downtime deployments on a single VPS doesn't require Kubernetes, a service mesh, or a sidecar to your sidecar. The easiest path β and the one most production teams actually use β is the atomic-symlink pattern: deploy a fresh release into a sibling directory, run health checks, then atomically flip a symbolic link to point at it. The flip takes a few milliseconds, and rollback is a second symlink flip back to the previous release.
This guide shows you the smallest possible setup that gets you to true zero downtime β defined as RTO under one second and zero dropped requests β without rewriting your stack. If you need to compare patterns like Blue/Green, Canary, and Rolling for a multi-server fleet, read our zero downtime deployment strategies comparison instead. This post is the beginner-friendly setup for a single server.
The Atomic Symlink Pattern in 30 Seconds
Every deploy creates a new timestamped directory and updates a single symlink:
/var/www/myapp/
βββ releases/
β βββ 20260514-141503/ β previous release (still on disk for rollback)
β βββ 20260515-091227/ β new release deployed here first
β βββ 20260515-103340/ β latest release
βββ shared/ β persistent files (.env, uploads, logs)
βββ current β releases/20260515-103340 β this symlink is what nginx serves
Your web server (nginx, Apache, Caddy) is configured to serve /var/www/myapp/current. When you deploy, the deploy script does this:
- Create
releases/<timestamp>/ - Upload code into it
- Symlink
shared/.env,shared/storage, etc. into the new release - Run build steps and health checks inside the new release directory
- Atomically swap the
currentsymlink:ln -nfs releases/<new> current - Reload the app (graceful reload, not restart)
The ln -nfs operation is a single rename() system call on Linux β it's atomic at the filesystem level. No request sees a half-deployed state. No build step touches the live directory. Rollback is the same ln -nfs command pointed at the previous release.
That's it. That's the entire mechanism. Everything else in this article is detail.
What Zero Downtime
Actually Means (the RPO/RTO numbers)
Before you set anything up, pin the goal to numbers SREs actually measure:
- RTO (Recovery Time Objective): maximum acceptable time to restore service after a bad release. Atomic-symlink deploys put this at < 1 second β the time to flip the symlink back.
- RPO (Recovery Point Objective): maximum acceptable data loss. For a stateless web tier this is 0 β no in-flight transactions are lost because the old process keeps serving its connections until they drain.
- Deployment SLO: the error-rate budget you accept during a release. A reasonable target: p99 latency stays under 200 ms and 5xx rate stays under 0.1% throughout the deploy window.
If your current process can't hit those numbers, you don't have zero downtime yet β you have short downtime. The difference matters the day an SLA credit shows up on a customer invoice.
The Prerequisites Most Tutorials Skip
The atomic-symlink pattern only delivers true zero downtime if these four things are true. Audit them before you flip your first symlink:
1. Your app is stateless. Sessions, cache, uploads, and queue state cannot live on the local filesystem inside the release directory. Move sessions to Redis, uploads to S3 or a shared/ directory, and logs to stdout (or shared/logs). If you skip this step, your users get logged out every deploy.
2. Your .env and persistent files live in shared/. Anything that must survive across releases β env files, SQLite databases, user uploads, persistent caches β lives in /var/www/myapp/shared/ and is symlinked into each new release at deploy time.
3. Your database migrations are backward-compatible. This is the gotcha that bites every team eventually. During the symlink flip, the old code and new code briefly run against the same database (old workers are still finishing requests when new workers start). Migrations must follow the expandβcontract pattern:
- Expand release: add new columns/tables as nullable. Deploy code that writes to both old and new schema. Never drop columns in the same release.
- Contract release: in a later deploy, after the old code is fully gone, remove the deprecated columns.
For a deeper treatment of schema migrations under load, see our database migration strategies for zero-downtime deployments.
4. Your web server reloads gracefully. nginx -s reload, apachectl graceful, and systemctl reload php-fpm all finish in-flight requests before swapping to the new release. A hard restart drops connections. Use reload. Always.
The 3-2-1 Rule for Safe Releases
Before you touch production, adopt the 3-2-1 release rule β three environments, two verification steps, one instant rollback path:
- 3 environments the code passes through: development β staging β production
- 2 verification steps before flipping the symlink: an automated test suite and a post-build smoke test (curl the health endpoint inside the new release directory)
- 1 instant rollback path that requires no rebuild β for atomic deploys, that's
ln -nfs releases/<previous> current && systemctl reload nginx
Skip any leg and you're gambling that the next deploy isn't the one that takes you down.
Setting It Up the Hard Way: Bare Shell Script
If you want to understand exactly what's happening, here's the minimal Bash version. It runs on any Linux host with SSH access and demonstrates every primitive.
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp
RELEASE=$(date -u +%Y%m%d-%H%M%S)
NEW_RELEASE="$APP_DIR/releases/$RELEASE"
# 1. Create the new release directory
mkdir -p "$NEW_RELEASE"
# 2. Sync code from your Git checkout into it
rsync -a --delete --exclude='.git' /tmp/build/ "$NEW_RELEASE/"
# 3. Symlink shared files (env, uploads, logs)
ln -nfs "$APP_DIR/shared/.env" "$NEW_RELEASE/.env"
ln -nfs "$APP_DIR/shared/storage" "$NEW_RELEASE/storage"
ln -nfs "$APP_DIR/shared/logs" "$NEW_RELEASE/logs"
# 4. Build inside the new release (NOT in current/)
cd "$NEW_RELEASE"
npm ci --omit=dev
npm run build
# 5. Smoke test before flipping
node -e "require('./dist/health')" || { echo "Health check failed"; exit 1; }
# 6. Atomic symlink flip
ln -nfs "$NEW_RELEASE" "$APP_DIR/current"
# 7. Graceful reload (NOT restart)
sudo systemctl reload nginx
sudo systemctl reload myapp
# 8. Keep last 5 releases, prune the rest
ls -1dt "$APP_DIR/releases"/*/ | tail -n +6 | xargs -r rm -rf
echo "Deployed $RELEASE"
That's a real, working zero-downtime deploy. About 30 lines. Run it from a CI runner or a deploy box and you're done.
Gotchas this script doesn't handle yet:
- Parallel deploys racing each other (use a flock or a deploy lock file)
- WebSocket / long-poll connections that won't drain in the reload window
- Cron jobs and queue workers that need to be restarted in a controlled order
- Multi-server fleets where you need to flip symlinks across hosts in sequence
- Build caching so you don't
npm cifrom scratch every time
That's the gap between I have a script
and I have a deploy pipeline.
Plugging those gaps is where most teams give up and reach for a tool.
Setting It Up the Easy Way: DeployHQ
DeployHQ runs the exact pattern above β atomic symlinks, shared directories, graceful reloads, release retention β as a managed pipeline. You don't write the script; you tick a checkbox.
To enable it on an SSH server:
- Add an SSH server in your project and check
Zero-downtime deployments
in the server config - Choose your atomic strategy. Two options:
- Copy previous release first: faster on slow networks, only the changed files transfer
- Cache directory: uploads into a cache, then copies into the new release on the server side (cleaner separation, slightly more disk)
- Define shared paths. Add
.env,storage/,public/uploads/, or whatever needs to persist across releases. DeployHQ symlinks them into every new release automatically. - Run the first deploy. This sets up
releases/,shared/, and the initialcurrentsymlink on the server. - Subsequent deploys transfer only the files that changed, run your build steps inside the new release directory, and flip the symlink. You can configure how many releases to keep β 5 is a reasonable default.
If something goes wrong after the flip, one-click rollback re-points current at the previous release in under a second. No rebuild, no waiting for CI.
For Git-driven workflows, automatic deployment from Git listens for pushes to a chosen branch and runs the zero-downtime flow on every commit. The full feature is documented on the zero downtime deployments feature page.
Multi-Server Setups: Sequential vs Parallel
If your app runs on more than one server, DeployHQ's server groups let you choose how the symlink flip propagates:
- Sequential β deploy to one server at a time, wait for health checks, then move to the next. Higher latency, lowest blast radius. Use this when you have database failover or in-memory state to warm.
- Parallel β deploy to all servers in a group simultaneously. Fastest, but every server flips at the same moment, so a bad release hits 100% of traffic at once.
For most teams, sequential with a small wait between hosts is the sweet spot β you get effective canary behaviour without standing up a service mesh.
Quick Checklist Before You Ship Your First Zero-Downtime Deploy
- [ ] App is stateless (sessions in Redis, uploads in
shared/or object storage) - [ ]
.env, persistent storage, and logs live inshared/and symlink into each release - [ ] Database migrations are additive only (expandβcontract); no
DROP COLUMNin the same release as the code change - [ ] Web server reload (not restart) is wired into the deploy script
- [ ] Build steps run inside the new release directory, not in
current - [ ] Health-check command runs against the new release before the symlink flip
- [ ] Rollback path is a single
ln -nfscommand or one click β no rebuild required - [ ] You keep at least 3 previous releases on disk
- [ ] WebSocket / long-poll connection drain is handled (graceful shutdown window of 30β60 seconds)
If you can tick every box, your next deploy will be invisible to your users.
When the Easy Way
Isn't Enough
Atomic symlinks are the right answer for the vast majority of single-server and small-fleet deployments. They're not the right answer for:
- Stateful services (Postgres primaries, Redis masters, Elasticsearch nodes) β those need failover, not symlink flips
- Strict regulatory environments where every release must run on parallel infrastructure for a verification window β that's a Blue/Green requirement
- Very large fleets (>50 nodes) where you want gradual traffic shifting β that's where Canary releases earn their complexity
For those cases, the Blue/Green, Canary, and Rolling patterns each have their place β see the strategies comparison linked at the top of this article for cost trade-offs and decision criteria. For a worked example of canary releases at the code level, see our canary release implementation guide. And if you're deploying a Laravel app specifically, our walkthrough on how to deploy Laravel with zero downtime shows the same pattern with Laravel-specific gotchas (queue workers, scheduler, Octane). Hosting on a single VPS without Docker? We covered that scenario in zero downtime deployment without Docker or Kubernetes.
Wrapping Up
The easiest way to set up zero downtime deployments is the atomic-symlink pattern: deploy into a fresh directory, health-check it in place, flip a symlink, reload your web server gracefully. It's a 30-line shell script if you want to write it yourself, or a checkbox in DeployHQ if you'd rather skip straight to the part where it works.
The hard part isn't the symlink β it's the prerequisites: stateless app, shared persistent paths, backward-compatible migrations, and graceful reloads. Get those right and the deploy mechanism is almost an afterthought.
Ready to ship without the maintenance banner? Start a DeployHQ project and check zero-downtime deployments when you add your first SSH server. Your first deploy will look like every deploy after it: invisible.
Questions about setting up atomic deployments on a tricky stack? Reach the team at support@deployhq.com or @deployhq on X.