KBeezie

There's no place like ::1

Menu
  • Home
  • Start Here
  • Security Series
  • About

Serving Static Sites with Nginx

2026/05/11 in 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:

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

# 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)$ {
    deny all;
    access_log off;
    log_not_found off;
}

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.

  • ← Previous
  • 1
  • 2
  • 3
  • Next →
Tags: nginx, static, hugo, jekyll, eleventy, astro, caching, gzip, brotli
©2026 KBeezie | Disclaimer | Privacy Notice