I’ve seen plenty of teams assume Caddy “already handles HTTPS” so they can forget about HSTS. That half-truth causes sloppy rollouts.

Caddy does make TLS easy. It gets certificates, renews them, redirects HTTP to HTTPS in many setups, and generally removes a lot of web server pain. But HSTS is a separate browser policy, and if you don’t configure it deliberately, users can still make that very first insecure HTTP request.

That first request is where downgrade attacks live.

Here’s a real-world style case study based on a pattern I’ve seen more than once: a team moved a customer-facing app to Caddy, thought HTTPS was fully covered, then discovered they were missing HSTS and a few adjacent hardening details.

The setup

The app was a typical SaaS dashboard:

  • app.example.com for the main product
  • www.example.com for marketing pages
  • example.com redirecting to www
  • Caddy running in front of a Go API and static frontend
  • Automatic TLS enabled through Caddy

The team had done the obvious part well: everything served over HTTPS. But they hadn’t told browsers to always use HTTPS for future requests.

Their original Caddyfile looked roughly like this:

app.example.com {
	reverse_proxy localhost:8080
}

www.example.com {
	root * /var/www/site
	file_server
}

example.com {
	redir https://www.example.com{uri} permanent
}

At first glance, this looks fine. In practice, there were three problems:

  1. No Strict-Transport-Security header
  2. Inconsistent redirect behavior across hostnames during earlier deploys
  3. No staged rollout plan for subdomains

The missing HSTS header meant a user could still type http://app.example.com or follow an old insecure link and make an initial plaintext request before the redirect happened.

If you’re thinking “but Caddy redirects that anyway,” yes, usually. The issue is that the redirect itself only happens after the insecure request reaches the server. HSTS tells the browser not to make that insecure request at all after it has learned the policy.

What “before” looked like in testing

A quick scan showed the site had no HSTS configured. You can verify your own setup with browser dev tools, curl, or a headers scanner like Headertest.

The response headers looked like this:

HTTP/2 200
server: Caddy
content-type: text/html; charset=utf-8
x-content-type-options: nosniff
x-frame-options: DENY

Nothing wrong with those headers, but nothing enforcing HTTPS persistence either.

With curl, it was obvious:

curl -I https://app.example.com

Output:

HTTP/2 200
server: Caddy
content-type: text/html; charset=utf-8
x-content-type-options: nosniff
x-frame-options: DENY

No strict-transport-security.

The first attempt: technically correct, operationally reckless

The team’s first fix was this:

app.example.com {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
	}

	reverse_proxy localhost:8080
}

www.example.com {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
	}

	root * /var/www/site
	file_server
}

example.com {
	redir https://www.example.com{uri} permanent
}

This is the kind of config people copy from blog posts and regret later.

Why? Because includeSubDomains and preload are not cosmetic flags.

  • includeSubDomains tells browsers to force HTTPS on every subdomain
  • preload signals intent for browser preload lists
  • max-age=31536000 locks that policy in for a year

That’s fine only if every subdomain is already HTTPS-ready and will stay that way.

This team had an old support endpoint on help.example.com behind a legacy vendor. It still had broken TLS on some routes. They also had an internal-but-public DNS record for status.example.com that hadn’t been cleaned up.

If they had pushed this globally without checking, they would have broken real traffic.

That’s the thing about HSTS: the hard part isn’t adding the header. The hard part is not lying to browsers.

The safer rollout

They backed up and staged it properly.

Phase 1: Add HSTS to the main app with a short max-age

They started with the app only:

app.example.com {
	header {
		Strict-Transport-Security "max-age=300"
	}

	reverse_proxy localhost:8080
}

A 5-minute policy is boring, and that’s exactly why it’s good. If something goes wrong, users aren’t stuck for long.

They tested with:

curl -I https://app.example.com

Now the output included:

strict-transport-security: max-age=300

Then they verified browser behavior, login flows, callback URLs, and asset loading. Mixed content wasn’t a problem here, but it often shows up when old frontend code still references http:// assets.

Phase 2: Increase max-age after validation

After a few days without issues:

app.example.com {
	header {
		Strict-Transport-Security "max-age=86400"
	}

	reverse_proxy localhost:8080
}

Then later:

app.example.com {
	header {
		Strict-Transport-Security "max-age=31536000"
	}

	reverse_proxy localhost:8080
}

This is a much more sane path than jumping straight to one year on day one.

Phase 3: Expand to other hosts only after inventory

Next, they audited every public subdomain:

  • TLS certificate valid
  • No HTTP-only endpoints left
  • Redirects consistent
  • No forgotten staging hosts in DNS
  • Third-party services mapped to subdomains actually supported HTTPS correctly

Only then did they move to this:

app.example.com {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}

	reverse_proxy localhost:8080
}

www.example.com {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}

	root * /var/www/site
	file_server
}

example.com {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}

	redir https://www.example.com{uri} permanent
}

That solved the real issue without pretending preload was necessary yet.

The after state

Once deployed correctly, the security posture changed in a meaningful way.

Before:

  • Browsers could still initiate an insecure first request
  • Redirects depended on network-path integrity
  • Subdomain HTTPS assumptions were undocumented

After:

  • Browsers that had seen the HSTS header would skip HTTP entirely
  • Downgrade and SSL stripping risk dropped significantly
  • HTTPS expectations became explicit and testable

A header check now showed:

HTTP/2 200
server: Caddy
strict-transport-security: max-age=31536000; includeSubDomains
content-type: text/html; charset=utf-8
x-content-type-options: nosniff
x-frame-options: DENY

That’s the version I like seeing in production.

A cleaner Caddy pattern

If you manage several hosts in one Caddyfile, it’s worth reducing repetition. One practical pattern is a snippet:

(hsts_policy) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
	}
}

app.example.com {
	import hsts_policy
	reverse_proxy localhost:8080
}

www.example.com {
	import hsts_policy
	root * /var/www/site
	file_server
}

example.com {
	import hsts_policy
	redir https://www.example.com{uri} permanent
}

This keeps the policy consistent. It also makes future changes less error-prone.

Official Caddy docs are here if you want the exact syntax and directive behavior: https://caddyserver.com/docs/

Mistakes I’d avoid

1. Enabling preload too early

Preload sounds cool because it protects even the first visit in preload-aware browsers. But it’s a commitment, not a badge.

If you haven’t fully audited your domain tree, don’t use:

preload

I’d treat preload as the final step, not the starting point.

2. Using includeSubDomains without a subdomain inventory

This breaks forgotten hosts. And every company has forgotten hosts.

You probably have one old subdomain pointing to a vendor nobody wants to touch. Find it before browsers do.

3. Sending HSTS over HTTP

Browsers ignore HSTS headers received over insecure HTTP. The header must be sent on HTTPS responses.

4. Assuming redirects replace HSTS

They don’t. Redirects are reactive. HSTS is preventive.

5. Forgetting rollback behavior

To disable HSTS, you need to send:

Strict-Transport-Security: max-age=0

over HTTPS.

That clears the policy for future visits, but clients that already cached a long max-age may continue enforcing until they receive the new header. That’s why short initial rollouts matter.

A production-ready recommendation

If I were setting up HSTS on Caddy for a typical production app today, I’d do this:

  1. Start with max-age=300
  2. Validate app behavior, redirects, third-party integrations, and subdomains
  3. Increase to max-age=86400
  4. Move to max-age=31536000
  5. Add includeSubDomains only after a full DNS and service audit
  6. Consider preload only when you’re absolutely sure

A solid final Caddyfile often looks like this:

(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
	}
}

app.example.com {
	import security_headers
	reverse_proxy localhost:8080
}

www.example.com {
	import security_headers
	root * /var/www/site
	file_server
}

example.com {
	import security_headers
	redir https://www.example.com{uri} permanent
}

That’s not exotic. It’s just disciplined.

And that’s really the story with HSTS on Caddy: Caddy makes HTTPS easy, but you still need to decide how strict you want browsers to be. The difference between “we have TLS” and “we enforce HTTPS properly” is one header and a bit of operational honesty.