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.
Al Amin Ahamed
Senior software engineer & AI practitioner. Building things in Laravel, PHP, and TypeScript.
About me →More from the blog
← Older
Semantic Search with pgvector and Laravel Scout
Newer →
Five Years of Open Source on WordPress.org — What I've Learned
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.