A zero downtime deploy means shipping a new version without a single user ever seeing an error page. In the classic approach you connect to the server, copy files over the live directory, install dependencies, and restart the service — but during those few seconds the site either serves half-written files or goes down entirely. In this guide I walk through, step by step, how to set up a seamless switchover for small and mid-sized projects using nothing more than a symlink and a health check, without reaching for a heavy orchestration tool.
The problem: why "copy and restart" causes downtime
Writing files directly over the live directory carries two dangers. First, if the copy is interrupted, the web server ends up serving a mix of old and new code — which in PHP means fatal errors, asset mismatches, or half-rendered templates. Second, steps like composer install or running migrations take seconds, and for that whole window the application sits in an inconsistent state.
The core of the solution is simple: you install the new version into a separate folder, and once everything is ready you flip the link that points to the live directory to the new folder in a single operation. On Linux, replacing a symlink is an atomic operation — the server either sees the entirely old folder or the entirely new one, never a mix of the two.
Directory layout: releases, shared, and current
There is a proven structure that tools like Capistrano and Deployer have used for years. Set up this layout on the server:
/var/www/app/
├── releases/
│ ├── 2026-06-28-101500/
│ └── 2026-06-28-094200/
├── shared/
│ ├── .env
│ └── storage/
└── current -> releases/2026-06-28-101500/
The logic is this: every deploy lands as a new timestamped folder under releases/. Things that don't change between versions and must be preserved (the environment file, uploaded images, logs) live under shared/ and are linked into each release via symlinks. The web server's document root always points at the current link. The switchover is simply a matter of moving that current link.
The deploy flow, step by step
The sequence below is the skeleton of a clean switchover. Run each step only when the previous one succeeded:
- Create the new release folder and extract the code into it (git clone, tar pipe, or a CI artifact).
- Link the shared resources: symlink items like
.envandstoragefromshared/into the new release. - Install dependencies:
composer install --no-dev -o, followed by a frontend build if needed. - Migrations and cache: if the schema changed, run
php artisan migrate --force, thenphp artisan optimize. - Run the health check: verify that the new release actually comes up.
- Flip the symlink: only if the check passes, point
currentat the new folder.
The critical point: leave the symlink for last. Because the slow operations — building, installing dependencies, migrating — all happen in a folder that isn't live yet, users are never affected by them.
The heart of the switch: replacing the symlink
The right way to update a symlink in place is ln -sfn. The -f flag overwrites the existing link, while -n stops the link from being created inside the target when that target is a directory:
ln -sfn /var/www/app/releases/2026-06-28-101500 /var/www/app/current
This single command is the moment the whole switchover happens. On most systems ln creates the target under a temporary name first and then moves it into place with a rename() call, and rename() is atomic on the same filesystem. After the switch, processes like PHP-FPM may still have the old path cached in OPcache, so reload PHP-FPM gracefully afterwards (for example systemctl reload php8.3-fpm) or reset OPcache.
Health check: never promoting a broken version
The last barrier in front of the switch is the health check. The goal is to confirm — before flipping the symlink — that the new release actually responds. Add a simple endpoint to your application (for example /health) and poll it in the deploy script:
URL="http://127.0.0.1/health"
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w "%{http_code}" "$URL")
if [ "$code" = "200" ]; then
echo "Health OK"; exit 0
fi
sleep 2
done
echo "Health check failed, deploy aborted"; exit 1
If the script gets anything other than 200, it stops with exit 1 and the current link keeps pointing at the old, working version. The health endpoint should not be superficial: if it checks dependencies — a database connection, access to a critical cache — rather than just saying "PHP is running," it eliminates the risk of promoting a half-broken version.
Rollback and cleaning up old releases
The best part of this layout is that rolling back is as fast as deploying. If you spot a problem, pointing current back at the previous release is enough:
ln -sfn /var/www/app/releases/2026-06-28-094200 /var/www/app/current
systemctl reload php8.3-fpm
So old releases don't pile up on disk, add a small cleanup step that keeps the newest 3-5 and deletes the rest. That way you keep a few backups for rollback while the disk never fills up.
A note on migrations: backward-compatible changes
The symlink may be atomic, but the database schema is not. Old and new code briefly share the same table during the switch. So split destructive schema changes (dropping or renaming a column) into two phases: first add the new structure and deploy in a way that both versions keep working; then, after the old code is fully retired, clean up the old columns in a later deploy. This "expand and contract" approach is the hidden other half of true zero downtime.
Frequently Asked Questions
Do I need Docker or Kubernetes for this?
No. Symlink-based deploys work perfectly on a single VPS or shared hosting, with no containers at all. Docker and Kubernetes add value once you scale up and have to manage multiple servers or replicas, but for small-to-medium projects the symlink approach is far simpler and entirely sufficient.
Do I really need a dedicated health-check endpoint?
Polling the homepage also works, but a dedicated /health endpoint is preferable. It can deliberately check dependencies (DB, cache), be kept lightweight, and be monitored without creating noise in your logs.
Why reload PHP-FPM after flipping the symlink?
OPcache can cache files by their resolved real paths. Even after you flip the link, processes may keep serving the old code. A reload (not a restart) refreshes that cache and does so without causing downtime.
Want a deployment pipeline with no downtime? Whether it's Laravel or another stack, I can help you set up a symlink-based, health-checked deploy flow on your existing server that rolls back with a single command. Get in touch and let's talk about your project.