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.