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/initialRemove the copied storage and .env
rm -rf storage
rm -f .envSymlink to shared — always use absolute paths
ln -sfn /var/www/visa-saas/shared/storage storage
ln -sfn /var/www/visa-saas/shared/.env .envVerify
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-saasRename 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 -tnginx: 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 pipefailDEPLOY=/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-saasSTAGING="$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-scriptsSwap: 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
- Post 6: Zero-Downtime Deploy to VPS with GitHub Actions and Laravel — the complete GitHub Actions workflow that uses this directory structure
- Post 8: Deploy Laravel 13 to VPS — 12 GitHub Actions Errors Fixed — every error we hit running this pipeline for the first time
- Post 5: Managing Secrets and Environment Variables in GitHub Actions — set up VPS_HOST, VPS_SSH_KEY and the shared .env correctly before running any deploy
Originally published at dineshstack.com — read the full version with code samples and updates there.
Top comments (0)