HSTS in Nginx is one of those changes that looks tiny in config, but it has very real consequences. Done right, it closes off downgrade attacks and makes sure browsers stop trying plain HTTP for your domain. Done wrong, you can lock users into a broken HTTPS setup for weeks or months.

I’ve seen teams treat HSTS like a checkbox: add one header, deploy, move on. That’s how people end up with subdomains they forgot about, stale certificates, and support tickets from users who can’t get in anymore. The header is simple. The rollout is the hard part.

What HSTS actually does

HSTS stands for HTTP Strict Transport Security. It tells the browser:

  • only use HTTPS for this site
  • don’t allow certificate warning bypasses
  • optionally apply this rule to all subdomains
  • optionally signal that the domain wants to be included in browser preload lists

Once a browser sees a valid HSTS header over HTTPS, it remembers that policy for the max-age duration. From that point on, requests to http://yourdomain.com get internally upgraded to HTTPS before the browser even makes the network request.

That matters because redirects from HTTP to HTTPS are not enough on their own. Without HSTS, an attacker on the network can interfere with that first HTTP request. HSTS removes that gap after the browser has learned the policy.

Before you enable HSTS in Nginx

Don’t add HSTS until these are true:

  • HTTPS works correctly on your main site
  • your certificate chain is valid
  • HTTP already redirects cleanly to HTTPS
  • all critical subdomains support HTTPS if you plan to use includeSubDomains
  • you understand that browsers will cache the policy

That last point is the big one. If you set:

Strict-Transport-Security: max-age=31536000; includeSubDomains

you’re telling browsers to enforce HTTPS for a full year across the domain and all subdomains. You don’t get to casually undo that for users who already cached it.

The basic HSTS header for Nginx

In Nginx, HSTS is sent with the Strict-Transport-Security response header. The most common starting point looks like this:

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate /etc/ssl/certs/example/fullchain.pem;
    ssl_certificate_key /etc/ssl/private/example/privkey.pem;

    add_header Strict-Transport-Security "max-age=300" always;

    location / {
        root /var/www/example;
        index index.html;
    }
}

A few things matter here:

  • max-age=300 means 5 minutes. That’s a safe testing value.
  • always makes Nginx send the header even on error responses like 404 or 500. I strongly recommend using it.
  • this must be served over HTTPS, not HTTP

If you skip always, you can end up with inconsistent behavior where some responses include HSTS and some don’t. That’s sloppy, and browsers learn policy from the responses they actually get.

Start small, then increase max-age

This is the part people rush.

A sane rollout usually goes like this:

  1. start with max-age=300 for testing
  2. move to max-age=86400 for one day
  3. move to max-age=31536000 for one year when you’re confident

That gradual ramp gives you time to catch broken subdomains, mixed content, certificate issues, and weird redirect chains.

Here’s a more production-ready example once you’re comfortable:

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        proxy_pass http://app_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

This is the typical “real” config behind a reverse proxy. Nothing fancy. Just make sure every subdomain really is ready before you add includeSubDomains.

Don’t set HSTS on port 80

A common mistake is trying to add the HSTS header in the HTTP-to-HTTPS redirect server block. That does not help. Browsers only honor HSTS when they receive it over a valid HTTPS connection.

Use your port 80 block only for redirects:

server {
    listen 80;
    server_name example.com www.example.com;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    root /var/www/example;
    index index.html;
}

That split is clean and predictable. Port 80 redirects. Port 443 serves content and sends security headers.

Where to put the header in Nginx

You can define add_header in several places:

  • http block
  • server block
  • location block

I prefer setting HSTS in the HTTPS server block for the sites that should use it. That keeps scope obvious and avoids accidentally applying it somewhere weird.

Be careful with nested location blocks. In Nginx, add_header inheritance can be surprising. If you define other add_header directives inside a location, they can override parent header behavior depending on your version and setup. If you start customizing headers per location, double-check that HSTS is still being sent everywhere you expect.

This is one reason I like to test actual responses instead of trusting the config in my head.

Should you use includeSubDomains?

Only if you mean it.

includeSubDomains tells the browser the HSTS policy applies to every subdomain under the current domain. That includes obvious hosts like www, but also forgotten ones like:

  • dev.example.com
  • old-admin.example.com
  • mail.example.com
  • m.example.com
  • staging.example.com

If one of those doesn’t support HTTPS properly, users who have cached your HSTS policy may be blocked from reaching it.

For a company with a tidy DNS setup and centralized certificate management, includeSubDomains is usually the right move. For a messy legacy environment, it can be painful. Be honest about what you’re running.

Should you use preload?

Maybe, but preload is not where you start.

The preload token looks like this:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

That says your domain wants to be added to browser preload lists. Once accepted, major browsers hardcode HTTPS-only behavior for your domain from the first visit. That’s stronger than regular HSTS because it removes the “first visit” problem.

But preload comes with strict requirements:

  • valid HTTPS on the apex and all subdomains
  • includeSubDomains
  • max-age of at least 31536000
  • redirect from HTTP to HTTPS everywhere

And the big catch: removing yourself from preload is slow and annoying. I would not touch preload until the domain has been stable under HSTS for a while.

How to verify HSTS is working

First, check the Nginx config:

  • nginx -t
  • reload Nginx
  • inspect the response headers over HTTPS

You can use browser dev tools, curl -I, or an online checker. For a quick external check, Test your HSTS configuration and other security headers at headertest.com — free, instant, no signup required.

You want to see the header on HTTPS responses, like:

HTTP/2 200
server: nginx
strict-transport-security: max-age=31536000; includeSubDomains
content-type: text/html

If it’s missing, check these usual suspects:

  • you added it only in the port 80 block
  • Nginx config wasn’t reloaded
  • another location block is overriding headers
  • you’re testing through a CDN or proxy that strips or rewrites headers
  • you’re hitting a different virtual host than you think

That last one bites people all the time on multi-site Nginx boxes.

HSTS behind a load balancer or CDN

If Nginx sits behind Cloudflare, AWS ALB, or another proxy, think about where the header should be set.

You can set HSTS at:

  • the edge layer, like CDN or load balancer
  • the origin, in Nginx
  • both, if you know exactly what’s happening

I usually prefer setting it at the edge if that’s where TLS terminates for users. That ensures the client always sees the policy consistently. If TLS terminates at Nginx directly, then Nginx is the right place.

Just don’t create conflicting policies across layers. If your CDN sends max-age=300 and Nginx sends max-age=31536000, debugging gets annoying fast.

How to disable or reduce HSTS if needed

You can tell browsers to forget the policy by serving:

Strict-Transport-Security: max-age=0

But this only helps after the browser successfully reaches your site over HTTPS and receives that updated header. It does not magically undo policies already baked into preload lists, and it does not help if your HTTPS setup is so broken the browser refuses the connection entirely.

That’s why cautious rollout matters. HSTS is easy to enable and much harder to back out of cleanly.

Common mistakes I keep seeing

Using a one-year max-age on day one

Bad idea unless you’re very sure of your setup. Start with minutes, then days, then a year.

Enabling includeSubDomains without auditing DNS

If you don’t know every live subdomain under the zone, you’re not ready.

Forgetting non-production hosts

Internal tools, VPN portals, and old admin panels count too if they live under the same parent domain.

Sending HSTS only on 200 responses

Use always so errors and redirects also carry the header.

Assuming redirects are enough

They’re not. Redirects help with usability. HSTS helps with security.

A practical rollout plan

If I were enabling HSTS on a production Nginx site today, I’d do this:

Step 1: confirm HTTPS is solid

Check certificate validity, redirects, and key subdomains.

Step 2: deploy a short max-age

Use:

add_header Strict-Transport-Security "max-age=300" always;

Watch for breakage.

Step 3: increase to one day

If nothing weird shows up, move to:

add_header Strict-Transport-Security "max-age=86400" always;

Step 4: audit subdomains

Only now decide whether includeSubDomains is safe.

Step 5: move to one year

Once you trust it:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Step 6: consider preload later

Not during the first rollout. Preload is a commitment, not an experiment.

Final Nginx example I’d actually use

For a straightforward production setup:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    root /var/www/example/public;
    index index.html index.htm;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

That’s enough for most sites.

HSTS in Nginx is not complicated technically. The real job is making sure your domain is ready for the policy you’re advertising. If you treat it with that level of respect, it’s one of the highest-value headers you can deploy in a few lines of config.