Blue-Green Deployments for Laravel with an Nginx Upstream Switch

Run two Laravel releases side by side and cut traffic over with one Nginx upstream reload. A practical blue green deployment guide with instant rollback.

Steven Richardson
Steven Richardson
· 10 min read

I rolled back a bad release last year by running the deploy script again with an older tag. It took four minutes. Four minutes of 500s because a symlink deploy is only atomic for the swap — everything after it, the composer install, the cache warm, the FPM reload, is still a live rebuild on the box that is serving traffic.

A Laravel blue green deployment fixes that. Two complete environments, both running, both warm. Nginx points at one. Rollback is pointing it at the other.

The standard Laravel deploy — releases/20260804120000, a current symlink, ln -sfn && nginx -s reload — gets you most of the way there. I still use it for the majority of projects, and the zero-downtime deploy pipeline with GitHub Actions and Forge is exactly that pattern automated.

But it has three cracks.

Both releases share one PHP-FPM pool. When you flip the symlink, the pool's OPcache still holds compiled files keyed to the old $realpath_root. If you don't reset it, requests keep executing old code; if you do reset it, you get a cold-start latency spike on every worker at once.

The rollback path is the deploy path in reverse, which means it takes as long as a deploy. That's fine for a typo. It's not fine when checkout is broken.

And in-flight requests can straddle two code versions — a request that resolved config/app.php from release A can end up autoloading a class from release B mid-request.

Blue-green removes all three by never mutating the environment that is serving traffic.

The Laravel blue green deployment model#

Two directories, two PHP-FPM pools, two sets of queue workers, two OPcaches. Nothing is shared except the database, Redis, and the storage/app directory.

/srv/app/
├── blue/          # release A - complete checkout, vendor/, node build
├── green/         # release B - complete checkout, vendor/, node build
├── shared/
│   ├── .env
│   └── storage/   # symlinked into each colour
└── active         # a file containing "blue" or "green"

Each colour gets its own FPM pool listening on its own Unix socket:

; /etc/php/8.4/fpm/pool.d/blue.conf
[blue]
user = www-data
group = www-data
listen = /run/php/app-blue.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
; Separate OPcache per pool - this is the whole point
php_admin_value[opcache.validate_timestamps] = 0
php_admin_value[opcache.memory_consumption] = 192

Copy that to green.conf, change [blue] to [green] and the socket path to app-green.sock. Both pools run permanently. opcache.validate_timestamps = 0 is safe here because a colour's code never changes while its pool is up — you always restart the pool after building into it.

Switching traffic with an Nginx upstream reload#

Nginx fastcgi_pass accepts an upstream group name, and an upstream group can hold Unix sockets. That's the hinge the whole strategy swings on.

Define both pools as upstreams and keep the active one in a separate, generated include file:

# /etc/nginx/conf.d/app-upstreams.conf
upstream php_blue {
    server unix:/run/php/app-blue.sock;
}

upstream php_green {
    server unix:/run/php/app-green.sock;
}

# Generated by the deploy script - the only file that changes
include /etc/nginx/conf.d/app-active.conf;

app-active.conf is one line:

# /etc/nginx/conf.d/app-active.conf
upstream php_active { server unix:/run/php/app-blue.sock; }

The site config never changes. It points root at a symlink and fastcgi_pass at php_active:

server {
    listen 443 ssl;
    server_name example.com;

    # Symlink to /srv/app/blue/public or /srv/app/green/public
    root /srv/app/current/public;

    index index.php;
    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass php_active;
        # $realpath_root resolves the symlink, so FPM and Nginx agree on the path
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

The switch itself is three commands:

#!/usr/bin/env bash
# /usr/local/bin/app-switch - usage: app-switch green
set -euo pipefail

TARGET="$1"

# 1. Rewrite the active upstream and the document root symlink
printf 'upstream php_active { server unix:/run/php/app-%s.sock; }\n' "$TARGET" \
    > /etc/nginx/conf.d/app-active.conf
ln -sfn "/srv/app/${TARGET}" /srv/app/current

# 2. Refuse to reload a config that does not parse
nginx -t

# 3. Graceful reload - old workers finish their in-flight requests
systemctl reload nginx

echo "$TARGET" > /srv/app/active

systemctl reload nginx sends HUP to the master process. Nginx starts new workers against the new config and lets the existing workers drain their current requests before exiting. Nothing is dropped, and no connection ever sees a half-written config because nginx -t gates the reload.

That's the cutover. Sub-second, and every byte of the new release was already compiled and warm before it happened.

Warming the idle environment before you switch#

The deploy does all its work against the colour that isn't serving traffic. This is where blue-green earns its keep — a slow composer install or a failing asset build costs you nothing, because production is still happily served by the other colour.

#!/usr/bin/env bash
set -euo pipefail

ACTIVE=$(cat /srv/app/active)
IDLE=$([ "$ACTIVE" = "blue" ] && echo green || echo blue)
DIR="/srv/app/${IDLE}"

git -C "$DIR" fetch --depth 1 origin main
git -C "$DIR" reset --hard origin/main

composer install --working-dir="$DIR" --no-dev --optimize-autoloader --no-interaction
npm --prefix "$DIR" ci && npm --prefix "$DIR" run build

# Caches config, events, routes and views in one command
php "$DIR/artisan" optimize

# New code needs a fresh pool - the idle pool is serving nobody
systemctl restart "php8.4-fpm@${IDLE}" 2>/dev/null || systemctl reload php8.4-fpm

# Smoke test the idle colour through its own socket before trusting it
curl --fail --silent --unix-socket "/run/php/app-${IDLE}.sock" \
     --header 'Host: example.com' 'http://localhost/up' > /dev/null

/usr/local/bin/app-switch "$IDLE"

The curl --unix-socket line matters more than it looks. Laravel's built-in /up route returns 200 only if the application boots without exceptions, so hitting it against the idle socket proves the new release can resolve its container, read its cached config and reach its dependencies — all before a single real user touches it. If you've already wired that route into Kubernetes readiness and liveness probes, you can reuse the same endpoint here.

Queue workers need the same treatment. Point Supervisor at the colour directory rather than current, run a program group per colour, and start the idle colour's workers as part of the warm step. My setup for keeping Laravel queue workers alive with Supervisor translates directly — you just end up with app-blue-worker and app-green-worker groups instead of one.

Running migrations across the switch#

This is the part people get wrong, and it's the reason blue-green isn't free.

During the switch window both colours are live. Old code and new code hit the same database. A migration that drops a column, renames one, or adds a NOT NULL constraint will break whichever colour doesn't know about it — usually the one still serving traffic.

So migrations run before the switch and must be backward compatible with the currently active release. That's the expand/contract pattern: expand the schema in this deploy, backfill, switch, and only contract in a later deploy once no live code references the old shape. I've written up the full sequence in zero-downtime database migrations with expand and contract, and blue-green makes following it mandatory rather than merely wise.

Run migrations from the idle colour, right after optimize and before the health check:

php "$DIR/artisan" migrate --force --no-interaction

If a migration fails, the script exits, the switch never happens, and the active colour is untouched. That is a much better failure mode than a half-migrated database behind a symlink you've already flipped.

Rolling back with a second switch#

Rollback is the same script with the other argument:

app-switch blue

One file rewrite, one nginx -t, one reload. The previous release is still on disk, still has its vendor/, still has a warm OPcache, and its FPM pool never stopped. You are back on known-good code in about a second.

The caveat is the database. Code rolls back instantly; schema does not. If your deploy ran an expand migration, rolling back the code is safe because expand migrations are additive by definition. If you ran a contract migration in the same deploy — dropping a column the old release still selects — rollback will not save you. That's the discipline blue-green demands in exchange for the instant switch.

Laravel blue green deployment vs rolling: choosing#

Rolling deploys replace instances a few at a time behind a load balancer. Blue-green replaces all of them at once by redirecting the balancer.

Blue-green wins on rollback speed and cutover clarity. There is never a moment where 40% of your traffic is on the new release and 60% on the old, which makes error-rate graphs after a deploy actually readable — a spike is unambiguously the new release.

Rolling wins on cost and capacity. Blue-green needs enough headroom to run two full environments, which on a single VPS means double the FPM children, double the memory, double the disk for vendor/ and built assets. On a small box that is a real constraint.

My rule: blue-green when a bad release costs money per minute and you're on fixed infrastructure you control. Rolling when you're already on an orchestrator that does it natively — if you're running Laravel on Kubernetes, a Deployment's RollingUpdate strategy with a readiness probe gets you 90% of the benefit for none of the bespoke shell script. And if you're on Kamal 2, its container-swap deploy is already blue-green in everything but name.

Gotchas and Edge Cases#

The scheduler will run twice. If cron invokes php /srv/app/current/artisan schedule:run, it follows the symlink and only the active colour runs it — good. If you hardcoded /srv/app/blue/artisan in the crontab, both colours' schedulers fire and you get duplicate emails. Always point cron at current.

Cache keys are shared, and that's usually correct. Resist the urge to prefix the cache per colour. A colour-scoped cache means every switch is a cold cache and a database stampede. Version individual keys that change shape instead.

Sessions must not live on the filesystem. SESSION_DRIVER=file writes into the colour's storage/framework/sessions and everyone gets logged out on switch. Use redis or database, or symlink storage/ from shared/ as in the layout above.

Don't reload FPM globally. systemctl reload php8.4-fpm restarts every pool including the active one, which drops its OPcache mid-traffic. Use per-pool systemd units, or accept that you're restarting the world and schedule accordingly.

.env drift is silent. Both colours read /srv/app/shared/.env. If you ever let one colour have its own .env, a switch changes application behaviour in ways nothing in your diff explains.

Octane changes the shape of the problem. With Octane, the "pool" is a long-running worker process, so the colour boundary is the Octane server, not FPM. The upstream switch still works — you just point it at two Octane HTTP ports instead of two FPM sockets. The Octane + FrankenPHP production notes cover the process-management side.

Wrapping Up#

Start with the two FPM pools and the php_active include file — that alone is an afternoon's work and gives you a rollback that finishes before anyone opens the incident channel. Add the idle-colour warm script next, and only then automate it from CI.

The one prerequisite you can't skip is migration discipline. Read zero-downtime database migrations with expand and contract before your first blue-green deploy, not after. And if you'd rather not own any of this shell script, Laravel Vapor vs Forge in 2026 covers the managed alternatives.

FAQ#

What is a blue-green deployment?

Blue-green is a release strategy where two identical production environments — conventionally called blue and green — run side by side, but only one receives live traffic at any moment. You deploy the new version to the idle environment, verify it, then redirect all traffic to it in a single step. The previously active environment stays running as an instant rollback target.

How do I do blue-green with Nginx for Laravel?

Run two PHP-FPM pools, each with its own Unix socket and its own copy of the release. Define both as Nginx upstream groups, then keep a generated one-line include file that defines php_active as whichever socket is live, and have your fastcgi_pass point at php_active. Deploying means building into the idle colour, rewriting that one include file, running nginx -t, and issuing systemctl reload nginx.

How is blue-green different from rolling deployment?

A rolling deployment replaces instances gradually, so for a period both versions serve real traffic simultaneously and your metrics blend the two. Blue-green switches everything at once, so traffic is always on exactly one version. The trade-off is resources: rolling reuses the same capacity, while blue-green requires enough headroom to run two complete environments permanently.

How do I roll back a blue-green deploy?

Point the upstream back at the previous colour and reload Nginx — the same switch script with the other argument. Because the old environment never stopped running and its OPcache is still warm, the rollback completes in about a second. The exception is the database: if the deploy included a destructive migration, rolling the code back will not undo the schema change, which is why contract migrations belong in a separate later deploy.

How do database migrations work with blue-green?

Run migrations from the idle colour before the traffic switch, and make every migration backward compatible with the release that is still live. In practice that means the expand/contract pattern — add new columns and tables in one deploy, backfill and switch, then drop the old shape in a subsequent deploy once nothing references it. If a migration fails, your deploy script should abort before the switch, leaving the active colour completely untouched.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.