nginx, TLS and the headers
A Let's Encrypt certificate, strict security headers, and nothing at all for requests that do not name the site.
Why Web server, on the tour
On this page 4 sections
nginx serves the site's files and nothing else. Requests for the server's bare address, or for any name that is not the site's, get no answer and no certificate, so scanners do not even learn which site lives here.
1. DNS#
At your DNS provider, the two names and two CAA records. CAA says which certificate authority may issue for the domain: only Let's Encrypt, and nobody may issue a wildcard.
www.azit.se. A 203.0.113.10
azit.se. A 203.0.113.10
azit.se. CAA 0 issue "letsencrypt.org"
azit.se. CAA 0 issuewild ";"Ask the zone's own nameservers, so no cache is in the way. From the server: a home network that forces DNS through its own resolver blocks queries like these.
Check
sudo apt install bind9-dnsutils
NS=$(dig +short NS azit.se | head -1)
dig +short A www.azit.se @$NS; dig +short CAA azit.se @$NSThe server's address, and both CAA records (in either order).
2. Port 80 first#
The full configuration names a certificate that does not exist yet, so nginx starts with port 80 only: the folder Let's Encrypt checks, and a redirect for everything else.
sudo apt install nginx certbot
sudo install -d -m 755 /var/www/letsencrypt
sudo tee /etc/nginx/sites-available/homelab >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name www.azit.se azit.se;
location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
EOF
sudo ln -s /etc/nginx/sites-available/homelab /etc/nginx/sites-enabled/homelab
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo install -d -m 755 /var/www/letsencrypt/.well-known/acme-challenge
echo ok | sudo tee /var/www/letsencrypt/.well-known/acme-challenge/testCheck
curl -s http://www.azit.se/.well-known/acme-challenge/test
curl -s http://azit.se/.well-known/acme-challenge/testok twice: from the outside, through every firewall, exactly the request Let's Encrypt will make. Remove the test file afterwards (sudo rm /var/www/letsencrypt/.well-known/acme-challenge/test).
3. The certificate#
First against Let's Encrypt's staging servers, which saves nothing and counts against no limits:
sudo certbot certonly --webroot -w /var/www/letsencrypt -d www.azit.se -d azit.se --dry-runIt asks for an email address (Let's Encrypt no longer sends expiry reminders, so Enter is fine) and for the terms, and ends with The dry run was successful. Then for real:
sudo certbot certonly --webroot -w /var/www/letsencrypt -d www.azit.se -d azit.se --deploy-hook "systemctl reload nginx"The certificate is named after the first -d, so it lands in /etc/letsencrypt/live/www.azit.se/. The deploy hook is stored with it and runs after every renewal: without it, nginx would keep serving the old certificate from memory.
Check
sudo certbot certificates
systemctl list-timers 'certbot*' --no-pager
sudo grep renew_hook /etc/letsencrypt/renewal/www.azit.se.confOne certificate for both names, key type ECDSA, about 89 days left; a certbot.timer that checks twice a day; and renew_hook = systemctl reload nginx.
4. The whole configuration#
Show the whole file, 108 lines
# nginx server blocks for the site.
# Put the file in
# /etc/nginx/sites-available/, link it from sites-enabled, then:
# nginx -t && systemctl reload nginx
# Logs: the usual files, plus every request as JSON in the journal, from where home fetches them.
# Outside any server block, so it covers all of them, including the catch-alls that scanners hit.
log_format json_www escape=json '{"time":"$time_iso8601","client":"$remote_addr","host":"$host","method":"$request_method","uri":"$request_uri","protocol":"$server_protocol","status":$status,"bytes":$body_bytes_sent,"referer":"$http_referer","agent":"$http_user_agent","tls":"$ssl_protocol","duration":$request_time}';
access_log syslog:server=unix:/dev/log,tag=nginx_access,nohostname json_www;
# Catch-all: requests by IP or with an unknown Host get nothing.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
ssl_protocols TLSv1.2 TLSv1.3;
}
server {
listen 80;
listen [::]:80;
server_name www.azit.se azit.se;
server_tokens off;
location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
# Apex: HTTPS on the same host first (HSTS preload requirement), then to www.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name azit.se;
server_tokens off;
ssl_certificate /etc/letsencrypt/live/www.azit.se/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.azit.se/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:azit:10m; # resume TLS sessions instead of a full handshake
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
return 301 https://www.azit.se$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.azit.se;
root /var/www/homelab;
index index.html;
ssl_certificate /etc/letsencrypt/live/www.azit.se/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.azit.se/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:azit:10m; # resume TLS sessions instead of a full handshake
ssl_session_timeout 1d;
ssl_session_tickets off;
server_tokens off; # no version in headers or error pages
absolute_redirect off; # /about -> /about/ without spelling out the host
charset utf-8;
# Compress text. nginx's default compresses only HTML; nothing here is secret, so BREACH does not apply.
gzip on;
gzip_vary on;
gzip_types text/css application/javascript image/svg+xml application/json text/plain application/xml;
# Note: an add_header inside a location block would replace all of these,
# so the locations below use only "expires".
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Only GET and HEAD
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
error_page 404 /404.html;
location = /404.html { internal; }
# Hidden files stay hidden, except security.txt
location = /.well-known/security.txt { default_type text/plain; }
location ~ /\. { return 404; }
# Stylesheet and script names carry a content hash, so they can be cached for good
location /assets/ { expires max; try_files $uri =404; }
location /img/ { expires 1d; try_files $uri =404; }
# The guides' config files: shown as text, whatever their name
location ~ ^/guides/[^/]+/[^/]+/files/ { types { } default_type text/plain; try_files $uri =404; }
location / { try_files $uri $uri/ =404; }
}- The two catch-alls come first. A request for the bare address, or for a name that is not the site's, gets nothing on port 80 (
444closes the connection) and no certificate on 443 (ssl_reject_handshake). - The apex answers HTTPS on its own name, with HSTS, before it sends visitors on to
www: the HSTS preload list checks the apex itself. server_tokens offin every block that answers. Debian 13'snginx.confalready sets it at the top level, where a second one would be an error (duplicate).- The headers are set once, in the server block. An
add_headerinside alocationwould replace all of them there, so the locations only set caching. types { }in the guides'files/folders: every configuration file there is served as text, whatever its extension, so an.htmlor.svgthat ends up there is never rendered.- The two lines at the top also write every request to the journal as JSON, for chapter 4. They do no harm before that.
scp deploy/nginx.conf azit:sudo install -m 644 ~/nginx.conf /etc/nginx/sites-available/homelab
sudo nginx -t && sudo systemctl reload nginxnginx -t before every reload: a reload with a broken file is refused, but a restart or a reboot would then leave nginx down.
Check
curl -sI http://azit.se/ | grep -iE '^(HTTP|location)'
curl -sI https://azit.se/ | grep -iE '^(HTTP|location|strict)'
curl -sI https://www.azit.se/ | grep -iE '^(HTTP|content-security|strict|x-content|referrer|permissions|cross-origin)'
curl -sSI http://203.0.113.10/
curl -sSI https://203.0.113.10/In order: a 301 to https://azit.se/; a 301 to https://www.azit.se/ with HSTS; all seven headers (on a 404 until the site is deployed: always sends them on errors too); Empty reply from server; and a TLS alert such as unrecognized name, without any certificate.