Serving Static Sites with Nginx

The Full Configuration

Here's everything together:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name 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;

    root /var/www/mysite.com/public_html;
    index index.html;

    access_log /var/log/nginx/mysite.access.log;
    error_log /var/log/nginx/mysite.error.log;

    server_tokens off;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    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;

    # Pre-compressed assets
    gzip_static on;

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

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

    # Clean URLs — drop .html extension
    location / {
        if ($request_method !~ ^(GET|HEAD)$) {
            return 405;
        }
        try_files $uri $uri.html $uri/ =404;
    }

    # Hashed assets — immutable
    location ~* \.[a-f0-9]{8,}\.(js|css|png|jpg|jpeg|gif|ico|webp|svg|woff2|woff)$ {
        expires max;
        add_header Cache-Control "public, immutable";
    }

    # Non-hashed static — cache with revalidation
    location ~* \.(png|jpg|jpeg|gif|ico|webp|woff2|woff|ttf|svg)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
    }

    location ~* \.(css|js|xml|txt)$ {
        expires 7d;
        add_header Cache-Control "public, must-revalidate";
    }

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

    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    location ~ ~$ {
        deny all;
        access_log off;
        log_not_found off;
    }

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

    # Custom error pages
    error_page 404 /404.html;
    error_page 500 502 503 504 /50x.html;
}

No PHP. No database. No CMS updates. Just Nginx doing what it's always been best at — serving files fast, securely, and without drama.

Technical Audit Summary

This guide is maintained as part of a modular, SSL-first framework. Each configuration is audited for production stability and modern security standards.

Last Audit: May 2026
Environment: Debian Trixie (13)
Nginx: 1.30.2

Compatibility: Tested against current stable releases. While optimized for the stack above, core logic remains relevant for Nginx 1.26+.

2026-05-21: Production audit — bumped Nginx to 1.30.1. Added .well-known/traffic-advice and tdmrep.json exceptions to the housekeeping block to prevent Chrome and AI crawler probes from generating 404 noise. Added tfstate and tfvars to the sensitive extension deny list. Added cross-link to the Securing Nginx and PHP guide for the full drop.conf with rate-limited deny blocks.