Building Laravel takes an hour; putting it into production seriously takes a week. This piece walks through the stack I use and why each piece is there. I’m not saying “everything is mandatory” — I’m someone who keeps things minimal until I understand they’re needed — but what each piece buys you is concrete.
Components
Cloudflare → HTTP → Nginx → PHP-FPM ┬─→ PostgreSQL (pgBouncer)
├─→ Redis (cache, session, lock)
├─→ RabbitMQ (for queue)
└─→ Supervisor → Queue Workers + Horizon
Why do I put servers behind Cloudflare?
- DDoS protection, WAF, rate limiting and similar features out-of-the-box.
- It passes the
X-Forwarded-Forheader correctly, so the real client IP shows up in the application. - You can do SSL termination at Cloudflare and talk to the backend over
http. - With its CDN features, serving static assets from the Cloudflare cache is easy.
- It provides a free 15-year SSL certificate, which simplifies management.
Nginx — why not Apache?
Both work. I prefer Nginx because:
- Its asynchronous event-loop model is more consistent at low latency.
- TLS termination, static asset serving, and fastcgi are all clean in a single config.
- The
try_files $uri $uri/ /index.php?$query_stringdirective is canonical for Laravel.
A typical server block:
# -----------------------------
# HTTP → HTTPS redirect
# -----------------------------
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
# -----------------------------
# HTTPS
# -----------------------------
server {
listen 443 ssl http2;
server_name app.example.com;
root /var/www/app/current/public;
index index.php;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 25M;
access_log /var/log/nginx/app.example.com.access.log;
error_log /var/log/nginx/app.example.com.error.log warn;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm-app-example.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffering on;
fastcgi_read_timeout 60s;
}
location ~* \.(css|js|png|jpg|jpeg|gif|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location ~ /\.(?!well-known).* {
deny all;
}
location ~ /\. {
deny all;
}
}
So that X-Forwarded-For works correctly, add set_real_ip_from and real_ip_header CF-Connecting-IP (when behind Cloudflare).
PHP-FPM — pool configuration
I covered the details in a separate piece. Two opcache settings specific to production:
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; needs a reset on deploy
opcache.revalidate_freq=0
opcache.fast_shutdown=1
opcache.preload=/var/www/app/current/preload.php
With validate_timestamps=0, PHP doesn’t automatically detect file changes — after a deploy, an FPM reload via kill -USR2 is mandatory. In return, a difference of hundreds of requests per second.
PostgreSQL — why not MySQL?
Both work. My reasons for preferring PostgreSQL:
- JSON/JSONB support is more mature than MySQL’s (GIN index, query operators).
- CTEs, window functions, materialized views are native.
- Logical replication and PITR tooling is cleaner.
- Transactional DDL — if a crash happens mid-migration, the database stays clean.
In return — setup demands a bit more discipline. Typical postgresql.conf sensitivities:
shared_buffers = 4GB # ~25% of total RAM
effective_cache_size = 12GB # ~75% of RAM
work_mem = 16MB
maintenance_work_mem = 256MB
wal_buffers = 16MB
max_connections = 500
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
random_page_cost = 1.1 # for SSD
effective_io_concurrency = 200
pgBouncer — connection pool
PHP opens and closes a connection per request. Starting a PostgreSQL connection is expensive (a forked process). We absorb this by putting pgBouncer in front in transaction mode. auth_query note.
Redis — three hats
The same Redis instance plays three roles:
- Cache —
Cache::remember(...). - Session —
SESSION_DRIVER=redis. - Lock — a distributed mutex via
Cache::lock(...).
They all run safely on a single Redis — as long as:
- Maxmemory policy:
volatile-lru(evict keys with a TTL via LRU; don’t evict persistent queue data). appendonly no(RDB is enough for cache + queue).save 900 1 300 10 60 10000(RDB snapshot rules).
RabbitMQ (optional)
As Laravel’s queue driver I use rabbitmq — instead of Database or Redis. Why?
Queue — QUEUE_CONNECTION=rabbitmq.
The Redis queue driver is simple but falls short compared to RabbitMQ’s features:
- Persistence — RabbitMQ can write messages to disk; Redis queues stay in RAM.
- Acknowledgements — RabbitMQ deletes a message after it’s processed; in a Redis queue messages aren’t deleted, and cleaning up processed messages is the application’s responsibility.
- Routing — with RabbitMQ exchanges it’s easy to route messages to different queues; a Redis queue has a single list.
- Monitoring — with the RabbitMQ management panel you can observe queues, messages, and consumers; a Redis queue has none of this.
Supervisor + Horizon
Queue workers run under Supervisor. Horizon is Laravel’s queue dashboard — to be able to orchestrate accordingly, Supervisor runs the horizon process:
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/current/artisan horizon
user=app
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
stopwaitsecs=3600
Horizon forks its worker processes internally — you set the worker count and memory limit for each queue via config/horizon.php.
Scheduler
A single cron is enough:
* * * * * app cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1
Laravel manages its own scheduler internally. Mind the --withoutOverlapping() and onOneServer() modifiers — they prevent race conditions on multiple servers.
Logging
Structured logs in JSON format:
// config/logging.php
'channels' => [
'production' => [
'driver' => 'stack',
'channels' => ['daily', 'stderr'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => 14,
'formatter' => Monolog\Formatter\JsonFormatter::class,
],
],
/etc/logrotate.d/laravel-app:
/var/www/app/shared/storage/logs/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 app app
sharedscripts
}
.env management
.env always lives in the shared/ directory, symlinked into deploys. Never in the repo. For secrets I do this with the power of simplicity — if a vault becomes necessary we add it; you don’t need one to start.
Deploy
A symlink-swap model. Details are in the “Deploy” section of the multi-project architecture piece.
Which piece do you lose what by removing?
| Component | What happens if you remove it |
|---|---|
| pgBouncer | During connection storms PostgreSQL connections run out and requests throw 500s |
| Supervisor | Workers don’t come back after a crash; you’re forced to set up alerts |
| Horizon | Visibility collapses and you answer “why is the queue slow” blind |
| opcache | Every request reads and parses the PHP file from disk, ~5x slowdown |
| pgBackRest | You’re left with pg_dump — you lose PITR, and in a real incident your margin for error is a single daily backup |
| Redis lock | You’re open to race conditions, with no distributed-safe mutex left |
This is exactly where the strength of a boring stack lies: each piece is quantitatively valuable and easy to swap. Instead of a “the whole cluster went down” scenario, you live through a “I missed the Redis maxmemory setting” scenario — and fixing that takes five minutes.