HSTS on Fly.io looks simple right up until it breaks logins, bricks a staging subdomain, or quietly does nothing because the header never reaches the browser.

I’ve seen all three.

If you’re deploying on Fly.io, the platform handles TLS nicely, but HSTS is still your job. That’s where people get tripped up: they assume “HTTPS is on” means “HSTS is done.” Not even close.

Here are the mistakes I see most often, why they happen on Fly.io, and how I’d fix them.

Mistake 1: Setting HSTS before HTTPS is fully enforced

HSTS only makes sense when every request is already safely on HTTPS. If users can still hit http:// and get content, mixed behavior, or weird app responses, sending HSTS is premature.

On Fly.io, TLS termination usually happens at the edge. Your app often sees proxied traffic, and if your framework isn’t configured to trust proxy headers, it may think the request is plain HTTP. That leads to bad redirects, insecure cookies, and inconsistent behavior.

What to do

First, make sure HTTP is redirected to HTTPS everywhere.

In fly.toml, that typically means:

[[services]]
  internal_port = 8080
  protocol = "tcp"

  [[services.ports]]
    port = 80
    handlers = ["http"]
    force_https = true

  [[services.ports]]
    port = 443
    handlers = ["tls", "http"]

That force_https = true handles the redirect at Fly’s edge, which is usually what you want.

Then make sure your app trusts forwarded headers correctly.

Express example

import express from "express";

const app = express();

// Trust Fly's proxy so req.secure works correctly
app.set("trust proxy", true);

app.use((req, res, next) => {
  if (req.secure) {
    res.setHeader(
      "Strict-Transport-Security",
      "max-age=31536000; includeSubDomains"
    );
  }
  next();
});

app.get("/", (req, res) => {
  res.send("hello");
});

app.listen(8080);

If you skip trust proxy, req.secure may be false behind Fly’s proxy, and your app logic can go sideways.

Mistake 2: Sending HSTS on HTTP responses

Browsers ignore HSTS over plain HTTP. That’s by design. If you’re trying to set the header during the redirect from HTTP to HTTPS and assuming that counts, it doesn’t.

This catches people because they inspect the 301 response and see Strict-Transport-Security, then assume they’re covered.

They aren’t.

The browser only honors HSTS when received over a valid HTTPS connection.

What to do

Serve the header on the final HTTPS response, not just the redirect.

Good:

  • http://example.com → 301 to HTTPS
  • https://example.com → 200 with Strict-Transport-Security

Bad:

  • http://example.com → 301 with Strict-Transport-Security
  • https://example.com → 200 without it

You can verify this quickly with browser devtools or a scanner like headertest.com.

Mistake 3: Using a tiny max-age forever

A lot of teams start with:

Strict-Transport-Security: max-age=300

That’s fine for testing. It’s not fine if it stays there for months because nobody revisits it.

A five-minute policy barely protects anything in the real world. If a user comes back tomorrow, the browser has forgotten the rule.

What to do

Roll it out in phases:

  1. Start with something short like max-age=300
  2. Move to max-age=86400
  3. Move to max-age=31536000 once you’re confident

For a stable production app on Fly.io, I’d generally aim for:

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

That’s one year, which is a sane default for production.

Mistake 4: Turning on includeSubDomains too early

This one hurts.

includeSubDomains tells browsers to enforce HTTPS for every subdomain too. That includes forgotten admin hosts, old staging domains, random customer subdomains, and internal tools someone exposed years ago.

On Fly.io, it’s common to have multiple apps, review environments, or service-specific subdomains. If even one of them is not ready for HTTPS-only use, includeSubDomains can cause outages that are surprisingly annoying to unwind.

What to do

Inventory your subdomains before enabling it.

Ask:

  • Does every subdomain serve valid HTTPS?
  • Are there any old DNS entries still pointing somewhere?
  • Do staging or preview environments live under the main production domain?
  • Are any subdomains intentionally HTTP-only? If yes, fix that first or keep them out of the namespace

If you’re not sure, start without it:

Strict-Transport-Security: max-age=31536000

Then add includeSubDomains later.

I’m conservative here. HSTS mistakes are sticky because browsers cache them aggressively.

Mistake 5: Preloading too soon

A lot of developers see preload and think “more secure, must enable now.”

Slow down.

Preload means asking browsers to hardcode your domain as HTTPS-only. That’s a serious commitment. If you preload a domain and later need to serve anything over HTTP, you’re in for a bad time.

To qualify, you typically need something like:

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

That sounds easy. The hard part is operational discipline.

What to do

Only preload when all of this is true:

  • HTTPS works on the apex and www
  • all subdomains are HTTPS-ready
  • you control the full namespace
  • you won’t need HTTP for legacy services
  • your redirects and certificates are boring and reliable

If your Fly.io setup includes ephemeral apps, test hosts, or tenant subdomains you don’t fully control, preload is usually a bad idea.

If you want the preload requirements and process, check the browser vendor docs and the relevant HSTS references in official documentation.

Mistake 6: Setting HSTS in multiple layers with conflicting values

This happens a lot on Fly.io because there are several places you might touch headers:

  • app code
  • framework middleware
  • reverse proxy inside your container
  • CDN or edge config in front of Fly
  • platform-specific config elsewhere

Then someone sets:

  • Express: max-age=31536000
  • Nginx: max-age=0
  • framework plugin: no includeSubDomains

Now you’ve got inconsistent behavior depending on route or response path.

What to do

Pick one source of truth for HSTS if possible.

For app-driven stacks, I usually prefer framework middleware because it’s visible in code review and travels with the app.

Nginx example

server {
    listen 8080;
    server_name _;

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

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

The always matters. Without it, some responses won’t include the header.

Then remove duplicate HSTS config elsewhere unless you truly need it.

Mistake 7: Sending HSTS from staging or preview environments

This one is easy to miss on Fly.io because spinning up apps is so quick.

If your staging app lives at something like staging.example.com and you enable HSTS with includeSubDomains on the parent domain, browsers will remember that. Later, if staging changes, breaks, or uses a temporary certificate setup, you can lock your own team out until browser state is cleared.

What to do

Keep staging and previews on separate domains if possible.

For example:

  • production: example.com
  • staging: example-staging.net
  • previews: myapp.fly.dev or another isolated domain

That separation makes HSTS much safer. I’m a big fan of not mixing production trust boundaries with experimental environments.

Mistake 8: Forgetting that HSTS does not protect first contact

HSTS only helps after the browser has already seen the policy once, unless the domain is preloaded.

That means a user’s very first visit can still be vulnerable to downgrade or SSL stripping attacks if they type the domain manually over HTTP and you’re not preloaded.

What to do

Use HSTS, but understand what problem it solves.

You still want:

  • edge HTTPS redirects
  • secure cookies
  • no mixed content
  • solid TLS config
  • preload only if you’re truly ready

HSTS is one layer, not the whole story.

Mistake 9: Not checking the actual response path users hit

Developers often test / and call it done. Meanwhile:

  • app pages have HSTS
  • static assets don’t
  • redirects from www differ from apex
  • error pages omit the header
  • framework exceptions bypass middleware

On Fly.io, different handlers or services can make this more subtle than expected.

What to do

Check:

  • https://example.com/
  • https://www.example.com/
  • common redirects
  • 404 pages
  • 500 pages
  • authenticated routes if they use a different stack

If you use a scanner, make sure it follows the real chain and reports final HTTPS responses. headertest.com is handy for a quick pass.

A practical baseline for Fly.io

If your production domain is fully HTTPS-ready and all subdomains are under control, this is a reasonable target:

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

And if you’re using Express behind Fly:

import express from "express";

const app = express();
app.set("trust proxy", true);

app.use((req, res, next) => {
  if (req.secure) {
    res.setHeader(
      "Strict-Transport-Security",
      "max-age=31536000; includeSubDomains"
    );
  }
  next();
});

app.get("/", (req, res) => {
  res.send("secure app");
});

app.listen(8080);

Pair that with force_https = true in fly.toml, and you’ve covered the basics cleanly.

Final gut-check before shipping

Before you enable a long-lived HSTS policy on Fly.io, I’d verify:

  • HTTP always redirects to HTTPS at the edge
  • your app trusts Fly’s proxy headers
  • HSTS is present on final HTTPS responses
  • you’re not accidentally covering fragile subdomains
  • staging and previews are isolated from production
  • only one layer owns the header
  • you’ve tested redirects, errors, and alternate hostnames

That’s the difference between “we enabled HSTS” and “we deployed HSTS without creating future pain.”

For platform-specific details on TLS, custom domains, and app configuration, check the official Fly.io docs and your framework’s official proxy/header documentation.