Web Servers & Reverse Proxies

Serving Static Content & PHP/App Backends

18 min Lesson 3 of 28

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.
Why keep static and dynamic separate? Static files — CSS, JS, images, fonts — never change per-request. Nginx can serve them at full NIC throughput (hundreds of thousands of requests per second on a single core) with no PHP or Node process involved. Mixing static and dynamic in your backend wastes CPU and memory on processes that have nothing to add.

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.

server { listen 80; server_name assets.example.com; root /var/www/myapp/public; index index.html; location / { # Try the exact file, then a directory index, then return 404. try_files $uri $uri/ =404; } # Long-lived cache headers for fingerprinted assets (e.g. app.a3f92c.js) location ~* \.(js|css|woff2|woff|ttf|svg|png|jpg|webp|ico|gif)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; # skip logging assets — saves disk I/O at scale } }

Key details worth internalizing:

  • try_files $uri $uri/ =404 — Nginx checks for a file, then a directory (appending index), 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.

server { listen 80; server_name app.example.com; root /var/www/myapp/public; index index.php index.html; # Static assets — served by Nginx directly, FPM never involved location ~* \.(js|css|png|jpg|webp|svg|woff2|ico)$ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } # All other requests: try file, then rewrite to index.php (SPA/framework) location / { try_files $uri $uri/ /index.php?$query_string; } # Hand PHP files to PHP-FPM via Unix socket (faster than TCP on same host) location ~ \.php$ { # Security: reject requests for PHP files that do not exist on disk try_files $uri =404; fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_index index.php; # Standard FastCGI params — required for PHP to see server variables include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; # Buffer the response in Nginx memory before sending to client fastcgi_buffer_size 128k; fastcgi_buffers 4 256k; } }
Production pitfall — path traversal via 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.

upstream app_backend { # Multiple workers for round-robin load balancing (lesson 6 expands on this) server 127.0.0.1:3000; server 127.0.0.1:3001; keepalive 32; # reuse connections to the backend — avoids TCP handshake per request } server { listen 80; server_name api.example.com; location / { proxy_pass http://app_backend; # Tell the backend the real client IP and protocol proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Timeouts — tune to your app\'s slowest legitimate response proxy_connect_timeout 5s; proxy_send_timeout 60s; proxy_read_timeout 60s; # Buffer settings — allow Nginx to absorb slow clients proxy_buffering on; proxy_buffer_size 16k; proxy_buffers 4 32k; } }
Always set 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.

Static vs Dynamic Request Path through Nginx Browser Client HTTP Nginx Location Matching Engine :80 / :443 Static Path Disk /var/www/public File bytes Dynamic Path FastCGI / proxy_pass App Backend PHP-FPM / Node / Gunicorn / Go Database MySQL / Redis HTTP response Static (Nginx serves from disk) Dynamic (forwarded to backend)
Static requests are served entirely by Nginx from disk; dynamic requests are forwarded to a backend process (PHP-FPM, Node, Gunicorn) which may in turn query a database.

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-fpm and sudo 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_timeout or fastcgi_read_timeout expired. 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 root mismatch. Run nginx -T | grep root and compare the full path against what is actually on disk with ls -la.
  • Static file changes not reflected — The browser or a CDN is caching the old version. If assets are not fingerprinted, change expires to off or 1h during development.
Debugging tool: 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.