DEV Community

Cover image for How to migrate Laravel 13 + Next.js to Zero-Downtime VPS Releases
Dinesh Wijethunga
Dinesh Wijethunga

Posted on • Originally published at dineshstack.com

How to migrate Laravel 13 + Next.js to Zero-Downtime VPS Releases

Part 9 of the CI/CD for Laravel Developers series.

Migrate Laravel 13 + Next.js to zero-downtime VPS releases in under 20 minutes — no downtime during the migration itself. This post is part of the DevOps series on deploying Laravel and Next.js with GitHub Actions. Most tutorials assume you're starting from scratch. This one assumes you already have a live project at /var/www/your-project and want to move it to the releases/symlink pattern so GitHub Actions can deploy atomically.

We'll migrate a monorepo with a Laravel 13 API and a Next.js 16 frontend running under Nginx and PM2 on a single Ubuntu VPS. The same steps apply to any project layout with minor path adjustments.

What Is the Releases Pattern and Why You Need It on VPS

In a normal VPS setup, your deployment overwrites files in the live directory. During that window — while composer install or npm ci runs — your app is in a broken state. Requests hit a mix of old PHP files and new ones, or a half-installed vendor directory.

The releases pattern solves this by keeping every deployment in its own timestamped directory. The live path is a symlink that points to the current release. When a new deploy finishes, you flip the symlink atomically — the switch takes microseconds and Nginx follows it instantly:

/var/www/visa-saas/
├── api -> releases/20260710143022 ← symlink (one atomic flip)
├── releases/
│ ├── initial/ ← your existing app (backed up here)
│ ├── 20260710143022/ ← current live release
│ └── 20260711091544/ ← next release (building here)
├── shared/
│ ├── .env ← one .env, symlinked into every release
│ └── storage/ ← persistent uploads and logs
└── web/ ← Next.js (swap pattern, explained below)

The shared/ directory holds everything that must persist across releases: your .env file and the Laravel storage/ directory (uploads, logs, sessions). Each release symlinks to them instead of containing its own copy.

Before You Start: What Your Live VPS Looks Like Now

Typical existing layout — a git clone or manual upload, served directly by Nginx:

/var/www/visa-saas/
├── api/ ← Laravel 13 app (Nginx serves api/public)
├── web/ ← Next.js 16 (PM2 runs from this directory)
└── README.md

Nginx root currently points to /var/www/visa-saas/api/public and PM2 has cwd: /var/www/visa-saas/web. After the migration, Nginx will point to the same path — but api will be a symlink instead of a directory, and Nginx follows symlinks transparently with no config change.

Step 1: Pause the Queue Worker Before Touching VPS Files

Signal any queue workers to stop picking up new jobs before touching files. They'll finish their current job and exit cleanly:

cd /var/www/visa-saas/api
php artisan queue:restart # signals workers to exit after current job

If you're running Laravel Horizon, use php artisan horizon:pause instead. If you have no queue workers, skip this step.

Step 2: Create the Releases and Shared Directory Structure on VPS

Create the releases/ and shared/ directories alongside your existing api/ directory:

cd /var/www/visa-saas

mkdir -p releases

mkdir -p shared/storage/app/public

mkdir -p shared/storage/framework/{cache/data,sessions,views}

mkdir -p shared/storage/logs

Step 3: Back Up Your Live Laravel 13 App as the Initial Release

Your current live app becomes the first named release. Nothing is deleted — this is a copy, not a move:

cp -a api releases/initial

cp -a (archive mode) preserves ownership, permissions, and symlinks. Your original api/ directory stays intact as a safety net until you've confirmed everything works through the symlink.

Step 4: Move the Laravel .env and Storage to the Shared Directory

The .env file and storage/ directory must live outside every release so they survive across deployments.

Move .env:

cp releases/initial/.env shared/.env

Move storage contents:

# Copy contents (not the directory itself) into shared/storage

cp -a releases/initial/storage/. shared/storage/

Verify

ls shared/storage/




app framework logs

Fix ownership so PHP-FPM can write to the shared storage:

sudo chown -R www-data:www-data shared/storage

sudo chmod -R 775 shared/storage

Step 5: Symlink Shared Resources Into the Laravel Release

Wire releases/initial to use the shared files instead of its own copies:

cd /var/www/visa-saas/releases/initial

Remove the copied storage and .env

rm -rf storage
rm -f .env

Symlink to shared — always use absolute paths

ln -sfn /var/www/visa-saas/shared/storage storage
ln -sfn /var/www/visa-saas/shared/.env .env

Verify

ls -la storage .env

.env -> /var/www/visa-saas/shared/.env




storage -> /var/www/visa-saas/shared/storage

Step 6: Replace the api/ Directory With a Symlink on VPS

Back in the project root, rename the original api/ directory to a backup and create the symlink:

cd /var/www/visa-saas

Rename the original (keep as backup until confirmed working)

mv api api-backup

Create the symlink

ln -sfn /var/www/visa-saas/releases/initial api

Verify

ls -la api




api -> /var/www/visa-saas/releases/initial

Step 7: Verify Nginx Follows the Symlink to Laravel public/

Nginx's root /var/www/visa-saas/api/public directive resolves through the symlink automatically. Test the config and reload:

sudo nginx -t

nginx: configuration file /etc/nginx/nginx.conf syntax is OK

sudo systemctl reload nginx

Hit your API health check to confirm:

curl -s https://api-visa-recruiter.orions360.com/up




{"status":"ok"}

If you get a 502 or permission denied, PHP-FPM may need explicit symlink permission. Add this to your Nginx server block:

server {

root /var/www/visa-saas/api/public;

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

}

Most Ubuntu + Nginx setups follow symlinks by default. disable_symlinks off is only needed if your distro explicitly enabled the restriction.

Step 8: Remove the Backup Once the VPS Migration Is Confirmed

With the app confirmed working through the symlink, remove the backup directory:

rm -rf /var/www/visa-saas/api-backup

Step 9: Update Your GitHub Actions Deploy Script for Zero-Downtime Releases

Now that the structure is in place on the VPS, your GitHub Actions SSH deploy script can use the full releases pattern. Each deploy creates a new timestamped directory, installs dependencies, warms caches, then flips the symlink atomically:

      - name: Run release on server

uses: appleboy/ssh-action@v1

with:

host: ${{ secrets.VPS_HOST }}

username: ${{ secrets.VPS_USER }}

key: ${{ secrets.VPS_SSH_KEY }}

script: |

set -euo pipefail
        DEPLOY=/var/www/visa-saas
        RELEASE="$DEPLOY/releases/$(date +%Y%m%d%H%M%S)"
        SHARED="$DEPLOY/shared"

        # 1. Extract into a new timestamped release directory
        mkdir -p "$RELEASE"
        tar -xzf /tmp/api-release.tar.gz -C "$RELEASE"

        # 2. Wire shared .env and storage
        cd "$RELEASE"
        rm -rf storage
        ln -sfn "$SHARED/storage" storage
        ln -sfn "$SHARED/.env" .env

        # 3. Install and warm caches
        composer install --no-dev --optimize-autoloader --no-interaction --quiet
        php artisan migrate --force
        php artisan config:cache
        php artisan route:cache
        mkdir -p resources/views && php artisan view:cache
        php artisan event:cache
        php artisan queue:restart || true

        # 4. Atomic symlink flip — zero downtime
        ln -sfn "$RELEASE" "$DEPLOY/api"

        # 5. Reload PHP-FPM to clear opcache
        sudo systemctl reload php8.4-fpm

        # 6. Keep only the last 5 releases
        ls -1dt "$DEPLOY/releases/"* | tail -n +6 | xargs rm -rf || true

        rm -f /tmp/api-release.tar.gz
        echo "✓ API deployed: $RELEASE"</code></pre><p>The atomic flip on step 4 is the key line: <code>ln -sfn "$RELEASE" "$DEPLOY/api"</code> replaces the symlink in a single filesystem operation. Nginx reads the new target on the very next request — no reload, no downtime.</p><h2>How Next.js 16 Migration Works Differently on VPS</h2><p>The releases/symlink pattern works perfectly for Laravel 13 because PHP reads files on every request — the new symlink target takes effect immediately. Next.js 16 running under PM2 is different: PM2 holds the process in memory with a fixed <code>cwd</code>. Flipping a symlink doesn't cause PM2 to reload its running process.</p><p>For Next.js we use a <strong>staging swap</strong> instead — extract to a staging directory, install deps, then atomically rename it into place:</p><pre><code class="language-bash">BASE=/var/www/visa-saas

STAGING="$BASE/web-staging"

Extract to staging

rm -rf "$STAGING" && mkdir -p "$STAGING"
tar -xzf /tmp/web-release.tar.gz -C "$STAGING"
ln -sfn "$BASE/shared/.env.local" "$STAGING/.env.local"

Install production deps (ignore husky and other dev lifecycle scripts)

cd "$STAGING"
npm ci --omit=dev --ignore-scripts

Swap: remove old web/, rename staging to web/

rm -rf "$BASE/web"
mv "$STAGING" "$BASE/web"

Restart PM2 from the new web/ directory

cd "$BASE/web"

pm2 delete visa-saas 2>/dev/null || true

pm2 start ecosystem.config.js

pm2 save

This causes ~1–2 seconds of PM2 downtime during the restart. For true zero-downtime Next.js deploys, apply the symlink pattern here too — but PM2's ecosystem.config.js must point cwd to a symlink path that you flip, not the real directory path.

Rollback to a Previous Laravel Release in One Command

The main benefit of keeping old releases on the VPS: if a deploy breaks production, rollback is a single symlink change — no re-deploy, no composer install, no migration:

# List releases newest first

ls -1dt /var/www/visa-saas/releases/*

Roll back to the previous release

ln -sfn /var/www/visa-saas/releases/20260709091544 /var/www/visa-saas/api
sudo systemctl reload php8.4-fpm

echo "✓ Rolled back"

The previous release directory is already fully installed. Rollback takes seconds.

Common Issues During VPS Migration

Nginx 403 after creating the symlink: The deploy user owns releases/initial/ but www-data needs read access. Run sudo chown -R <logged_in_user_name>:www-data /var/www/visa-saas/releases and chmod -R 750 releases/.

Laravel 13 can't write to storage after migration: The shared/storage/ directory must be owned by www-data. Run sudo chown -R www-data:www-data /var/www/visa-saas/shared/storage.

php artisan commands can't find .env: The symlink in releases/initial must use the full absolute path (/var/www/visa-saas/shared/.env), not a relative path. Relative symlinks break when you cd into the release directory.

Opcache serving stale PHP after the symlink flip: PHP's opcache caches the resolved real path, so it continues serving old bytecode after the symlink changes. Always reload PHP-FPM immediately after the symlink flip: sudo systemctl reload php8.4-fpm.

Verify the Final VPS Structure

After your first automated deploy through GitHub Actions, confirm the structure looks exactly like this:

ls -la /var/www/visa-saas/

api -> /var/www/visa-saas/releases/20260710143022

releases/

initial/

20260710143022/

shared/

.env

storage/

web/

ls -la /var/www/visa-saas/releases/20260710143022/

.env -> /var/www/visa-saas/shared/.env

storage -> /var/www/visa-saas/shared/storage

vendor/

app/




...all Laravel 13 files

Related Posts in This Series


Originally published at dineshstack.com — read the full version with code samples and updates there.

Top comments (0)