backend / protocols / nginx / 09_static_files_compression.md

Nginx Static Files and Compression

6 interview angles 6 min read source

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

  • alias without trailing slash when location ends in / — the first character of the matched path gets eaten. Always match trailing slashes between location and alias.
  • Forgetting Cache-Control headers — browsers cache by heuristics (often <10 minutes). Set explicit expires + Cache-Control for predictable behavior.
  • gzip on a binary content-type — wastes CPU, may grow file size. Use gzip_types allowlist.
  • No gzip_vary behind a CDN — gzipped content served to non-gzip clients.
  • Logging every static request — fills disk with GET /favicon.ico lines. 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.
  • expires and Cache-Control are redundant.”expires is the older HTTP/1.0 way; nginx’s expires directive sets BOTH headers. Modern browsers use Cache-Control: max-age; older proxies may use Expires.
  • sendfile works 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 on and gzip_static on?”gzip on compresses on the fly; gzip_static on serves pre-built .gz files (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 on important behind a CDN?” — without Vary: 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_files and error_page differ?”try_files checks files/locations until one matches; error_page redirects on a specific HTTP error code.