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=300means 5 minutes. That’s a safe testing value.alwaysmakes 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:
- start with
max-age=300for testing - move to
max-age=86400for one day - move to
max-age=31536000for 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:
httpblockserverblocklocationblock
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.comold-admin.example.commail.example.comm.example.comstaging.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
includeSubDomainsmax-ageof 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
locationblock 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.