Serving PHP in Production with Nginx and PHP-FPM
Configure an Nginx server block to serve a PHP application through PHP-FPM: set the document root correctly, hand dynamic requests off via FastCGI, serve static assets efficiently, cap upload sizes at both layers, and apply baseline security headers.
Nginx cannot execute a single line of PHP itself — it is a fast, event-driven web server and reverse proxy, nothing more. Every real Nginx + PHP deployment is actually two cooperating processes: Nginx handles the raw HTTP connection, decides what kind of request it's looking at, and either serves a file straight off disk or hands the request over to PHP-FPM (FastCGI Process Manager), a separate pool of PHP worker processes that actually run your .php scripts and hand back rendered output. Getting this handoff wrong is one of the most common sources of production PHP bugs — a misconfigured SCRIPT_FILENAME, a missing try_files, or a location block matched in the wrong order can turn into anything from a blank page to, in the worst case, an attacker being able to execute arbitrary PHP by uploading a disguised file. This lesson walks through a complete, realistic Nginx server block for a PHP-FPM application, and the handful of settings — upload limits and security headers — that every production site needs on top of the basic wiring.
Document Root and the Static-vs-Dynamic Split
The document root (root directive) tells Nginx which directory on disk corresponds to the site's URL root. For a modern PHP framework, the document root should almost always point at a public/ subdirectory (e.g. /var/www/myapp/public), not the project root — the project root contains application source code, configuration with secrets, vendor dependencies, and other files that must never be directly downloadable by a visitor. Only the public/ folder, containing the front controller (index.php) and truly public assets (CSS, JS, images), should be web-reachable at all; everything else lives one level up, outside the served tree, where Nginx has no path to it regardless of what URL is requested.
Within that root, every request needs to be routed to one of two destinations: served directly by Nginx as a static file, or handed off to PHP-FPM to execute. Nginx decides this per-request using location blocks matched against the request URI. A location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ block (or similar) tells Nginx to serve those extensions itself, straight from disk, with far-future cache headers — Nginx serving a static file is dramatically cheaper than spinning up a PHP worker just to have PHP call readfile() on the same bytes. Everything that isn't a recognized static asset falls through to the PHP location block and gets executed.
The try_files directive is the piece that ties this together for frameworks using front-controller routing (almost all modern PHP frameworks do): try_files $uri $uri/ /index.php?$query_string; tells Nginx to first check whether the exact requested file exists on disk, then whether it exists as a directory, and only if both fail, fall back to invoking index.php with the original query string preserved — that fallback is what lets a framework's own router handle a URL like /products/42 that has no corresponding file on disk at all.
• root should point at public/, never the project root — application code and secrets must stay outside the web-reachable tree. • Static assets (css/js/images/fonts) are matched by extension and served directly by Nginx, with long cache lifetimes. • try_files $uri $uri/ /index.php?$query_string; implements front-controller routing: real files win, everything else goes through index.php. • Never let a location ~ \.php$ block match a path that a static-asset block should have already served.
The FastCGI Handoff: fastcgi_pass and SCRIPT_FILENAME
When a request needs PHP, Nginx does not talk HTTP to PHP-FPM — it speaks FastCGI, a binary protocol designed for exactly this kind of web-server-to-application-process handoff. The location ~ \.php$ block is where this happens: fastcgi_pass names the PHP-FPM pool to send the request to, either as a TCP address (127.0.0.1:9000) or, more commonly on a single-server deployment, a Unix domain socket (unix:/run/php/php8.3-fpm.sock) — a socket avoids the overhead of a TCP connection for two processes on the same machine and is the standard choice in most Linux distributions' default PHP-FPM packaging.
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; is the single most important — and most commonly misconfigured — line in this block. It tells the PHP-FPM worker the absolute filesystem path of the script to execute. Get this wrong (a stale path, a mismatched root, or copy-pasting a config from a different document root without updating it) and PHP-FPM will either 404, or — far worse in some historical misconfigurations — execute an unintended file. fastcgi_split_path_info and the accompanying regex in the location block exist to correctly separate the actual script path from any PATH_INFO suffix (used by some legacy routing schemes), and include fastcgi_params; (or fastcgi.conf on newer Nginx) pulls in the standard set of FastCGI parameters — request method, headers, remote address, and so on — that PHP's superglobals like $_SERVER are built from.
A frequent security mistake is forgetting to guard against a request URI that ends in .php but doesn't correspond to a script that should be executable — for example /uploads/avatar.php where avatar.php is actually an uploaded image whose filename an attacker crafted to end in .php. The fix is architectural as much as configuration: never allow user uploads to be stored inside a directory that the PHP location block covers, and consider an explicit regex that only matches known application PHP files rather than a blanket \.php$.
A Complete Server Block
Putting the pieces together, a realistic production server block handles the domain, TLS termination (covered in depth in the next lesson), the document root, static-asset serving, the PHP handoff, upload size limits, and security headers, all in one file. The order of location blocks matters: Nginx picks the most specific matching block, so a prefix location for well-known static paths, a regex location for static extensions, and a regex location for .php files can coexist without conflicting as long as they're not overlapping in an ambiguous way.
It's worth internalizing that this configuration is declarative, not procedural — Nginx doesn't run through the file top to bottom per request the way a PHP script executes top to bottom. It evaluates all matching server and location blocks against the request according to Nginx's own matching precedence rules (exact match, then longest prefix match, then regex in file order), so testing a config change after every edit with nginx -t before reloading is essential — a syntax error or unreachable block is easy to introduce and easy to miss by eye.
Upload Size Limits: Two Layers That Must Agree
Nginx and PHP each enforce their own, independent limit on how large an incoming request body can be, and both have to be raised together for a large upload to succeed — raising only one leaves the other as the effective ceiling. Nginx's client_max_body_size (settable at the http, server, or location level) caps the request body size Nginx itself will accept; if a client sends more, Nginx immediately returns 413 Request Entity Too Large without ever forwarding the request to PHP-FPM at all — a cheap, fast rejection that protects PHP workers from being tied up by oversized payloads.
On the PHP side, php.ini has two related settings: upload_max_filesize caps the size of an individual uploaded file, and post_max_size caps the total size of the POST body (which must be at least as large as upload_max_filesize, since the file is part of the POST body, and in practice a little larger to leave room for other form fields and multipart overhead). If post_max_size is smaller than the incoming body, PHP silently empties $_POST and $_FILES for that request rather than throwing a catchable error — a classic, confusing bug where a form 'just doesn't submit' with no visible error message, which is exactly why checking $_SERVER['CONTENT_LENGTH'] against post_max_size, or simply setting generous, matching limits at both layers, is standard practice.
A practical rule of thumb: pick the largest upload your application legitimately needs to accept, set client_max_body_size in Nginx to that value (or slightly above), and set upload_max_filesize and post_max_size in php.ini to match, with post_max_size a bit larger than upload_max_filesize. All three settings should be revisited together whenever an application's upload requirements change — a change to just one is a common cause of 'uploads that used to work now fail'.
• client_max_body_size (Nginx) — hard cap on request body size; oversized requests get 413 before ever reaching PHP. • upload_max_filesize (php.ini) — cap on a single uploaded file's size. • post_max_size (php.ini) — cap on the total POST body; must exceed upload_max_filesize. • If post_max_size is exceeded, PHP silently empties $_POST/$_FILES instead of raising a normal error — a classic silent-failure trap.
Security Headers at the Nginx Layer
Several important HTTP security headers are best set once, centrally, in Nginx, rather than repeated in every PHP response — doing it in Nginx guarantees the header is present even on error pages, static assets, and any response path a developer might forget to add a header to in application code. X-Content-Type-Options: nosniff stops browsers from trying to guess ('sniff') a response's content type based on its content rather than trusting the declared Content-Type header, which closes off a class of attack where a file uploaded as an image is reinterpreted and executed as HTML/JavaScript by a browser that ignored the declared type.
X-Frame-Options: DENY (or SAMEORIGIN if the site legitimately needs to frame itself) prevents the page from being loaded inside an <iframe> on another site, which is the core defense against clickjacking — an attacker overlaying invisible UI on top of your real page to trick users into clicking something they didn't intend to. Content-Security-Policy (CSP) is the most powerful and most involved of the group: it lets you declare, per-directive, which origins scripts, styles, images, fonts, and connections are allowed to load from, and a well-built CSP is one of the strongest available defenses against cross-site scripting (XSS) — even if an attacker manages to inject a <script> tag, a CSP that doesn't allow inline scripts or untrusted origins prevents it from running.
These headers are set with the add_header directive inside the relevant server or location block. One easy-to-miss subtlety in older Nginx versions: add_header inside a location block does not inherit headers set in an outer block unless that outer block also uses add_header in a way Nginx's inheritance rules pick up — as of modern Nginx this is less of a footgun, but it's still worth verifying headers actually appear on every response type (including error responses) with a real HTTP client, not just assuming the config does what it looks like it does.
• X-Content-Type-Options: nosniff — stops browsers from guessing content types, closing an XSS vector via mislabeled uploads. • X-Frame-Options: DENY / SAMEORIGIN — defends against clickjacking by controlling framing. • Content-Security-Policy — whitelists allowed script/style/image/connection origins; strongest general XSS mitigation. • Set security headers centrally in Nginx so they apply uniformly, including to error pages and static assets.
The Core PHP-FPM location Block
fastcgi_pass can be a Unix socket (shown here) or a TCP address like 127.0.0.1:9000. SCRIPT_FILENAME must resolve to the real absolute path of the script — mismatches with root are a top cause of 404s and misfires.
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}Code Examples
This block routes static assets straight off disk with caching, funnels everything else through the front controller via try_files, hands .php execution to a PHP-FPM socket with a correct SCRIPT_FILENAME, caps uploads at 20MB, applies three baseline security headers to every response, and denies access to dotfiles (like .env or .git) that should never be served.
post_max_size is set slightly above upload_max_filesize to leave headroom for other form fields and multipart boundaries; both must be raised together with Nginx's client_max_body_size, or the smallest of the three silently becomes the real limit. After editing php.ini for an FPM pool, PHP-FPM must be restarted (or reloaded) for the new limits to take effect — they are read once at worker startup.
server {
listen 80;
server_name app.example.com;
root /var/www/myapp/public;
index index.php;
client_max_body_size 20m;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 30d;
access_log off;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.(?!well-known) {
deny all;
}
}; php.ini (pool-specific or global, matching client_max_body_size above)
upload_max_filesize = 20M
post_max_size = 22M
max_file_uploads = 5Nginx to PHP-FPM Request Flow
Nginx filters and routes; only requests that truly need PHP ever reach a PHP-FPM worker.
Browser Nginx PHP-FPM pool
┌────────┐ req ┌───────────────────────┐ FastCGI ┌────────────────┐
│ GET / │──────▶│ static asset? │──yes──▶ served directly
│ page │ │ no → try_files │
└────────┘ │ → location ~ \.php$ │──────────▶│ worker executes │
└───────────────────────┘ socket │ index.php │
└────────────────┘Important Rules
Point root at public/, never the project root — keep source, config, and secrets outside the served tree.
Always verify SCRIPT_FILENAME resolves correctly for your document root; test with nginx -t after every config change.
Raise client_max_body_size, upload_max_filesize, and post_max_size together — the smallest one silently wins.
Set X-Content-Type-Options, X-Frame-Options, and a Content-Security-Policy centrally in Nginx.
Never let uploaded-file directories fall under a .php execution location block.
Why Sockets Over TCP
Most single-server PHP deployments (and the default packaging on Debian/Ubuntu) connect Nginx to PHP-FPM over a Unix domain socket rather than TCP, because a socket avoids the small but real overhead of the TCP/IP stack for two processes that are always on the same host, and it's what you'll see in nearly every production nginx.conf and www.conf pairing.
root /var/www/myapp; # points at the project root, not public/ root /var/www/myapp/public;
Pointing the document root at the project root instead of public/ exposes application source, .env files, and vendor/ directly over HTTP to anyone who requests the right path.
client_max_body_size 50m; # Nginx raised, but php.ini left at defaults (upload_max_filesize = 2M) client_max_body_size 50m; # and in php.ini: upload_max_filesize = 50M, post_max_size = 55M
Raising only the Nginx limit does nothing for uploads larger than PHP's own upload_max_filesize/post_max_size — both layers must be increased together.
What does the SCRIPT_FILENAME FastCGI parameter tell PHP-FPM? A. The URL path the browser requested B. The absolute filesystem path of the PHP script to execute C. The name of the PHP-FPM pool to use D. The HTTP method of the request
B. The absolute filesystem path of the PHP script to execute — SCRIPT_FILENAME must resolve to the real absolute path of the script on disk; a mismatch with the document root is a common cause of 404s or misfired execution.
If client_max_body_size in Nginx is 50m but php.ini's post_max_size is still the default (8M), what happens to a 20MB upload? A. It succeeds because Nginx's larger limit takes precedence B. Nginx rejects it with 413 before PHP ever sees it C. PHP-FPM receives the request but truncates and empties $_POST/$_FILES D. PHP automatically raises its own limit to match Nginx
C. PHP-FPM receives the request but truncates and empties $_POST/$_FILES — Nginx would let a 20MB request through since its limit is 50m; PHP's smaller post_max_size then causes PHP to silently empty $_POST and $_FILES for that request rather than raising a visible error — the two limits must be raised together.
Why should uploaded user files never be stored in a directory covered by a location ~ \.php$ block? A. It would slow down static asset serving B. PHP-FPM would refuse to start C. An attacker could upload a file named to end in .php and have it executed as a script D. It has no security implication, only a performance one
C. An attacker could upload a file named to end in .php and have it executed as a script — If the PHP execution location matches any file ending in .php regardless of directory, an attacker who can upload a file can rename it to end in .php and get PHP-FPM to execute it as code — uploads should live outside any PHP-executable path.
Which header is the primary defense against clickjacking? A. X-Content-Type-Options B. Content-Security-Policy only C. X-Frame-Options D. client_max_body_size
C. X-Frame-Options — X-Frame-Options (DENY or SAMEORIGIN) controls whether a page can be embedded in an iframe on another site, which is the classic defense against clickjacking; CSP's frame-ancestors directive can also do this but X-Frame-Options is the traditional, widely supported header for it.
Write an Nginx location block that serves .woff2, .css, and .js files directly with a 7-day cache expiry, and a separate .php location block that proxies to a PHP-FPM pool listening on TCP port 9001 instead of a socket.
location ~* \.(woff2|css|js)$ { expires 7d; access_log off; } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9001; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }
Use fastcgi_pass 127.0.0.1:9001; for the TCP case instead of a unix: path.