Do Security Headers Even Work Through a Cloudflare Tunnel? (Yes — Here's How I Checked)
My nginx never talks to the internet directly — Cloudflare and a tunnel sit in between. Do headers like HSTS actually survive that trip? I checked instead of assuming.
This site’s whole deploy story (zero open ports, if you missed it) means nginx never talks to the public internet directly. Cloudflare terminates TLS at the edge, forwards to cloudflared running on the Pi, which hands off to nginx over plain http://localhost:80. Which raises a question I’d genuinely never bothered answering: if nginx adds a security header meant for the browser — Strict-Transport-Security, say — does that actually survive two hops through a tunnel and out the other side as HTTPS? Or does something along the way strip it, rewrite it, or just quietly drop it?
Turns out: it survives completely intact. But I wanted to actually check, not assume, so here’s the receipt.
What was missing
Auditing the nginx config (same pass where I found the www/apex duplicate-content bug), I noticed the site had zero Strict-Transport-Security header and no Permissions-Policy either — just the basics (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) that had been there from the start.
Added both:
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
I skipped includeSubDomains and preload on the HSTS header deliberately — those are much harder to walk back if something later needs plain HTTP on a subdomain, and a bare max-age gets you most of the practical benefit without the commitment.
Proving the tunnel doesn’t eat it
$ curl -sI https://mynerdylife.com/ | grep -i strict
strict-transport-security: max-age=31536000
That’s the real public hostname, through Cloudflare’s edge, through the tunnel, through nginx, and the header comes back untouched. Cloudflare doesn’t strip custom response headers by default, and cloudflared is just proxying bytes — there was never really a reason it wouldn’t work, but “there’s no reason it wouldn’t” and “I confirmed it does” are different levels of confidence, and this setup has enough moving parts that I’d rather have the second one.
The other half: Brotli
While I was in there, I noticed the site was gzip-only. Brotli compresses noticeably better than gzip at equivalent settings, especially on text-heavy HTML/CSS, and it’s a one-package install on Debian:
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
Debian’s nginx packaging auto-registers the module via /etc/nginx/modules-enabled/, so no load_module directives to hand-write. Then it’s just a mirror of the existing gzip block:
brotli on;
brotli_comp_level 5;
brotli_min_length 256;
brotli_types
text/plain text/css text/xml application/javascript
application/json application/xml image/svg+xml
application/rss+xml font/woff2;
$ curl -sI -H "Accept-Encoding: br" https://mynerdylife.com/ | grep -i content-encoding
content-encoding: br
Same page, smaller over the wire, no client-facing change at all.
Why bother checking
None of this is exotic — HSTS and Brotli are both boring, well-documented technology. But “boring and well-documented” is exactly the kind of thing that’s easy to configure and then never verify actually reached the user, especially once you’ve got a few hops of infrastructure between your web server and the internet. Five minutes with curl -I against the real public URL is cheap insurance against configuring something correctly and having it silently not matter.