---
title: "Do Security Headers Even Work Through a Cloudflare Tunnel? (Yes — Here's How I Checked)"
description: "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."
url: "https://mynerdylife.com/blog/security-headers-through-a-cloudflare-tunnel/"
author: "Jared"
published: "2026-08-01"
category: "Raspberry Pi"
tags: ["nginx","security-headers","performance","cloudflare"]
---

# Do Security Headers Even Work Through a Cloudflare Tunnel? (Yes — Here's How I Checked)

This site's whole deploy story ([zero open ports, if you missed it](https://mynerdylife.com/blog/zero-open-ports-deploy-pipeline/)) 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](https://mynerdylife.com/blog/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:

```nginx
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

```bash
$ 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:

```bash
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:

```nginx
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;
```

```bash
$ 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.
