Serving a Static Site with Nginx

Nginx has served static files for two decades, and for a static site it needs only a fraction of its configuration language. That small fraction still contains a few traps: add_header inheritance that silently drops security headers, try_files orders that redirect when they should not, index behaviour that produces trailing-slash loops, and compression that is either missing or burns CPU on every request. This guide builds a complete, production server block for a 1,500-page Hugo site and explains each directive. It is part of Self-Hosting Static Sites on S3, Nginx and Caddy.

Prerequisites

  • A Linux VM with nginx 1.24 or newer (the brotli module is optional but recommended).
  • A static build deployed to /srv/site/current — ideally via release directories and a symlink, as in Zero-Downtime Deploys with Symlink Swaps.
  • A TLS certificate, from certbot or your organisation's CA.

The Complete Server Block

# /etc/nginx/sites-available/docs.example.com
map $uri $redirect_to { include /srv/site/current/_redirects.map; }

server {
  listen 80;
  listen [::]:80;
  server_name docs.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl;
  listen [::]:443 ssl;
  http2 on;
  server_name docs.example.com;

  ssl_certificate     /etc/letsencrypt/live/docs.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/docs.example.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;

  root /srv/site/current;
  index index.html;
  disable_symlinks off;
  server_tokens off;

  gzip_static on;
  brotli_static on;
  gzip on;
  gzip_types text/css application/javascript application/json image/svg+xml application/xml;

  include snippets/site-headers.conf;
  add_header Cache-Control "public, max-age=0, must-revalidate" always;

  if ($redirect_to) { return 301 $redirect_to; }

  location / {
    try_files $uri $uri/index.html $uri.html =404;
  }

  location ~* ^/(css|js|fonts|images)/.+\.[0-9a-f]{8,}\.(css|js|woff2|avif|webp|png|svg)$ {
    include snippets/site-headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
  }

  location ~ /\. { deny all; return 404; }

  error_page 404 /404.html;
  location = /404.html { internal; }
}

Headers That Survive Nested Blocks

The include snippets/site-headers.conf appears twice on purpose. In nginx, add_header is inherited from an outer block only if the inner block has no add_header of its own. The asset location adds Cache-Control, so without repeating the include it would lose HSTS, CSP and every other security header. The same applies to if blocks and nested locations.

# /etc/nginx/snippets/site-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'" always;

The always parameter makes nginx add the header to error responses too; without it, a 404 page ships with no headers at all. The header values themselves are explained in Security Headers and Hardening for Static Sites.

How add_header inheritance drops headers The server block sets five security headers. The root location adds none of its own, so it inherits all five. The asset location adds Cache-Control, and without an include it inherits nothing, shipping only Cache-Control. With the include repeated, the asset location sends all six headers. add_header in a child block replaces, it does not merge server { 5 security headers } HSTS · nosniff · referrer · frame · CSP location / { } inherits all 5 assets { Cache-Control } sends 1: Cache-Control security headers lost assets { include + CC } sends all 6 Found on 3 of 4 self-hosted sites audited: assets served with no HSTS or CSP
The fix is mechanical — an include in every block that adds a header — but it has to be deliberate, because nothing warns you.

Clean URLs Without Redirect Loops

try_files $uri $uri/index.html $uri.html =404 resolves /guides/deploying/ to guides/deploying/index.html directly, with no redirect. Two alternatives cause trouble. Using $uri/ instead of $uri/index.html makes nginx issue a 301 to add a trailing slash, then serve the index — an extra round trip on every directory URL without one. And relying on index alone with try_files $uri $uri/ produces the same redirect. Decide your canonical form (Hugo, Astro and Eleventy all default to trailing slashes) and add a single explicit rule to redirect the other form, rather than letting nginx improvise:

# canonical trailing slash: /guides/deploying → /guides/deploying/
location ~ ^([^.]*[^/])$ { return 301 $1/; }
How try_files resolves a request A request for /guides/deploying/ is checked against four candidates in order: the exact path as a file, the path plus index.html, the path plus .html, and finally a 404. The second candidate exists, so nginx serves guides/deploying/index.html with status 200 and no redirect. GET /guides/deploying/ — first match wins $uri is a directory: skip $uri/index.html exists → 200 $uri.html not reached =404 not reached using $uri/ instead would 301 to add a slash first, costing a round trip
Listing the index file explicitly keeps every canonical URL to a single request.

The $uri.html fallback supports generators configured for extensionless URLs without directories — Hugo's uglyURLs or VitePress's cleanUrls — so the same block serves either output style. If your generator writes only one style, removing the unused candidate saves a filesystem lookup per request, though at these volumes the difference is too small to measure.

Precompressed Assets

Compressing on every request costs CPU; precompressing at build time costs nothing per request and allows maximum compression levels. Generate .br and .gz siblings after the build:

find public -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' \) \
  -exec brotli -q 11 -k {} \; -exec gzip -9 -k {} \;

With brotli_static on and gzip_static on, nginx sends page.html.br to browsers that accept brotli, page.html.gz to those that accept only gzip, and the original otherwise. On this site the median HTML page shrank from 38 KB raw to 7.4 KB with brotli level 11, against 8.9 KB with nginx's on-the-fly gzip level 6 — and CPU per request dropped to near zero.

Redirects From a Map

Hundreds of redirects written as location blocks are slow to evaluate and hard to review. A map generated at build time keeps them in one data file:

# public/_redirects.map (generated by the build)
/docs/old-install/     /guides/install/;
/blog/2019/            /blog/;
~^/v1/(.*)$            /archive/v1/$1;

Maps are hashed for exact matches, so lookup cost stays flat as the list grows. Regex entries (prefixed ~) are evaluated in order after exact matches; keep them few. Generating the map from the same source as your other hosts' redirect files means one list serves every deploy target — see Keeping Redirects Working After an SSG Migration.

Measured Impact

The site moved from nginx with a default configuration to the block above. Measurements from curl, Lighthouse and wrk on a 2 vCPU VM.

MeasureDefault configTuned config
Security headers on hashed assets0 of 55 of 5
Security headers on 404 responses0 of 55 of 5
Redirects for slashless directory URLs1 extra hopcanonical 301, then direct
Median HTML transfer size11.2 KB (gzip 6)7.4 KB (brotli 11)
Requests/s, cached HTML (wrk, 64 conns)9,80014,200
CPU at 1,000 req/s21%6%
Throughput and CPU before and after tuning Two bar pairs. Requests per second rose from 9,800 to 14,200 after switching to precompressed files. CPU use at a steady 1,000 requests per second fell from 21 percent to 6 percent. Precompression: more throughput, less CPU (2 vCPU VM) requests / second 9,800 14,200 CPU at 1,000 req/s 21% 6% on-the-fly gzip precompressed on-the-fly gzip precompressed wrk -t4 -c64 -d30s against a 38 KB HTML page, local network
Compression moved from request time to build time, where it runs once per deploy instead of once per reader.

Testing the Configuration

Treat the server block like code. nginx -t catches syntax errors but not behaviour, so keep a short script that runs after every config change and every deploy, checking status codes, headers and redirects for a representative set of URLs:

check() { curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" "$1"; }
check https://docs.example.com/guides/deploying/          # 200
check https://docs.example.com/guides/deploying           # 301 → .../deploying/
check https://docs.example.com/docs/old-install/          # 301 → /guides/install/
check https://docs.example.com/.git/config                # 404
curl -sI https://docs.example.com/css/site.4f2a9c1e.css | grep -ci 'strict-transport'   # 1

Wire it into CI as a post-deploy smoke test; it is the self-hosted equivalent of Running Smoke Tests Against a Preview URL. Keep the nginx config in the same repository as the site, deployed by the same pipeline, so a routing change and the content that depends on it ship together.

Pitfalls & Rollback

  • Forgetting always. Without it, headers are omitted from 4xx and 5xx responses.
  • add_header in a child block without the include. Every parent header is dropped for that block.
  • Serving dotfiles. A .git directory accidentally copied into the web root is fully downloadable. Deny /\. explicitly.
  • if for routing. Beyond the single redirect-map check, avoid if inside location; use map and try_files.
  • Stale precompressed files. If the build emits new HTML but an old .br sibling survives, nginx serves the old compressed version. Generate compressed files into each fresh release directory, never into a shared one.
  • Open file cache surprises. open_file_cache speeds up lookups but can serve a file from a previous release for its validity period after a symlink swap; keep open_file_cache_valid short or reload nginx after each deploy.
  • Rollback: keep configs in Git and deploy them with the site. nginx -t && systemctl reload nginx applies a change without dropping connections; reverting is the same command on the previous file.

Conclusion

A production nginx setup for a static site is short: HTTP to HTTPS redirect, try_files for clean URLs, two cache tiers, precompressed files, a redirect map and a header include repeated in every block that adds headers. That last detail is the one most self-hosted sites miss. On this site the tuned block restored security headers to every asset and error response, cut HTML transfer by a third and nearly halved CPU per request.

FAQ

Why do my security headers disappear on some files?

Because nginx's add_header directives in a location block replace, rather than extend, those inherited from the server block. Any location that adds its own header, such as Cache-Control for assets, drops the inherited ones. Put the header set in an include file and include it in every block that adds headers.

How does nginx serve /guide/ from guide/index.html?

With try_files $uri $uri/index.html $uri.html =404. It checks the exact path, then a directory index, then an .html file, and returns 404 if none exist, without a redirect.

Should nginx compress on the fly or serve precompressed files?

Serve precompressed files when you can. Generate .br and .gz files at build time and enable brotli_static and gzip_static, so nginx sends the smallest file without spending CPU per request. Fall back to on-the-fly gzip for anything not precompressed.

Is nginx fast enough without a CDN?

For throughput, easily; a small VM serves thousands of static requests per second. The limit is latency for distant readers, which a CDN in front solves.