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-For header 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_string directive 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:

  1. CacheCache::remember(...).
  2. SessionSESSION_DRIVER=redis.
  3. 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?

QueueQUEUE_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?

ComponentWhat happens if you remove it
pgBouncerDuring connection storms PostgreSQL connections run out and requests throw 500s
SupervisorWorkers don’t come back after a crash; you’re forced to set up alerts
HorizonVisibility collapses and you answer “why is the queue slow” blind
opcacheEvery request reads and parses the PHP file from disk, ~5x slowdown
pgBackRestYou’re left with pg_dump — you lose PITR, and in a real incident your margin for error is a single daily backup
Redis lockYou’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.