Skip to content
Muhammet Şafak
tr
Updated 5 min read

Laravel Production Stack: Why Each Piece Is There

A production-grade Laravel setup with Nginx + PHP-FPM + Redis + PostgreSQL + Supervisor + Horizon. Each piece's job and what you lose if you drop it

TL;DR

Every component of the Laravel stack I run in production — from Cloudflare through Nginx, PHP-FPM, PostgreSQL, pgBouncer and Redis to Supervisor and Horizon — has a concrete payoff. Without pgBouncer a connection storm exhausts PostgreSQL connections, without pgBackRest you lose PITR; even so, staying minimal until a part proves necessary is the default.

Request flow through a Laravel production stack: from Cloudflare (DDoS, WAF, CDN, TLS) to Nginx, then to PHP-FPM with opcache and preload enabled. PHP-FPM reaches three backing services: PostgreSQL behind pgBouncer, Redis serving cache, session and lock duties, and RabbitMQ for the durable queue. Horizon workers under Supervisor consume from RabbitMQ.
Every box does one job on its own; what you lose by removing each is in the table at the end of the article.

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.

Why do I put servers behind Cloudflare?

  • DDoS protection, WAF, rate limiting, and similar features out-of-the-box.
  • It forwards the real client IP in the X-Forwarded-For and CF-Connecting-IP headers; with the real_ip directives below, that IP is what the application sees.
  • 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;

    # Real client IP behind Cloudflare, one line per range: https://www.cloudflare.com/ips/
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    # ... remaining Cloudflare ranges
    real_ip_header CF-Connecting-IP;

    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";
        # add_header is not inherited once this level declares one of its own
        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-Content-Type-Options "nosniff";
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        access_log off;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

The set_real_ip_from list has to cover every Cloudflare range; otherwise Nginx keeps logging Cloudflare’s IP instead of the client’s. Note the single dot-file block: Nginx stops at the first matching regex location, so an exception-free location ~ /\. placed after it would swallow /.well-known/ too and break ACME validation.

PHP-FPM — pool configuration

I covered the details in a separate piece. The 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.preload=/var/www/app/current/preload.php
opcache.preload_user=app        ; php-fpm master starts as root; preload is refused without this

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

Backups go through pgBackRest rather than pg_dump: full plus incremental, with PITR on top of the WAL archive.

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. Cache — Cache::remember(...).
  2. Session — SESSION_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 only keys that carry a TTL, via LRU).
  • appendonly no (RDB is enough for cache, session, and lock data).
  • 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 of RabbitMQ’s features:

  • Persistence — RabbitMQ writes durable messages to disk; Redis serves the queue from memory and only reaches disk through RDB snapshots or AOF.
  • Acknowledgements — RabbitMQ deletes a message once the consumer acks it; Laravel’s Redis driver deletes the finished job from the reserved set on its own, so the difference isn’t cleanup but redelivery: Redis leans on a retry_after timeout, RabbitMQ on the consumer’s connection.
  • Routing — with RabbitMQ exchanges the broker decides which queue a message lands in; on Redis you can name as many queues as you like with onQueue(), but the routing decision stays in the application.
  • Monitoring — the RabbitMQ management panel shows queues, messages, and consumers at the broker level; on Redis the same view comes from Horizon, and it stops at what the application dispatched.

Supervisor + Horizon

Queue workers run under Supervisor. Horizon is Laravel’s queue dashboard — so that it can orchestrate the workers, 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

Out of the box Horizon only drives a redis queue connection; on the rabbitmq connection above it needs the Horizon mode of the vladimir-yuldashev/laravel-queue-rabbitmq package — RABBITMQ_WORKER=horizon, which makes the connection dispatch the events Horizon reads. 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.

What do you lose by removing each piece?

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 an “I missed the Redis maxmemory setting” scenario — and fixing that takes five minutes.

Frequently asked

3 questions

  • Why does opcache.validate_timestamps=0 force a reload on every deploy?

    With that setting PHP stops checking the filesystem for updated scripts, so a new release is invisible until the cache is reset. After a symlink swap you must send PHP-FPM a graceful reload with kill -USR2. The cost is one extra deploy step; the return is hundreds of requests per second.

  • Can one Redis instance serve cache, session and lock at the same time?

    Yes, under three conditions: a volatile-lru maxmemory policy so only keys carrying a TTL are ever evicted, appendonly off because RDB is enough for cache, session and lock data, and explicit RDB snapshot rules. Miss the eviction policy and the lock keys go with the cache.

  • Does Horizon work with the RabbitMQ queue connection?

    Not out of the box. Horizon only drives a redis queue connection; on a rabbitmq connection it needs the Horizon mode of the vladimir-yuldashev/laravel-queue-rabbitmq package, set with RABBITMQ_WORKER=horizon, so the connection emits the events Horizon reads.

Sources

Related

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind