HSTS on Heroku looks simple right up until you ship it wrong and lock users into a bad HTTPS setup.
I’ve seen this happen a few times: someone enables SSL on Heroku, adds a redirect to HTTPS, throws in Strict-Transport-Security, and calls it done. Then a week later they realize staging is broken, a custom domain is misconfigured, or preload was enabled before every subdomain was actually ready.
Heroku makes TLS termination easy. That does not mean HSTS is automatic, or safe by default.
Here are the mistakes I see most often, and how to fix them.
Mistake #1: Assuming Heroku adds HSTS for you
Heroku terminates TLS at the router, but it does not automatically add the Strict-Transport-Security header for your app.
That means users may get redirected from HTTP to HTTPS, but browsers still won’t remember to use HTTPS next time unless your app explicitly sends HSTS on secure responses.
Fix
Set the header in your application code or web server config.
A good starting value:
Strict-Transport-Security: max-age=31536000
That tells browsers to use HTTPS for the next year.
Express example
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: false,
preload: false
}));
app.get('/', (req, res) => {
res.send('Hello from Heroku');
});
app.listen(process.env.PORT || 3000);
Rails example
Rails has this built in:
# config/environments/production.rb
config.force_ssl = true
config.ssl_options = {
hsts: {
expires: 1.year,
subdomains: false,
preload: false
}
}
If you want to verify what your app is actually returning, run a scan with headertest.com or inspect the live response headers directly.
Mistake #2: Sending HSTS over HTTP responses
Browsers ignore HSTS headers sent over plain HTTP. They only honor them on valid HTTPS responses.
I still see apps trying to set HSTS globally, including on insecure requests. That does nothing useful.
Fix
Make sure your app serves HSTS only on HTTPS responses, and that HTTP gets redirected immediately.
On Heroku, your app sits behind a proxy, so you need to trust forwarded protocol headers correctly.
Express behind Heroku proxy
const express = require('express');
const helmet = require('helmet');
const app = express();
// Required so req.secure works correctly behind Heroku
app.enable('trust proxy');
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});
app.use(helmet.hsts({
maxAge: 31536000
}));
app.listen(process.env.PORT || 3000);
Without trust proxy, Express may think every request is HTTP and either redirect forever or behave inconsistently.
Mistake #3: Forgetting trust proxy and causing redirect loops
This is one of the most common Heroku-specific mistakes.
Heroku terminates TLS before traffic reaches your dyno. Your app sees forwarded headers like X-Forwarded-Proto: https, not a direct TLS socket. If your framework is not configured to trust the proxy, it may think the request is insecure even when the browser is already on HTTPS.
That leads to:
- infinite redirects
- broken login flows
- cookies not marked secure properly
- HSTS only appearing sometimes
Fix
Tell your framework to trust Heroku’s proxy.
Express
app.enable('trust proxy');
Django
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
Rails
config.force_ssl = true
Rails generally handles this well on Heroku, but you still need to test actual production behavior.
Heroku’s official docs on request routing and headers are worth reading when debugging proxy behavior: https://devcenter.heroku.com/
Mistake #4: Enabling includeSubDomains too early
This one bites teams with old DNS records, forgotten admin panels, or random subdomains pointing somewhere weird.
If you send:
Strict-Transport-Security: max-age=31536000; includeSubDomains
you are telling browsers that every subdomain must use HTTPS. Not just www. Not just app. All of them.
That includes things like:
api.example.comold.example.comstaging.example.comassets.example.commail.example.comif it’s web-facing- weird forgotten subdomains from five years ago
If any of them are not ready for HTTPS, users will get blocked hard.
Fix
Start with the parent domain only:
Strict-Transport-Security: max-age=31536000
Then inventory your subdomains before enabling includeSubDomains.
I like this rollout:
- Enable HTTPS everywhere.
- Redirect HTTP to HTTPS everywhere.
- Verify certificates and app behavior on every active subdomain.
- Add HSTS without subdomains.
- Wait and monitor.
- Add
includeSubDomainsonly when you know the whole namespace is clean.
If your Heroku app only serves one hostname, don’t assume your DNS estate is equally tidy.
Mistake #5: Jumping straight to preload
Preload is where people get overconfident.
A preload-ready header looks like this:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
That tells browsers you want your domain hardcoded into preload lists. Once accepted, backing out is slow and painful. If you made a mistake, users may be stuck for a long time.
On Heroku, preload mistakes usually happen when:
- apex and
wwware not both consistently HTTPS - some subdomains still live outside Heroku and aren’t TLS-ready
- staging shares the main domain namespace
- redirect chains are messy
- certificate coverage is incomplete
Fix
Treat preload as the last step, not the first.
Before enabling it, verify:
- HTTPS works on apex and
www - HTTP redirects cleanly to HTTPS
- all subdomains are HTTPS-only
- no mixed-content or cert issues remain
- you really want this policy across the full domain
If you are not completely sure, don’t preload yet. A normal one-year HSTS policy already gives strong protection.
Mistake #6: Using HSTS on staging or review apps
Heroku pipelines make it easy to spin up staging apps and review apps. That’s great for delivery, but terrible if you copy production HSTS settings blindly.
You probably do not want strong HSTS on:
staging.example.com- temporary review domains
- internal test environments
- hosts that get torn down and recreated
Browsers cache HSTS aggressively. If a staging environment later loses HTTPS support or changes purpose, your team can get stuck with weird browser behavior that looks like “the site is broken” when really the browser is enforcing an old policy.
Fix
Limit HSTS to stable production hostnames.
Example in Express:
app.use((req, res, next) => {
const host = req.hostname;
if (host === 'example.com' || host === 'www.example.com') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
}
next();
});
That approach is blunt, but effective.
Mistake #7: Setting a huge max-age on day one
If you send a two-year HSTS policy before validating your setup, you’re taking on risk you probably do not need.
A bad HSTS rollout is annoying because browsers keep enforcing it even after you fix the server config. That’s the whole point of HSTS, and also why testing matters.
Fix
Roll out in phases.
I usually recommend something like this:
- Start small:
Strict-Transport-Security: max-age=300
- Validate behavior in production.
- Increase to a day:
Strict-Transport-Security: max-age=86400
- Increase to a year:
Strict-Transport-Security: max-age=31536000
Once you’re confident, keep the one-year value. Only move to preload requirements when you actually mean it.
Mistake #8: Forgetting custom domains and certificate coverage
Heroku apps often respond on:
your-app.herokuapp.comwww.example.comexample.com
HSTS is hostname-based. If users hit multiple hostnames, each one needs a clean HTTPS story. Also, certificate coverage has to match reality.
A common mess looks like this:
www.example.comworksexample.comredirects, but cert setup is incomplete- HSTS is sent anyway
- users get certificate errors they can’t bypass cleanly
Fix
Audit every public hostname before enabling HSTS broadly.
On Heroku, check:
- all custom domains are added correctly
- ACM or your certificate setup is active
- apex and
wwwboth terminate TLS correctly - redirects are consistent and minimal
Heroku’s official SSL docs are the place to confirm platform behavior and domain setup: https://devcenter.heroku.com/
Mistake #9: Thinking HTTPS redirect alone is enough
Redirecting HTTP to HTTPS is necessary. It is not the same as HSTS.
Without HSTS, a user’s first visit can still be downgraded or intercepted before the redirect happens. HSTS closes that gap for later visits by teaching the browser to skip HTTP entirely.
Fix
Use both:
- a permanent redirect from HTTP to HTTPS
- HSTS on successful HTTPS responses
That combination is the baseline.
A practical Heroku-safe HSTS rollout
If I were setting this up on a normal Heroku production app today, I’d do it like this:
- Enable TLS for all production domains.
- Fix proxy trust in the app.
- Force HTTPS redirects.
- Start with:
Strict-Transport-Security: max-age=300
- Test production hostnames thoroughly.
- Raise to:
Strict-Transport-Security: max-age=31536000
- Add
includeSubDomainsonly after a full subdomain audit. - Add
preloadonly if you fully understand the blast radius.
That’s boring advice, which is exactly why it works.
If you want a quick check before or after rollout, scan the live site with headertest.com and verify the actual headers browsers see, not just what you think your code sends.
Heroku removes a lot of TLS operational pain. HSTS still needs deliberate setup. Get the proxy handling right, keep production and staging separate, and do not preload your whole domain because a blog post made it sound cool.