Nginx Static Files and Compression
Two nginx superpowers: serving static content at near-line-rate via sendfile(), and compressing responses on the fly. Both massively reduce backend load and bandwidth.
Serving static files
server {
listen 443 ssl;
location /static/ {
alias /var/www/myapp/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off; # don't log every CSS/image fetch
}
}
alias = “when URI starts with /static/, replace that prefix with this path.” See 04_location_matching.md for root vs alias.
expires 30d adds:
Expires: Tue, 10 Jun 2025 ...
Cache-Control: max-age=2592000
Browsers cache for 30 days. Combined with content-hashed filenames (app.abc123.js), cached forever-ish — and Cache-Control: immutable tells the browser not to even revalidate.
sendfile, tcp_nopush, tcp_nodelay
sendfile on; # use kernel's sendfile() syscall to copy file → socket
tcp_nopush on; # batch headers + start of file in one packet (Linux)
tcp_nodelay on; # disable Nagle's algorithm for keepalive responses
sendfile skips user-space copying — the file’s bytes go straight from page cache to the socket. For static-heavy workloads this is the difference between handling 10k RPS and 100k RPS.
tcp_nopush and tcp_nodelay look contradictory but cooperate: nopush batches the start of a response into one packet (saves round-trips), then nodelay flushes the rest immediately (reduces latency on keepalive).
try_files — fallback chains
Serve from disk if it exists; otherwise fall through to a backend (or a SPA index):
location / {
root /var/www/myapp;
try_files $uri $uri/ @app; # file → directory → @app
}
location @app {
proxy_pass http://backend;
}
For a single-page app where every URL falls back to index.html:
location / {
root /var/www/spa;
try_files $uri /index.html;
}
try_files walks the list, returns the first that exists, and uses the last entry as fallback (it doesn’t have to exist — a named location or a direct return is fine).
File-not-found behavior
By default a missing file returns nginx’s stock 404 page. Custom:
error_page 404 /404.html;
location = /404.html {
root /var/www/errors;
internal; # only servable via error_page redirect
}
internal prevents direct access to /404.html from outside.
Index files
location / {
root /var/www/site;
index index.html index.htm;
}
For request /, nginx tries /var/www/site/index.html, then index.htm. For /foo/, tries /var/www/site/foo/index.html.
Combined with try_files:
location / {
try_files $uri $uri/ /index.html;
}
$uri/ triggers index processing: if the path is a directory, look for an index file inside.
gzip — text compression
gzip on;
gzip_vary on; # add Vary: Accept-Encoding
gzip_min_length 256; # don't compress tiny responses
gzip_proxied any; # compress proxied responses too
gzip_comp_level 5; # 1–9, diminishing returns past 5
gzip_types
text/plain
text/css
text/xml
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
What NOT to gzip: already-compressed content. image/jpeg, image/png, video/*, application/zip. They don’t shrink (and may grow). HTML, CSS, JS, JSON are great targets — often 70–90% smaller.
gzip_vary on is critical when a CDN sits in front. Without Vary: Accept-Encoding, the CDN can serve gzipped content to a client that didn’t request it (broken page).
brotli — better than gzip
If your nginx has the ngx_brotli module (most distros’ nginx don’t by default):
brotli on;
brotli_static on; # serve pre-compressed .br files if present
brotli_types application/json text/css ...;
brotli_comp_level 5;
Brotli typically saves another 15–20% over gzip. Browsers negotiate via Accept-Encoding: br, gzip.
Pre-compressed static files
For files that won’t change (build artifacts), pre-compress at build time:
gzip -9 -k app.abc123.js # creates app.abc123.js.gz
brotli -9 app.abc123.js # creates app.abc123.js.br
Then:
gzip_static on; # serve .gz directly if client accepts gzip
brotli_static on; # serve .br directly if client accepts br
nginx serves the precompressed file without spending CPU. Pairs perfectly with frontend bundlers (webpack, Vite) that emit .gz and .br alongside the originals.
Range requests (video, downloads)
location /media/ {
alias /var/www/media/;
add_header Accept-Ranges bytes; # nginx does this automatically for files
sendfile on;
aio threads; # offload large file reads to thread pool
directio 4m; # bypass page cache for large files
}
For video streaming, range requests let clients seek. nginx supports them automatically for static files.
directio and aio threads matter for large media: avoid OS page cache pollution (a 10 GB video doesn’t push your hot CSS out of cache) and avoid blocking the worker on disk reads.
Open file cache
open_file_cache max=10000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
Caches file descriptors and stat() results. For high-RPS static serving, this avoids re-opening common files thousands of times per second.
CDN in front
For real scale, put a CDN in front of nginx:
[ Browser ] → [ CloudFront / Cloudflare ] → [ nginx ] → [ /static/ on disk ]
The CDN caches at the edge globally; nginx is a fallback origin. Costs:
- CDN $$ vs nginx bandwidth $$.
- Slightly more complex cache invalidation.
For most apps, even a small CDN dramatically reduces nginx traffic. See ../../28_networking/13_cdn.md.
CORS for static assets
If your static files are served from a different domain than your app (cdn.example.com for app.example.com):
location /static/ {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
alias /var/www/myapp/static/;
}
Required for fonts (woff2), some images, and Web Workers loaded cross-origin.
Common pitfalls
aliaswithout trailing slash when location ends in/— the first character of the matched path gets eaten. Always match trailing slashes betweenlocationandalias.- Forgetting
Cache-Controlheaders — browsers cache by heuristics (often <10 minutes). Set explicitexpires+Cache-Controlfor predictable behavior. gzipon a binary content-type — wastes CPU, may grow file size. Usegzip_typesallowlist.- No
gzip_varybehind a CDN — gzipped content served to non-gzip clients. - Logging every static request — fills disk with
GET /favicon.icolines.access_log off;for/static/and/favicon.ico.
Common interview confusions
- “Gzip everything for max savings.” — already-compressed (jpeg, png, zip) doesn’t shrink. CPU spent for no benefit.
- “
expiresandCache-Controlare redundant.” —expiresis the older HTTP/1.0 way; nginx’sexpiresdirective sets BOTH headers. Modern browsers useCache-Control: max-age; older proxies may useExpires. - “
sendfileworks for proxied responses.” — only for static files served directly by nginx. Proxied content goes through nginx’s user-space buffers.
Interview angle
- “Why is nginx fast at static files?” —
sendfile()syscall copies file directly from page cache to socket without entering user-space memory. Workers handle thousands of concurrent fetches. - “How do you serve a SPA via nginx?” —
try_files $uri /index.html;so any unmatched URL falls back to the SPA’s index, letting client-side routing handle it. - “What’s the difference between
gzip onandgzip_static on?” —gzip oncompresses on the fly;gzip_static onserves pre-built.gzfiles (if present) without compressing each request. Combine for max efficiency. - “When do you NOT want to gzip?” — already-compressed content (jpeg, png, mp4, zip), tiny responses (overhead beats benefit), and over-CPU’d servers.
- “Why is
gzip_vary onimportant behind a CDN?” — withoutVary: Accept-Encoding, the CDN can cache the gzipped response and serve it to a client that didn’t ask for gzip = broken page. - “How do
try_filesanderror_pagediffer?” —try_fileschecks files/locations until one matches;error_pageredirects on a specific HTTP error code.