· 4 min read

Zero-downtime deploys without Kubernetes

A symlink swap and a 500ms nginx reload handle zero-downtime for small teams. The Kubernetes tutorial is for a different problem.

By Marten

You do not need Kubernetes for zero-downtime deploys. You probably do not even need Docker. What you need is two directories, a symlink, and a reload command that nginx has shipped with since 2004.

This is how mine works. I’ll explain each piece, and more importantly, why each piece is there — because that is the part every tutorial skips.

The layout

On the server, the app lives at /srv/www/example.com/:

/srv/www/example.com/
├── current -> releases/20260412-142033
├── releases/
│   ├── 20260401-091512
│   ├── 20260406-184401
│   └── 20260412-142033
└── shared/
    ├── .env
    └── uploads/

Every deploy creates a new timestamped directory in releases/. The code for that release lives there and only there. The current symlink points to whichever release is live. The shared/ directory holds things that must survive across releases: environment config, user uploads, anything the user created that is not code.

nginx serves from /srv/www/example.com/current/public/. It does not know about the timestamped releases. As far as nginx is concerned, the path never changes.

The deploy

Six steps. In order:

RELEASE=$(date +%Y%m%d-%H%M%S)
RELEASE_DIR=/srv/www/example.com/releases/$RELEASE

# 1. Create the release directory and put the code in it
mkdir -p "$RELEASE_DIR"
rsync -a --exclude='.git' /tmp/build/ "$RELEASE_DIR/"

# 2. Link shared files into the release
ln -s /srv/www/example.com/shared/.env "$RELEASE_DIR/.env"
ln -s /srv/www/example.com/shared/uploads "$RELEASE_DIR/public/uploads"

# 3. Run anything that only needs to run once (migrations, asset precompile)
cd "$RELEASE_DIR" && php artisan migrate --force

# 4. Atomic swap — this is the zero-downtime moment
ln -sfn "$RELEASE_DIR" /srv/www/example.com/current.new
mv -T /srv/www/example.com/current.new /srv/www/example.com/current

# 5. Tell the web server to pick up the new symlink target
nginx -s reload

# 6. Clean up old releases (keep the last 5)
cd /srv/www/example.com/releases && ls -t | tail -n +6 | xargs -r rm -rf

Step 4 is the whole trick. ln -sfn would normally be enough, but on some systems ln -sfn is not atomic — it unlinks and then links, and a request that arrives in the gap fails. mv -T is atomic at the filesystem level because it uses rename(2), which is defined to be atomic for files on the same filesystem. Creating current.new first and then renaming it over current gives you a symlink swap that no request can see the seam of.

Why nginx reload

nginx’s reload is not a restart. The master process tells worker processes to finish their in-flight requests and then exit. It starts new workers with the new config, which pick up the new symlink target when they re-resolve paths.

Existing connections finish on the old workers. New connections go to new workers. No request is dropped.

This takes about 500ms in practice. The only thing that can go wrong is if a request takes longer than worker_shutdown_timeout (default 4 hours on some builds, but configurable). Long-running requests should not be served from the same nginx that handles your deploys anyway.

If you are using PHP-FPM or a similar pool-based worker, you may also want a kill -USR2 on the pool to force it to re-stat the code. This depends on your opcache settings. If opcache.validate_timestamps=1 is set, PHP will notice the symlink target has changed on its own.

What this does not do

This pattern does not give you blue-green in the “two full environments” sense. It does not give you canary deploys. It does not help you if a migration fails partway through.

It does give you, specifically: no dropped requests during deploys, instant rollback (swap the symlink back), and the ability to inspect the exact files of any past release for as long as you keep them around.

That is what most teams need. The cases where you need more — traffic shifting, feature-flagged canaries, coordinated multi-service releases — are not solved by Kubernetes either. Kubernetes gives you the primitives. You still have to build the workflow on top.

The parts that trip people up

Three things cause more than 80% of the pain people report with this pattern.

One: the shared directory lives outside the release. If you treat uploads as part of the release, they disappear on deploy. Symlinking them in from shared/ is the answer, and you need to remember to do it for every path that holds user-generated state.

Two: database migrations happen in step 3, before the symlink swap. That means for the duration of the migration, the old code is running against a database that may have new columns. This is usually fine, because adding a column doesn’t break reads. Dropping a column mid-deploy does break things — which is why DROP COLUMN belongs in a separate release, not the same one that adds the code change.

Three: environment variables. If your app reads .env at boot, a reload isn’t enough — you need to restart the worker pool. PHP-FPM handles this through the reload. Node or Ruby processes need an explicit restart (graceful, via their own signal). Make sure your deploy script knows which it is.

Zero-downtime is not a magic property. It is a sequence of small, explicit choices. Most tutorials get you to the symlink and stop. The interesting parts are what you do with the symlink, and which state you keep out of it.