HSTS looks simple until you have more than one environment.
On paper, it’s one header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
In real teams, that header touches local development, preview deployments, staging subdomains, internal tools, CDNs, load balancers, and production rollback plans. That’s where people get burned.
I’ve seen teams enable HSTS in production, break staging, lock developers out of test hosts, and then discover they can’t “just turn it off” because browsers cached the policy exactly like they were supposed to.
Here are the mistakes I see most often when setting up HSTS across multiple environments, and how to fix them without creating a mess.
Mistake #1: Using the same HSTS policy everywhere
A lot of setups start with “just add the header globally.” That usually means dev, staging, and prod all inherit the same config from a shared reverse proxy or CDN rule.
That’s a bad default.
Different environments have different needs:
- Local dev often needs flexibility
- Staging may use temporary domains or incomplete TLS setups
- Production should be strict and predictable
If you set a long-lived HSTS policy on every environment, you’ll eventually cache HTTPS-only behavior for hosts that were never meant to be permanent.
Fix
Set HSTS per environment, not per company ideology.
A sane model looks like this:
- Local development: no HSTS
- Ephemeral preview environments: usually no HSTS
- Staging: low
max-age, only if HTTPS is stable - Production: strong HSTS with a long
max-age
Example policy split:
- Dev: no header
- Staging:
max-age=300 - Prod:
max-age=31536000; includeSubDomains
If you’re using Nginx:
# staging
add_header Strict-Transport-Security "max-age=300" always;
# production
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
That always matters. Without it, some responses won’t include the header.
Mistake #2: Enabling includeSubDomains before auditing subdomains
This is the classic self-own.
Teams enable:
Strict-Transport-Security: max-age=31536000; includeSubDomains
on example.com, then forget they also have:
staging.example.comdev.example.comold-admin.example.comgrafana.example.com- random inherited DNS entries nobody wants to admit still exist
Once a browser sees HSTS with includeSubDomains from the parent domain, every subdomain must be HTTPS-only too.
If any of those hosts are HTTP-only, misconfigured, expired, or internally weird, users will hit hard failures.
Fix
Before enabling includeSubDomains, inventory every live subdomain that matters to users and staff.
You want answers to these questions:
- Does it resolve publicly?
- Does it serve valid TLS?
- Does it redirect HTTP to HTTPS cleanly?
- Is it still in use?
- Is it owned by your team?
If the answer is “I think so,” don’t enable includeSubDomains yet.
A practical rollout path:
- Start with production apex or
wwwonly - Validate all subdomains
- Move to
includeSubDomains - Only then consider preload
You can quickly verify header behavior with browser dev tools or run a scan at Headertest to spot missing or inconsistent HSTS headers.
Mistake #3: Sending HSTS over HTTP
Browsers ignore HSTS if it’s delivered over plain HTTP. That’s by design.
I still see people try to attach the header to port 80 responses, usually in a redirect block, assuming that will “teach” the browser to switch to HTTPS next time. It won’t.
Fix
Serve HSTS only on HTTPS responses.
Bad Nginx pattern:
server {
listen 80;
server_name example.com;
add_header Strict-Transport-Security "max-age=31536000";
return 301 https://$host$request_uri;
}
Better:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/ssl/example.crt;
ssl_certificate_key /etc/ssl/example.key;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
If you’re terminating TLS at a load balancer or CDN, make sure the HSTS header is added there or passed through consistently from the app.
Mistake #4: Starting with a one-year max-age
People copy the “final” policy first:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That’s fine once you know everything is clean. It’s reckless on day one.
HSTS is sticky. If you make a mistake, browsers keep enforcing the policy until the max-age expires or until they receive a new valid HSTS header with max-age=0 over HTTPS.
That’s not a fun way to discover a staging host was misconfigured.
Fix
Roll out in stages.
A pattern I like:
max-age=300max-age=86400max-age=604800max-age=31536000
Move up only after you’ve tested redirects, certificates, subdomains, and CDN behavior.
For staging, keep it small permanently unless there’s a strong reason not to:
Strict-Transport-Security: max-age=300
For production after validation:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Mistake #5: Forgetting that local dev browsers cache HSTS
This one wastes hours.
A developer visits https://app.localtest.me or some internal hostname once, the browser caches HSTS, and later HTTP stops working. Then everyone starts blaming Docker, Traefik, or DNS.
The problem is usually cached browser policy.
Fix
Don’t enable HSTS for local development domains unless you really mean it.
Better options:
- Use separate local-only hostnames that never receive HSTS
- Avoid reusing production-like parent domains for local setups
- Keep dev config free of HSTS headers
If you already poisoned a local hostname with HSTS, you’ll need to clear the browser’s HSTS state for that host. The exact process is browser-specific, so check the browser’s official documentation.
The bigger fix is architectural: local dev should not inherit production security headers blindly.
Mistake #6: Using production parent domains for unstable environments
A common pattern is:
example.comfor prodstaging.example.comfor stagingpr-123.example.comfor previews
Looks tidy. It also means production HSTS with includeSubDomains affects all of them.
If preview environments are disposable, this gets ugly fast.
Fix
Separate stable and unstable environments at the DNS boundary.
For example:
- Production:
example.com - Staging:
example-staging.net - Previews:
preview-example.net
That way, your production HSTS policy doesn’t accidentally govern temporary infrastructure.
If you want strong HSTS on the production zone, keep the production zone clean and boring. That’s the real trick.
Mistake #7: Setting HSTS in two places and getting inconsistent results
I see this all the time with CDNs, ingress controllers, and app frameworks.
Maybe Nginx adds one HSTS value, the CDN adds another, and the app adds a third on some routes. Now different responses return different policies.
That makes testing confusing and rollback harder than it needs to be.
Fix
Pick one layer to own HSTS.
Usually that should be the edge:
- CDN
- load balancer
- ingress
- reverse proxy
Set it there consistently for the environment, and remove app-level duplication unless you have a specific reason.
For Apache:
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
For a Node/Express app behind a proxy, I’d usually avoid setting HSTS in the app if the proxy already owns it. If you do need app-level control, be explicit:
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production') {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
next();
});
Just don’t do both unless you’re certain the values are identical.
Mistake #8: Treating HSTS rollback like a feature flag
It’s not.
You can’t flip HSTS off and expect clients to forget immediately. Browsers cache it. If you need to disable it, you must serve this over HTTPS:
Strict-Transport-Security: max-age=0
Even then, clients only clear it after receiving that response.
Fix
Have a rollback plan before rollout.
That plan should answer:
- Who can change the header at the edge?
- Can the affected host still serve valid HTTPS during the rollback?
- How quickly can config propagate through CDN or proxy layers?
- Are there any subdomains covered by
includeSubDomainsthat complicate recovery?
If your rollback plan requires broken HTTPS to somehow fix HSTS, that plan is fiction.
Mistake #9: Jumping into preload too early
Preload is attractive because it closes the first-visit HTTP gap. It also raises the cost of mistakes.
Once you aim for preload requirements, you’re committing to a stricter posture across the whole domain space.
Fix
Treat preload as the last step, not the starting point.
You should only consider it after:
- production HTTPS is stable
- all relevant subdomains are HTTPS-capable
includeSubDomainsis already safe- your team understands the operational consequences
Check the current browser preload requirements in the official HSTS documentation before making that call: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
A practical setup that works
If I were setting this up for a typical team, I’d keep it boring:
Local dev
No HSTS.
Preview environments
No HSTS, unless the previews are long-lived and fully managed.
Staging
Strict-Transport-Security: max-age=300
No includeSubDomains unless staging is clean and stable.
Production
Phase 1:
Strict-Transport-Security: max-age=300
Phase 2:
Strict-Transport-Security: max-age=86400
Phase 3:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Then verify consistently at the edge, across redirects, error pages, and app routes.
If you want a fast sanity check after rollout, use browser dev tools or run a scan at Headertest.
HSTS is one of those headers that rewards patience. The mistakes usually come from trying to make every environment behave like production before the infrastructure is ready. Don’t do that. Production should be strict. Everything else should be intentional.