Vol. 01 — 2026

[2026] Laravel VPS: Zero-Downtime Releases in 20 Min

Short answer: I run Laravel 13 and Next.js on a single ₹6K VPS with a releases-plus-symlink deploy pattern, atomic ln -sfn swaps, PM2 staging ports, opcache preloading, and GitHub Actions. Full releases take about 20 minutes end to end. Rollback takes 30 seconds. P95 stayed flat at 380ms through my last 14 deploys.

I host client apps from Junagadh, Gujarat. My name is Deepak Bagada. No Kubernetes. No platform fees. One virtual server, one deploy script, one ledger. A Rajkot booking app and a Surat catalog API both run this exact flow. It has survived festival traffic spikes twice. Details below.

Laravel 13 zero downtime VPS deployment with atomic symlink releases flow

The shape of the setup

One VPS. Two apps. Three layers:

Laravel 13.16 API served by PHP 8.4 with FrankenPHP, opcache on, config and route caches built at deploy time. The new artisan dev command from 13.16 replaced my old dev script — one command boots the local stack with sane defaults.

Next.js 16.3 frontend running under PM2 on a staging port, then swapped live. The Node process never serves half-written files because PM2 restarts point at a finished release folder.

Shared services — Postgres, Valkey, and n8n — live outside the release folders so deploys never touch data. Uploads live in storage/app/public via a persistent symlink, not inside releases.

Deploy layout on disk:

#!/usr/bin/env bash
# /var/www/shop/current is a symlink — the ONLY thing the web server points at
# /var/www/shop/releases/2026-09-17-1030   # this deploy
# /var/www/shop/releases/2026-09-16-1815   # previous deploy (rollback target)
# /var/www/shop/shared/.env                # never in git, never in releases
# /var/www/shop/shared/storage             # uploads survive deploys

If you want this set up for your business, my Laravel development service ships exactly this stack. Past client builds are in my project log.

Measured deploy numbers

Rajkot booking app, last 14 deploys, traffic during deploys included:

Metric Before (git pull on live) After (releases plus symlink) Change
Failed requests during deploy 40 to 90 0 Zero downtime
Deploy duration 6 min (plus 20 min firefighting) 20 min calm Predictable
Rollback time 25 min of panic 30 seconds, one command 50x faster
API P95 during deploy Spikes to 2.4s Flat 380ms No spike
Opcache hit rate after deploy 0 percent for 10 min 98 percent in 40s Preloaded

Zero failed requests across 14 deploys is the number that matters. Clients stopped noticing deploys. That is the whole point.

Short version. Same server. Same code. Deploys nobody feels.

The deploy script

This is the full script, minus secrets. It runs from GitHub Actions over SSH:

#!/usr/bin/env bash
# deploy.sh — atomic Laravel + Next.js release on one VPS
set -euo pipefail

APP_DIR="/var/www/shop"
STAMP=$(date +%Y-%m-%d-%H%M)
RELEASE="$APP_DIR/releases/$STAMP"
KEEP=5

echo "1. clone fresh release"
git clone --depth 1 --branch main git@github.com:client/shop.git "$RELEASE"

echo "2. link shared files (env + persistent storage)"
ln -sfn "$APP_DIR/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage/app/public"
ln -sfn "$APP_DIR/shared/storage" "$RELEASE/storage/app/public"

echo "3. php deps + caches"
composer install --no-dev --optimize-autoloader --working-dir="$RELEASE"
php "$RELEASE/artisan" config:cache
php "$RELEASE/artisan" route:cache
php "$RELEASE/artisan" view:cache
php "$RELEASE/artisan" migrate --force

echo "4. frontend build on staging port"
npm --prefix "$RELEASE/web" ci
npm --prefix "$RELEASE/web" run build
PORT=3101 pm2 start "$RELEASE/web/server.js" --name "shop-staging" --update-env || true
sleep 12
curl --fail --silent http://127.0.0.1:3101/api/health

echo "5. atomic swap (this is the zero-downtime moment)"
ln -sfn "$RELEASE" "$APP_DIR/current-tmp"
mv -Tf "$APP_DIR/current-tmp" "$APP_DIR/current"
sudo systemctl reload frankenphp
pm2 delete shop-live || true
pm2 start "$RELEASE/web/server.js" --name "shop-live" -- --port 3100
pm2 delete shop-staging || true

echo "6. warm opcache + route cache"
curl --silent https://shop.example.com/api/health
curl --silent https://shop.example.com/ | head -c 200

echo "7. prune old releases, keep last $KEEP"
ls -1dt "$APP_DIR"/releases/* | tail -n +$((KEEP + 1)) | xargs -r rm -rf
echo "LIVE: $STAMP"

PM2 ecosystem file for the Next.js side, staging plus live on separate ports:

// ecosystem.config.js — Next.js live + staging on one VPS
module.exports = {
  apps: [
    {
      name: "shop-live",
      script: "/var/www/shop/current/web/server.js",
      env: { PORT: 3100, NODE_ENV: "production" },
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "900M",
    },
    {
      name: "shop-staging",
      script: "/var/www/shop/current/web/server.js",
      env: { PORT: 3101, NODE_ENV: "production" },
      instances: 1,
      autorestart: false,
    },
  ],
};

GitHub Actions workflow that triggers the whole thing on push to main:

{
  "name": "deploy-vps",
  "on": { "push": { "branches": ["main"] } },
  "jobs": {
    "deploy": {
      "runs_on": "ubuntu-latest",
      "steps": [
        { "name": "ssh deploy", "run": "ssh shop@VPS_IP bash /var/www/shop/deploy.sh" },
        { "name": "verify", "run": "curl --fail https://shop.example.com/api/health" }
      ]
    }
  }
}

For Next.js-specific tuning on the same box, see my Next.js performance notes. For a fixed deploy setup quote, contact me here.

War story 1: the symlink swap that served half a release

October last year, before the atomic pattern. My script updated the current symlink with two commands: remove old link, create new link. Between those two commands — about 400 milliseconds — the web root pointed at nothing. FrankenPHP served 500s. Forty-one checkout requests failed during a Diwali sale evening. The client called within minutes. I remember the exact error rate graph: a red cliff at 19:42.

Log line: live_500_count=41 window_seconds=3 cause=symlink_gap.

Fix: the two-step ln -sfn current-tmp plus mv -Tf you see in step 5 above. mv -T renames atomically — there is no instant where the path points nowhere. Failed requests during deploys went from dozens to zero and stayed there for 14 straight releases.

Blunt rule. Never delete-then-create a live symlink. Rename over it.

War story 2: opcache served yesterday's prices for a day

January. I deployed a price-list change for the Surat catalog. Deploy green. Health checks green. Next morning the client wrote: old prices still showing. I cleared my browser cache, checked again — old prices. CDN? No CDN. I restarted PHP. New prices appeared.

Root cause: opcache with validate_timestamps=0 and no restart in my old script. PHP kept the compiled old files in memory. The new release folder had new code, but opcache served the old bytecode. API P95 looked perfect at 310ms. The data was simply wrong for 19 hours. Roughly 200 visitors saw stale prices. Two orders needed manual correction worth ₹9,600.

Fix: systemctl reload frankenphp in step 5 plus a warm-up curl, and opcache_reset() via a deploy-only endpoint as backup. Opcache hit rate recovers to 98 percent within 40 seconds. Stale bytecode has never recurred.

Lesson I pin everywhere now: green health checks mean nothing if the cache layer serves yesterday. Verify content, not just status codes.

Production Trade-offs: when NOT to use this

Do not use single-VPS deploys past real scale. This pattern holds to roughly 500 requests per second on a 8-vCPU box with opcache warm. Past that, you need a second box and a load balancer. My booking app peaks at 120 rps. Comfortable. If you run flash sales at 2000 rps, this is not your architecture.

Do not keep more than 5 releases on a small disk. Each Laravel plus Next.js release is 600 to 900 MB with node modules. Five releases plus shared storage fits a 80 GB disk. Ten releases will fill it at 2 AM and fail the next deploy. The prune step exists because I learned this at 2 AM.

Do not run migrations that lock tables during the swap. migrate --force runs before the symlink flip, while old code still serves. New columns must be nullable or have defaults, and code must tolerate both schemas for one release. Destructive migrations — dropped columns, renames — need a two-release expand-then-contract cycle. Skip this and the old code errors against the new schema mid-deploy.

Do not skip the staging-port health check. Step 4 boots the new frontend on port 3101 and curls it before the swap. Twice this caught a broken build that npm run build had passed. Five extra minutes of staging check beats a live rollback every time.

Related: my Next.js 16.3 upgrade measurements came from the frontend half of this same stack.

Frequently Asked Questions

How do zero-downtime releases work on a single VPS?

Build each release in a fresh timestamped folder, link shared env and storage, warm caches, then atomically rename the current symlink with mv -T and reload PHP. Requests never hit half-written files because the web server only ever points at complete releases. My last 14 deploys had zero failed requests.

How long does a Laravel plus Next.js deploy take on one VPS?

About 20 minutes calm: 4 minutes clone and link, 6 minutes composer plus artisan caches plus migrations, 7 minutes npm build and staging check, 1 minute atomic swap and reload, 2 minutes warm-up and prune. Rollback is one ln -sfn to the previous folder plus a reload — about 30 seconds.

What does artisan dev do in Laravel 13.16?

It boots the local development stack — server, queue listener, and log tailing — with one command and current defaults, replacing hand-rolled multi-process scripts. I use it for local work only. Production uses FrankenPHP plus PM2 as shown above, since dev servers are not built for live traffic.

How do I roll back a bad release instantly?

Point current back at the previous timestamped folder with ln -sfn, reload FrankenPHP, restart the PM2 live process from that folder, and verify the health endpoint plus one real page of content. Keep 5 releases on disk so the target always exists. Practice the rollback on staging quarterly so the 30-second claim stays true.

Bottom Line

Fourteen deploys, zero failed requests, 20 calm minutes each: fresh release folders, shared env and storage, cached artisan builds, a staging-port check, and one atomic symlink rename. The rename is the whole trick — everything else just makes the trick safe.

← All journal articles Get in touch →