Serving Static Sites with Nginx

Step 5: Security headers — go harder

Without dynamic content, you can lock things down more aggressively than a CMS ever could:

# HSTS — tell browsers to never use HTTP for this domain
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;

# Restrict framing — your static site probably doesn't need to be in an iframe
add_header X-Frame-Options "DENY" always;

# Content Security Policy — lock down to your own origin
add_header Content-Security-Policy "default-src 'self'; "
    "img-src 'self' https://*; "
    "style-src 'self' 'unsafe-inline'; "
    "script-src 'self'; "
    "font-src 'self'; "
    "frame-ancestors 'none'; "
    "base-uri 'self'; "
    "form-action 'self';" always;

# Hide Nginx version from error pages and headers
server_tokens off;

Contrast this with a CMS: you'd need 'unsafe-inline' for scripts, external font sources, third-party embed allowances, and whitelists for plugins. A static site you control end-to-end gets a tighter CSP by default.


Step 6: Custom error pages

No PHP means error pages are just static HTML files — dead simple:

error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;

# Make sure Nginx can still serve these when the root is unavailable
location = /50x.html {
    root /usr/share/nginx/html;
}

Most static site generators can produce pretty error pages during the build. Drop them in your output directory and you're done.


Step 7: Directory listings (optional)

If you're using Nginx to serve a file repository, doc archive, or download directory, you can turn on directory listings instead of requiring an index file:

location /downloads {
    autoindex on;
    autoindex_exact_size off;  # Show human-readable sizes (KB, MB)
    autoindex_localtime on;    # Show local time instead of UTC
}

For everything else, leave autoindex off (the default).


Step 8: Housekeeping

The same drop.conf rules from any other Nginx setup still apply. Even without PHP, you don't want hidden files, backup artifacts, or config files exposed through the web server:

# Let's Encrypt renewal
location ~ /.well-known/acme-challenge {
    root /usr/share/nginx/html;
    allow all;
}

# Automated probes that should never 404
location = /.well-known/traffic-advice { return 204; }
location = /.well-known/tdmrep.json    { return 204; }

#### Ideally the above should be in a modular listen.conf so that they always come first

# Handle favicon.ico cleanly
location = /favicon.ico {
    access_log off;
    log_not_found off;
    try_files $uri =204;
}

# Deny hidden files
location ~ /\. {
    deny all;
    access_log off;
    log_not_found off;
}

# Deny backup files
location ~ ~$ {
    deny all;
    access_log off;
    log_not_found off;
}

# Deny common config/dotfiles
location ~* \.(yml|yaml|env|bak|swp|dist|config|tfstate|tfvars)$ {
    deny all;
    access_log off;
    log_not_found off;
}

The .well-known exceptions for traffic-advice and tdmrep.json respond to automated probes from Chrome (performance optimization hints) and AI crawlers (EU AI Act training opt-out) with a silent 204 No Content. Without these, each probe produces a 404 that fills your access log with noise. The extension list includes tfstate and tfvars for Terraform users — if your static site generator pulls infrastructure-as-code into the build output, those files expose your cloud configuration.

For the complete drop.conf with rate-limited deny blocks and additional hardening, see the Securing Nginx and PHP guide. The deny rules themselves are identical whether you're serving a static site or a CMS.


Step 9: Redirect www to non-www (and HTTP to HTTPS)

server {
    listen 80;
    listen [::]:80;
    server_name mysite.com www.mysite.com;
    return 301 https://mysite.com$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name www.mysite.com;

    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
    ssl_certificate /etc/letsencrypt/live/mysite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mysite.com/privkey.pem;

    return 301 https://mysite.com$request_uri;
}

Step 10: Deploying your static site

Most static site workflows end with a folder of files that need to land on your server. rsync is the simplest approach:

rsync -avz --delete ./public/ user@mysite.com:/var/www/mysite.com/public_html/

The --delete flag removes files on the server that no longer exist in your build — keeps things clean. For production, wrap this in a script, a Makefile, or a CI/CD pipeline (GitHub Actions, GitLab CI, etc.) that runs on every push to main.