Serving Static Content & PHP/App Backends
Serving Static Content & PHP/App Backends
Nginx excels at two fundamentally different tasks: sending files straight off disk at wire speed, and acting as a smart gateway that forwards dynamic requests to an application process. Understanding both — and knowing exactly which path a request travels — is the core skill that separates a DevOps engineer who can debug production traffic from one who can only copy configs from Stack Overflow.
How Nginx Decides What to Do with a Request
Every incoming request hits Nginx's location matching engine. Nginx evaluates the URI against your location blocks in a strict priority order: exact matches (=), preferential prefix matches (^~), regex matches (~ and ~*), and finally plain prefix matches. The winning block determines whether the request is handled locally (static file) or proxied onward (dynamic backend).
The two directives that control dispatch are:
root/alias— map the URI to a path on disk and serve the file directly from Nginx. Zero application processes involved.fastcgi_pass/proxy_pass— forward the request to a separate process (PHP-FPM, Gunicorn, a Node.js server, a Laravel Octane process) and stream its response back to the client.
Serving Static Content
The canonical pattern for a static site or the asset portion of a web app looks like this. The root directive sets the base path; Nginx appends the URI to form the full file path.
Key details worth internalizing:
try_files $uri $uri/ =404— Nginx checks for a file, then a directory (appendingindex), and hard-returns 404 rather than falling through to another location. Always end static-only blocks this way.expires 1y; add_header Cache-Control "public, immutable"— for content-addressed assets (filename includes a hash), instruct browsers and CDNs to cache forever. The file hash changes when the content changes, so there is no stale-cache risk.access_log off— asset requests can represent 80% of all log lines while providing near-zero diagnostic value. Turning them off reduces disk writes and makes your access logs readable.
Forwarding to a PHP Backend via FastCGI
PHP does not speak HTTP natively — it speaks FastCGI, a binary protocol designed for long-lived worker processes. PHP-FPM (FastCGI Process Manager) maintains a pool of PHP workers listening on a Unix socket (or TCP port). Nginx translates each HTTP request into a FastCGI message, sends it to FPM, and streams the response back.
SCRIPT_FILENAME: Without the try_files $uri =404 guard inside location ~ \.php$, Nginx will pass requests for non-existent .php files to FPM anyway. FPM may then execute a PHP file embedded in an uploaded image (e.g. uploads/shell.jpg/../../evil.php). This was the "Nginx + PHP-FPM path traversal" vulnerability. The try_files guard is non-negotiable in production.
Forwarding to an App Backend via proxy_pass
Node.js, Python (Gunicorn/uvicorn), Ruby (Puma), Go, and Java backends all speak HTTP natively. Nginx proxies to them with proxy_pass — a simpler, higher-level directive than fastcgi_pass.
X-Forwarded-For and X-Forwarded-Proto. Without them your application sees Nginx's loopback IP as the client and thinks all traffic is HTTP. This breaks IP-based rate limiting, geo-detection, audit logs, and HTTPS redirect logic inside frameworks like Laravel or Django. Configure your framework to trust the proxy headers — in Laravel, set TRUSTED_PROXIES=* or list your Nginx IPs.
The Full Request Path — Visualized
The diagram below shows both paths side by side: a static asset request that never leaves Nginx, and a dynamic request that travels through FPM or a proxy backend before a response reaches the browser.
Common Failure Modes and How to Diagnose Them
- 502 Bad Gateway — Nginx reached PHP-FPM or the app backend but the backend refused the connection or crashed. Check
sudo systemctl status php8.3-fpmandsudo tail -f /var/log/php8.3-fpm.log. The most common cause: FPM pool is exhausted (all workers busy) or the socket path in your config does not match the actual socket path in/etc/php/8.3/fpm/pool.d/www.conf. - 504 Gateway Timeout — Backend is alive but taking too long. Your
proxy_read_timeoutorfastcgi_read_timeoutexpired. Either the request is genuinely slow (long DB query, external API call) or the backend is deadlocked. Check slow-query logs and application traces first. - 404 on PHP files — Usually a
rootmismatch. Runnginx -T | grep rootand compare the full path against what is actually on disk withls -la. - Static file changes not reflected — The browser or a CDN is caching the old version. If assets are not fingerprinted, change
expirestooffor1hduring development.
curl -I http://localhost/path/to/file from the server itself bypasses DNS and CDN layers and shows exactly what Nginx returns. Add -v for full headers. Pair with sudo nginx -t to validate config before reload and sudo tail -f /var/log/nginx/error.log to watch errors live.