Skip to content
advanced Phase 15 · Performance and Deployment

Deploying behind Nginx and PHP-FPM

Serve PHP through a production web server stack.

50m
0 problems
Topic Progress 0%

Following One Request: From Nginx to fastcgi_pass to the FPM Socket

Production PHP does not run inside the web server. A client request travels: Nginx worker (a few event-driven processes handling thousands of connections) matches a server block, then hands .php paths over FastCGI — a binary protocol over a Unix socket — to a PHP-FPM pool worker, which executes public/index.php and streams the response back through Nginx. Knowing each hop is what turns a 502 from a mystery into a diagnosis.

A complete server block, annotated where the landmines are:

server {
    listen 443 ssl http2;
    server_name app.example.com;
    root /var/www/app/current/public;
    index index.php;

    location / {
        try_files $uri $uri/index.php?$query_string;
    }

    location ~ .php$ {
        try_files $uri =404;                       # never hand a missing .php to FPM
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param HTTPS $https if_not_empty;
        fastcgi_read_timeout 60s;
    }

    location ~ /.(?!well-known) { deny all; }      # dotfiles never served
}

The try_files line inside location / is your front-controller routing: any path that is not a real file falls through to index.php with the original query string, and Laravel's router takes over from there. In location ~ .php$, try_files $uri =404 is a security control — without it, a request for /uploads/shell.php that matches nothing is still passed to FPM, and SCRIPT_FILENAME decides what executes. That parameter is the classic bug: FPM runs whatever path Nginx sends, so $document_root$fastcgi_script_name must compose to a file that actually exists under your root, or you get 404 (Primary script unknown) — or, misassembled, execution outside the web root entirely.

The root directive is the other half of the security story and a favorite interview question. Pointing it at .../public — the directory holding only index.php and public assets — is what keeps .env (your database credentials), composer.json, vendor/, and storage/ unreachable over HTTP, because they live above the document root, not merely behind a rewrite rule. Frameworks ship a public/ directory precisely so this is structural rather than a deny-list you must maintain.

Transport details: the Unix socket (/run/php/php8.3-fpm.sock) beats TCP on latency and exposure — no open port, kernel-level IPC. Use TCP 127.0.0.1:9000 only when another host must reach FPM. On the FPM side the matching pool file defines the other end:

[www]
user = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.mode = 0660
pm = dynamic
pm.status_path = /fpm-status

Two settings sit beside the PHP plumbing because they shape every response Nginx returns. TLS terminates at Nginx — certificates via certbot --nginx or your ACME tooling, plus a port-80 redirect — and fastcgi_param HTTPS $https if_not_empty is what lets Laravel's isSecure() see the original scheme, which decides secure cookies and generated URLs. Compression belongs here rather than in PHP: gzip on; gzip_types text/css application/json; offloads work PHP would otherwise pay on every byte of every response. And two defaults bite under real traffic: client_max_body_size 20M must match your upload limits or Nginx rejects large uploads with a 413 before PHP sees them, while fastcgi_buffers control streaming of large responses — a "malformed response" 502 on a big JSON export is usually buffers, not PHP.

One version note on the block above: on Nginx 1.25.1 and newer, http2 on; is its own directive and the combined listen 443 ssl http2 form is deprecated — older builds accept only the combined form, so match your Nginx version instead of copying either blindly.

If listen here disagrees with fastcgi_pass there, every request 502s with connect() to ... failed (11: Resource temporarily unavailable) — the classic wrong-socket and full-backlog error. With the plumbing correct, performance becomes a question of how many workers stand behind that socket, which is Chapter 2's arithmetic.

pm dynamic: Sizing FPM Workers With Arithmetic, Not Hope

Each FPM pool worker is a complete PHP process — interpreter, framework, your app — and its resident memory under load is the unit of capacity planning. Measure it (pm.status_path, or smem while load runs); a Laravel worker commonly sits between 40 and 80 MB RSS. The sizing question then has one right answer:

max_children = (total RAM − OS + Nginx + MySQL headroom) ÷ peak RSS per worker

On a 4 GB application box, reserving ~1 GB for the OS, Nginx, and a small MySQL, with 60 MB peak workers: (4096 − 1024) ÷ 60 ≈ 51 children. Undershoot and requests queue in the listen backlog while idle RAM sits unused; overshoot and the box swaps — which is slower than queueing by a wide margin.

The dynamic manager grows and shrinks children inside those bounds:

pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500
request_terminate_timeout = 60s

The three modes answer different traffic shapes, and naming the trade-off is the interview answer:

pm Behavior Fits
static Fixed children always resident Predictable high traffic; lowest spawn latency
dynamic Scales between min and max spare General web apps — the default for a reason
ondemand Spawns per request, kills after process_idle_timeout Many small low-traffic pools; RAM-cheap

pm.max_requests recycles each worker after 500 requests — long-lived processes leak memory through native extensions and accumulating static caches, and a clean recycle costs one cold compile, not a restart. request_terminate_timeout bounds a worker stuck in an infinite loop so it dies instead of pinning a slot forever.

Observability decides whether the numbers hold. Add to Nginx:

location = /fpm-status {
    allow 127.0.0.1; deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

Then read it under load. Two fields are alarms: listen queue must stay 0 — anything higher means requests are waiting for a worker, which is your latency spike — and max children reached counts the times every worker was busy at once. A slow log pairs with it: slowlog = /var/log/php-fpm-slow.log with request_slowlog_timeout = 1s dumps stacks for hung requests, catching the 2-second queries Profiling Slow Requests found earlier.

A healthy status page under load reads like this:

pool:                     www
process manager:          dynamic
listen queue:             0      ; must stay 0
max listen queue:         12     ; worst spike so far
idle processes:           14
active processes:         36
total processes:          50
max children reached:     0      ; 0 = the sizing holds

Idle plus active sitting at max_children means the pool is saturated but not yet queueing — headroom is gone and the next request waits. Idle climbing while requests stay slow means the opposite: capacity is fine and the work itself is slow, which sends you back to the slow log rather than to the RAM budget.

ondemand deserves more than a passing mention for spiky services: with process_idle_timeout = 10s, workers appear on request and die when idle, so twenty low-traffic pools cost RAM only while actually serving. Its price is first-request latency — process spawn plus cold compile — which is irrelevant for internal tools and disqualifying for latency-sensitive public APIs.

Failure modes map directly to these settings: 502 Bad Gateway means Nginx got no answer (dead socket, or all workers busy past the backlog), while 504 Gateway Time-out means a worker exceeded fastcgi_read_timeout — usually a hung query from Indexing and Query Tuning territory. The timeout caps the damage; the tuning removes the cause.

fastcgi_cache or the App Cache? Caching the Whole Response One Layer Above PHP

Everything so far still executes PHP on every hit. Nginx can skip that entirely for repeat URLs by caching FPM's responses on disk:

fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=phpcache:64m max_size=2g inactive=10m;

location ~ .php$ {
    # ...the block from Chapter 1, plus:
    fastcgi_cache phpcache;
    fastcgi_cache_valid 200 301 302 10m;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
    fastcgi_cache_bypass $cookie_session $http_authorization;
    fastcgi_no_cache     $cookie_session $http_authorization;
    add_header X-FastCGI-Cache $upstream_cache_status;
}

A cache HIT never touches PHP-FPM: Nginx serves the stored body in about a millisecond, with every query and template render behind it already done. X-FastCGI-Cache: HIT/MISS/BYPASS in the response is non-negotiable — curl -I then tells you definitively what happened instead of you guessing from timings.

The bypass and no_cache lines are the safety rails, and this is where the pattern goes wrong in the wild. Caching is keyed by URL, but privacy is per user: /dashboard is the same URL for an anonymous visitor and a logged-in customer. Without the session-cookie bypass, the first logged-in user's private response is stored and handed to everyone after them — a data leak caused by a performance feature. Bypass on $cookie_session (and Authorization for API traffic) keeps personalized requests out of the cache; fastcgi_no_cache keeps them from being stored even when generated. Purely anonymous pages — landing pages, catalog — are exactly what belongs in there.

The cache key deserves one deliberate decision. The default $scheme$request_method$host$request_uri keys by full URL including the query string, which is right until a page's response varies on something the URL does not express — a currency cookie, a mobile layout, an Accept-Language header. Each of those either joins the key (fastcgi_cache_key "…$cookie_currency", multiplying stored entries) or becomes another bypass rule; when a page's shape depends on request headers, page caching is the wrong layer and you fall back to the fragment cache where you control the variation. Staleness during redeploys and upstream blips is handled by fastcgi_cache_use_stale error timeout updating http_500 … — Nginx may serve the aged copy while FPM recovers, turning an outage into a few minutes of slightly old content — and fastcgi_cache_lock on stops a stampede of identical misses from hitting PHP simultaneously: the first request fills the entry while the rest wait for it.

Compare it honestly with the application-level Redis cache from Caching with Redis and Files; the two layers solve different problems:

fastcgi_cache App cache (Redis)
Scope Whole HTTP response Fragments, queries, entities
Invalidation TTL, or a purge module Immediate, on write, tag-based
Per-user No (bypass instead) Yes
Skips All of PHP + SQL Only the cached operation

Use both: page-cache anonymous hot paths at the Nginx layer, and let Redis handle per-user and per-entity data with instant invalidation. Purging strategy is the trade you state out loud: TTL-only (inactive=10m above) is simple and bounds staleness at ten minutes; the fastcgi_cache_purge module adds immediate eviction but means every write path must remember to purge.

Finally, connect the layers. A release on this stack is: extract the new version to releases/<timestamp>, flip the current symlink, systemctl reload php8.3-fpm (fresh opcodes, per Enabling OPcache), and rm -rf /var/cache/nginx/phpcache/* — or rely on the 10-minute inactive window and accept bounded staleness. Each layer has its own staleness knob; a deploy that handles none of them is how users see tomorrow's code mixed with yesterday's today. This stack is also the target the next topic arms: Shipping with a CI Pipeline builds the automated path that tests a change and then hands it to these exact commands.

Quiz

1. An Nginx site sets `root /var/www/app;` (the project root) instead of `root /var/www/app/public;`. What is the most serious consequence?

Question 1 options

2. Under peak load, pm.status_path reports listen queue = 47 and "max children reached" climbing. What is the correct diagnosis and fix?

Question 2 options

3. A 4 GB box runs Nginx and php-fpm with framework workers measuring ~60 MB RSS each. Using max_children = (RAM − ~1 GB headroom) ÷ RSS, what is the defensible max_children?

Question 3 options

4. fastcgi_cache is enabled and anonymous landing pages return in 1 ms — but a logged-in customer reported seeing another user's dashboard. What caused it and what fixes it?

Question 4 options

Flashcards

Question

What does `try_files $uri $uri/index.php?$query_string;` accomplish in a framework site block?

Answer

It implements front-controller routing: a request for a real file is served directly; anything else falls through to index.php with the original query string so the framework's router can handle the path. Without it, pretty URLs 404 at Nginx.

Question

Why must the Nginx document root point at public/, and what does SCRIPT_FILENAME have to do with it?

Answer

public/ contains only index.php and public assets; placing the root there keeps .env, composer.json, vendor/, and storage/ unreachable over HTTP by structure rather than deny rules. SCRIPT_FILENAME ($document_root$fastcgi_script_name) then composes to exactly the executable front controller — misassembled, it causes "Primary script unknown" 404s or execution outside the root.

Question

State the php-fpm worker sizing formula and what pm.status fields prove it holds.

Answer

max_children = (total RAM − OS/Nginx/DB headroom) ÷ peak worker RSS — e.g. (4096 − 1024) ÷ 60 ≈ 50 on a 4 GB box. pm.status_path confirms it: listen queue must stay 0 and "max children reached" should not grow under peak load.

Question

static vs dynamic vs ondemand pm modes — when does each fit?

Answer

static: fixed children, lowest latency, for predictable high traffic. dynamic: grows/shrinks between min and max spare servers — the default for general web apps. ondemand: spawns per request, kills after process_idle_timeout, for many small low-traffic pools.

Question

nginx fastcgi_cache vs an application Redis cache — how do their scopes and invalidation differ?

Answer

fastcgi_cache stores whole responses per URL, invalidated by TTL or a purge module, and cannot serve per-user pages (bypass instead). Redis caching stores fragments, queries, or entities with immediate on-write and tag-based invalidation, at the cost of still executing PHP. Production sites typically layer both.

Revision Notes

Key Takeaways

  • 1. Nginx speaks FastCGI to php-fpm; the socket path and SCRIPT_FILENAME must compose exactly right or every request 502s or 404s
  • 2. root = public/ is a security boundary, not a convention — it is what makes .env and vendor/ unreachable over HTTP
  • 3. try_files routes non-file paths to index.php, which is where framework front-controller routing actually lives
  • 4. FPM capacity is arithmetic: RAM minus headroom divided by measured worker RSS — pm.status listen queue and max-children-reached are the verification
  • 5. fastcgi_cache skips all of PHP on a HIT but is keyed by URL, so authenticated traffic must bypass it or users see each other's pages

Interview Tips

  • • "How does a PHP request flow through the stack?" — Nginx matches the server block, try_files resolves to index.php, FastCGI over a Unix socket hands it to an FPM worker, which executes and returns; mention each layer's failure signature (502 vs 504)
  • • "Why point the document root at public/?" — .env, composer.json, and vendor/ become unreachable by structure, not deny rules; pair it with try_files $uri =404 to stop missing .php files reaching FPM
  • • "How many FPM workers do you need?" — walk the formula with real numbers, mention max_requests recycling, and name listen queue / max children reached as the saturation signals
  • • "When would you use fastcgi_cache, and what is its biggest risk?" — anonymous high-traffic pages; the risk is serving a stored private response to other users, prevented by bypassing on the session cookie and Authorization header

Cheat Sheet

Deploying behind Nginx and PHP-FPM

Request path: client → Nginx worker → FastCGI (Unix socket) → FPM pool worker → public/index.php.

Site block essentials:

root /var/www/app/current/public;              # .env/vendor unreachable
location / { try_files $uri $uri/index.php?$query_string; }
location ~ .php$ {
    try_files $uri =404;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_read_timeout 60s;
}

FPM pool: max_children = (RAM − headroom) ÷ worker RSS; dynamic mode with min/max spare; max_requests recycles leaky workers; pm.status_path — watch listen queue = 0 and max children reached; slowlog + request_slowlog_timeout.

502 = socket dead or workers saturated (check status page). 504 = fastcgi_read_timeout hit — usually a hung query.

fastcgi_cache: keys_zone + fastcgi_cache_valid 200 10m + add_header X-FastCGI-Cache $upstream_cache_status. Always bypass/no_cache on $cookie_session and $http_authorization so private pages are never stored or served.

Release: symlink swap → reload php8.3-fpm → purge /var/cache/nginx/phpcache (or rely on inactive TTL).

Continue Learning

Before You Start

Related Reading