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
brotlimodule 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.
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/; }
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.
| Measure | Default config | Tuned config |
|---|---|---|
| Security headers on hashed assets | 0 of 5 | 5 of 5 |
| Security headers on 404 responses | 0 of 5 | 5 of 5 |
| Redirects for slashless directory URLs | 1 extra hop | canonical 301, then direct |
| Median HTML transfer size | 11.2 KB (gzip 6) | 7.4 KB (brotli 11) |
Requests/s, cached HTML (wrk, 64 conns) | 9,800 | 14,200 |
| CPU at 1,000 req/s | 21% | 6% |
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_headerin a child block without the include. Every parent header is dropped for that block.- Serving dotfiles. A
.gitdirectory accidentally copied into the web root is fully downloadable. Deny/\.explicitly. iffor routing. Beyond the single redirect-map check, avoidifinsidelocation; usemapandtry_files.- Stale precompressed files. If the build emits new HTML but an old
.brsibling 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_cachespeeds up lookups but can serve a file from a previous release for its validity period after a symlink swap; keepopen_file_cache_validshort or reload nginx after each deploy. - Rollback: keep configs in Git and deploy them with the site.
nginx -t && systemctl reload nginxapplies 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.
Related
- Parent: Self-Hosting Static Sites on S3, Nginx and Caddy — comparing self-hosting options.
- Serving a Static Site with Caddy — the same features with less configuration.
- Zero-Downtime Deploys with Symlink Swaps — how
/srv/site/currentgets updated. - Cache Busting with Content-Hashed Filenames — why the asset location can be immutable.
- Enabling HSTS and Preload Safely — rolling out the HSTS header in the include.