Skip to content

Deploying Laravel on a VPS: Caddy, Supervisor, and Zero-Downtime Releases

A

Al Amin Ahamed

Senior Engineer

11 min read
𝕏 in

I've deployed Laravel applications on shared hosting, managed cloud (Forge, Envoyer), and raw VPS instances. Raw VPS is the most work up front but gives you full control over every layer. Here's the exact setup I use in production.

The Stack

  • Ubuntu 24.04 LTS — stable, 5-year LTS
  • PHP 8.4-FPM (Ondrej PPA) — latest stable PHP
  • Caddy 2 — automatic TLS, cleaner config than Nginx
  • Supervisor — keeps queue workers and the scheduler running
  • PostgreSQL 16 (Docker) — DB in a container, easier backups
  • Redis 7 (Docker) — cache and queues
  • GitHub Actions — CI/CD

Caddy Config

Caddy's automatic HTTPS is the killer feature. Zero Let's Encrypt config — it just works.

alaminahamed.com { root * /var/www/portfolio/current/public php_fastcgi unix//run/php/php8.4-fpm.sock file_server encode zstd gzip @static { file path *.css *.js *.png *.jpg *.svg *.woff2 } header @static Cache-Control "public, max-age=31536000, immutable" }

Supervisor Workers

Two processes: queue workers and the Laravel scheduler.

[program:laravel-worker] process_name=%(program_name)s_%(process_num)02d command=php /var/www/portfolio/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 autostart=true autorestart=true numprocs=2 redirect_stderr=true stdout_logfile=/var/www/portfolio/shared/storage/logs/worker.log [program:laravel-scheduler] command=php /var/www/portfolio/current/artisan schedule:work autostart=true autorestart=true redirect_stderr=true stdout_logfile=/var/www/portfolio/shared/storage/logs/scheduler.log

Zero-Downtime Deploy Script

The key insight: symlink swaps are atomic on Linux. Point current/ to a new release directory — from the web server's perspective, the switch is instantaneous.

#!/usr/bin/env bash set -euo pipefail DEPLOY_PATH="/var/www/portfolio" RELEASE="$DEPLOY_PATH/releases/$(date +%Y%m%d%H%M%S)" # 1. Fetch new code git clone --depth=1 git@github.com:mralaminahamed/laravel-portfolio-app.git "$RELEASE" # 2. Shared storage & .env ln -s "$DEPLOY_PATH/shared/.env" "$RELEASE/.env" ln -s "$DEPLOY_PATH/shared/storage" "$RELEASE/storage" # 3. Install deps (no dev, optimised autoload) cd "$RELEASE" composer install --no-dev --optimize-autoloader --no-interaction # 4. Build assets yarn install --frozen-lockfile yarn build # 5. Run migrations php artisan migrate --force # 6. Optimise php artisan optimize # 7. Swap the symlink (atomic) ln -sfn "$RELEASE" "$DEPLOY_PATH/current" # 8. Reload workers with new code php artisan queue:restart sudo supervisorctl restart laravel-worker:* # 9. Clean old releases (keep last 5) ls -dt "$DEPLOY_PATH/releases"/* | tail -n +6 | xargs rm -rf

Health Check

# Verify the deploy curl -I https://alaminahamed.com # 200? php artisan queue:failed # any new failures? tail -n 50 storage/logs/laravel.log # any errors?

Rolling back is just ln -sfn releases/20250401120000 current — the previous release is still on disk.

Share 𝕏 in
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Building things in Laravel, PHP, and TypeScript.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.