Skip to content
TutorialDevOps

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

D

Dinesh Wijethunga

August 12, 2026Reviewed Jul 12, 2026 9 min readIntermediate
ShareX / TwitterLinkedIn
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"

The atomic flip on step 4 is the key line: ln -sfn "$RELEASE" "$DEPLOY/api" replaces the symlink in a single filesystem operation. Nginx reads the new target on the very next request — no reload, no downtime.

How Next.js 16 Migration Works Differently on VPS

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 cwd. Flipping a symlink doesn't cause PM2 to reload its running process.

For Next.js we use a staging swap instead — extract to a staging directory, install deps, then atomically rename it into place:

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

Frequently Asked Questions

Does Nginx need any configuration change to serve through the symlink?
No. Nginx follows symlinks by default on Ubuntu. Your existing root /var/www/visa-saas/api/public directive works unchanged after you replace the api/ directory with a symlink. Only add disable_symlinks off if your distro explicitly restricts symlink following.
Will uploads and sessions be lost during the migration?
No — as long as you copy the contents of storage/ to shared/storage/ before removing it from the release directory. The shared storage directory persists across all releases and is symlinked into each new one.
Why does PHP-FPM need reloading after each symlink flip?
PHP's opcache caches the resolved real file path, not the symlink path. After flipping the symlink to a new release, opcache still serves bytecode from the old release directory until you run sudo systemctl reload php8.4-fpm, which clears the cache.
How do I roll back a broken deploy?
Run: ln -sfn /var/www/visa-saas/releases/PREVIOUS_TIMESTAMP /var/www/visa-saas/api && sudo systemctl reload php8.4-fpm — the previous release directory is already fully installed so rollback takes seconds with no composer install or migrations needed.
Why is Next.js handled with a staging swap instead of a symlink?
PM2 holds the Next.js process in memory with a fixed cwd. Flipping a symlink on the directory PM2 is already running from doesn't cause a reload. The staging swap (extract → install → mv staging/ web/) gives PM2 a clean directory to restart into.
D
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

Add a comment

Comments

Guest comments are held for moderation.

You might also like

Deploy Laravel to a VPS with Laravel Forge: Complete Walkthrough
TutorialIntermediateDevOps

Deploy Laravel to a VPS with Laravel Forge: Complete Walkthrough

Laravel Forge isn't a host — it turns any VPS into a managed Laravel server. Full walkthrough: provisioning, GitHub push-to-deploy, the deploy script, one-click SSL, queue workers, the scheduler, and an honest look at zero-downtime options.

D
Dinesh Wijethunga
5 months ago
6m