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.com
  • dev.example.com
  • old-admin.example.com
  • grafana.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:

  1. Start with production apex or www only
  2. Validate all subdomains
  3. Move to includeSubDomains
  4. 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:

  1. max-age=300
  2. max-age=86400
  3. max-age=604800
  4. max-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.com for prod
  • staging.example.com for staging
  • pr-123.example.com for 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 includeSubDomains that 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
  • includeSubDomains is 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.