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.comfor the main productwww.example.comfor marketing pagesexample.comredirecting towww- 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:
- No
Strict-Transport-Securityheader - Inconsistent redirect behavior across hostnames during earlier deploys
- 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.
includeSubDomainstells browsers to force HTTPS on every subdomainpreloadsignals intent for browser preload listsmax-age=31536000locks 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:
- Start with
max-age=300 - Validate app behavior, redirects, third-party integrations, and subdomains
- Increase to
max-age=86400 - Move to
max-age=31536000 - Add
includeSubDomainsonly after a full DNS and service audit - Consider
preloadonly 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.