Skip to content
Back to Blog
Laravel DevOps Deployment VPS

Zero-Downtime Laravel Deployments on a Budget VPS

Nur Ikhwan Idris · · 9 min read

The naive Laravel deployment looks like this: SSH in, git pull, composer install, php artisan migrate, done. It works — until a user hits a page during the 15 seconds that composer install is running and gets a fatal error because the autoloader is half-rebuilt. Or until a migration takes 30 seconds on a large table and requests that depend on the old schema break.

Envoyer solves this elegantly and is worth the cost if you're running multiple production apps. But if you're self-hosting on a VPS and don't want another monthly subscription, you can build the same core mechanic — atomic release switching via symlinks — with a deploy script and a GitHub Actions workflow.

This is the setup I've used across several projects. No dependencies beyond what's already on a LAMP stack.


The Core Idea: Atomic Symlinks

Instead of deploying into a single directory that's always live, you deploy into a timestamped release directory. The web server points at a current symlink. When the new release is fully prepared — dependencies installed, assets built, caches warmed — you swap the symlink in a single atomic operation. From the web server's perspective, the switch is instantaneous.

/var/www/myapp/
├── releases/
│   ├── 20260201_143022/   ← old release (kept for rollback)
│   └── 20260220_091500/   ← new release (being prepared)
├── shared/
│   ├── .env               ← shared across all releases
│   └── storage/           ← symlinked into each release
└── current -> releases/20260220_091500/   ← web server root

ln -sfn replaces the current symlink atomically. The old release stays on disk until you prune old releases, giving you an instant rollback path.


The Deploy Script

This lives on the server at /var/www/myapp/deploy.sh and is called by GitHub Actions over SSH. It does all the heavy lifting: clone, build, migrate, switch, clean up.

#!/bin/bash
set -euo pipefail

APP_DIR="/var/www/myapp"
REPO="[email protected]:yourusername/yourrepo.git"
BRANCH="${1:-main}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RELEASE_DIR="$APP_DIR/releases/$TIMESTAMP"
SHARED_DIR="$APP_DIR/shared"
CURRENT_LINK="$APP_DIR/current"

echo "==> Creating release directory: $RELEASE_DIR"
mkdir -p "$RELEASE_DIR"

echo "==> Cloning $BRANCH"
git clone --depth=1 --branch "$BRANCH" "$REPO" "$RELEASE_DIR"

echo "==> Linking shared files"
ln -sf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
rm -rf "$RELEASE_DIR/storage"
ln -sf "$SHARED_DIR/storage" "$RELEASE_DIR/storage"

echo "==> Installing dependencies"
cd "$RELEASE_DIR"
composer install --no-dev --no-interaction --optimize-autoloader --quiet

echo "==> Building assets"
npm ci --silent
npm run build --silent

echo "==> Caching config and routes"
php artisan config:cache
php artisan route:cache
php artisan view:cache

echo "==> Running migrations"
php artisan migrate --force --no-interaction

echo "==> Switching symlink"
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"

echo "==> Reloading PHP-FPM"
sudo systemctl reload php8.2-fpm

echo "==> Restarting queue workers"
php artisan queue:restart

echo "==> Pruning old releases (keeping last 5)"
ls -dt "$APP_DIR/releases"/*/ | tail -n +6 | xargs rm -rf

echo "==> Deploy complete: $TIMESTAMP"

A few things worth noting in this script:

  • set -euo pipefail — the script aborts on any error. If composer install fails, the symlink never gets switched. The old release keeps serving traffic.
  • Migrations run before the symlink switch. This means the new schema is in place before new code goes live. This works because migrations should be backward-compatible with the old code (more on this below).
  • systemctl reload php8.2-fpm — a reload (not restart) sends a graceful signal. In-flight PHP-FPM workers finish their current requests before picking up the new code path via the updated symlink. Zero active request interruptions.
  • php artisan queue:restart — sets a cache flag that tells queue workers to exit gracefully after finishing their current job and let the supervisor restart them with new code.

GitHub Actions Workflow

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          script: /var/www/myapp/deploy.sh main

Store three secrets in your GitHub repo: DEPLOY_HOST (server IP or hostname), DEPLOY_USER (the deploy user), and DEPLOY_KEY (an SSH private key whose public key is in ~/.ssh/authorized_keys on the server).

The deploy user needs passwordless sudo for exactly one command — the PHP-FPM reload. Add this to /etc/sudoers.d/deploy:

deployuser ALL=(ALL) NOPASSWD: /bin/systemctl reload php8.2-fpm

Zero-Downtime Migrations: The Expand-Contract Pattern

Atomic symlinks solve the code transition problem. Migrations are a separate concern — and the trickier one.

The rule is simple: migrations that run during a deploy must be safe to run while the old code is still serving requests. Because we run migrations before the symlink switch, there's a brief window where the new schema is live but old code is still handling requests.

This means:

  • Adding a nullable column: safe. Old code ignores it. New code reads it with a default fallback.
  • Adding an index: safe. Transparent to application code.
  • Removing a column: unsafe. Old code may still try to read or write it.
  • Renaming a column: unsafe. Old code uses the old name, new code uses the new name — one of them breaks.
  • Adding a NOT NULL column without a default: unsafe. Old code inserting rows without that column will fail.

The solution for breaking changes is the expand-contract pattern, deployed in two separate releases:

// ❌ Don't do this in one deploy:
// Migration: rename 'title' to 'name'
// Code: uses 'name'
// → Old code breaks during migration window

// ✅ Do this instead:

// Deploy 1:
// Migration: add nullable 'name' column (expand)
// Code: writes to both 'title' and 'name', reads from 'name' with 'title' fallback

// Deploy 2 (after Deploy 1 is stable):
// Code: reads and writes 'name' only
// Migration: drop 'title' column (contract)

It's more deploys, but each one is safe. You can also run a one-off backfill job between the two deploys to populate name from existing title values.


Shared Directory Setup

Some things need to persist across releases — the .env file and the storage/ directory especially. Set these up once, then every deploy symlinks into them.

# First-time server setup
mkdir -p /var/www/myapp/shared/storage
mkdir -p /var/www/myapp/shared/storage/{app,framework,logs}
mkdir -p /var/www/myapp/shared/storage/framework/{cache,sessions,views}

# Copy your .env to shared
cp .env.production /var/www/myapp/shared/.env

# Set permissions
chown -R www-data:www-data /var/www/myapp/shared/storage
chmod -R 775 /var/www/myapp/shared/storage

The nginx document root should point to /var/www/myapp/current/public, not to any specific release directory. Once the symlink exists, nginx doesn't care which release it resolves to.

# nginx site config
server {
    root /var/www/myapp/current/public;
    # ... rest of config
}

Rollback

Because old releases are kept on disk, a rollback is just a symlink swap back to the previous release. No git revert, no re-deploy.

#!/bin/bash
# rollback.sh — switch to the second-most-recent release
APP_DIR="/var/www/myapp"
PREVIOUS=$(ls -dt "$APP_DIR/releases"/*/ | sed -n '2p')

if [ -z "$PREVIOUS" ]; then
    echo "No previous release found."
    exit 1
fi

echo "Rolling back to: $PREVIOUS"
ln -sfn "$PREVIOUS" "$APP_DIR/current"
sudo systemctl reload php8.2-fpm
php artisan queue:restart
echo "Rollback complete."
Note: rollback only reverts the code. If the deploy included a migration that modified data or added a non-nullable column, rolling back code while the new schema is in place may cause issues. This is another reason to keep migrations backward-compatible and use the expand-contract pattern — rolled-back code can still run against the new schema.

What This Doesn't Cover

This setup is solid for a single-server deployment. It doesn't handle:

  • Multi-server deployments — you'd need to coordinate the symlink switch across all servers simultaneously, typically with a load balancer health check dance.
  • Long-running migrations on large tables — even with backward-compatible migrations, a migration that locks a table for 10 minutes will cause problems. For those, use pt-online-schema-change or native online DDL, and run them outside the deploy pipeline.
  • Blue-green deployments — for true zero-impact deploys on high-traffic apps, you want two identical environments and a load balancer. Overkill for most projects, right tool for large ones.

For the majority of Laravel projects running on a single VPS — which describes most side projects, early-stage products, and small-to-medium business apps — this setup achieves what matters: deploys that don't interrupt users, a rollback you can execute in 10 seconds, and a process you can hand off to a junior dev without a lengthy briefing.


Questions or corrections? Reach out via the contact section of my portfolio.