I’ve seen this pattern a lot on managed platforms: the app is already on HTTPS, the TLS certificate is valid, redirects are enabled, and everyone assumes transport security is done.

It usually isn’t.

For one client project running on DigitalOcean App Platform, the site looked fine at a glance. https:// loaded cleanly, the platform handled certificates, and HTTP redirected to HTTPS. But the app still had a gap that matters in the real world: no HSTS header.

That meant first-contact requests could still be downgraded or intercepted before the browser learned to prefer HTTPS. If you care about session cookies, login flows, or just not leaving obvious holes in a production app, that’s sloppy.

Here’s how we fixed it, what changed, and what I’d do again.

The setup

The app was a typical App Platform deployment:

  • Frontend served by DigitalOcean App Platform
  • Custom domain attached
  • Managed TLS enabled
  • HTTP automatically redirected to HTTPS
  • Backend app written in Node.js/Express

At first, the team assumed App Platform would “just handle” HSTS. That assumption is common, and wrong often enough that I always verify headers myself.

The first check showed this response:

HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate
x-powered-by: Express

No Strict-Transport-Security.

That’s the whole problem.

Why the redirect alone wasn’t enough

A redirect from http://example.com to https://example.com is better than nothing, but the very first request can still be made over plain HTTP. If someone is sitting on hostile Wi-Fi or messing with traffic upstream, they can interfere before the browser ever reaches the HTTPS version.

HSTS changes the browser’s behavior after it sees the header once. From then on, the browser upgrades future requests to HTTPS before making the request.

That matters for:

  • login pages
  • session cookies
  • password reset flows
  • admin panels
  • APIs called from browser-based apps

If your app is already HTTPS-only in practice, HSTS is the policy that tells the browser to enforce that.

The “before” state

The original Express app had a basic security middleware setup, but it was incomplete:

const express = require("express");
const app = express();

app.disable("x-powered-by");

app.get("/", (req, res) => {
  res.send("Hello from App Platform");
});

app.listen(process.env.PORT || 8080);

That app relied on the platform for TLS termination and redirects. Nothing in the app explicitly set HSTS.

On DigitalOcean App Platform, that’s a key point: TLS may terminate at the edge, but your app still controls many response headers. If the platform doesn’t inject HSTS for you, your app needs to send it.

The fix

We added HSTS at the application layer.

For Express, the cleanest approach is usually helmet:

const express = require("express");
const helmet = require("helmet");

const app = express();

app.disable("x-powered-by");

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: false,
    },
  })
);

app.get("/", (req, res) => {
  res.send("Hello from App Platform");
});

app.listen(process.env.PORT || 8080);

That produced the header we wanted:

strict-transport-security: max-age=31536000; includeSubDomains

Simple, but there’s a catch: don’t jump straight to a one-year policy on day one unless you’re sure every relevant host is HTTPS-ready.

I usually roll HSTS out in stages.

The rollout strategy that avoids self-inflicted pain

This is where teams get burned.

If you send:

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

you’re telling browsers to force HTTPS for the entire domain tree for a year. That includes subdomains. If you still have old subdomains, forgotten staging hosts, or weird vendor integrations on HTTP, you can break them hard.

So the safer rollout looked like this.

Stage 1: short max-age

We started with:

app.use(
  helmet({
    hsts: {
      maxAge: 300,
      includeSubDomains: false,
      preload: false,
    },
  })
);

That’s 5 minutes.

It sounds tiny, but it’s enough to validate:

  • the header is present
  • HTTPS works everywhere expected
  • there are no mixed-content surprises
  • no weird subdomain dependencies are exposed

Response header after deployment:

strict-transport-security: max-age=300

Stage 2: one week

After verifying production behavior, we increased it:

app.use(
  helmet({
    hsts: {
      maxAge: 604800,
      includeSubDomains: true,
      preload: false,
    },
  })
);

Now the policy covered subdomains too, but only for a week.

That’s long enough to get real browser behavior across your user base, without locking yourself into a bad decision for months.

Stage 3: one year

Once the team confirmed all active subdomains were HTTPS-capable, we moved to the final policy:

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: false,
    },
  })
);

That’s the version I’m comfortable calling production-grade for most apps.

The “after” state

After rollout, the response looked like this:

HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate
strict-transport-security: max-age=31536000; includeSubDomains

That changed browser behavior in a way redirects alone never could.

The security review also got cleaner:

  • HTTPS enforced by browser after first secure visit
  • reduced downgrade attack exposure
  • stronger baseline for cookies and authenticated sessions
  • clearer transport policy for the domain

Not flashy, but very real.

A manual header version if you don’t want Helmet

If you don’t want the dependency, set the header yourself:

const express = require("express");
const app = express();

app.disable("x-powered-by");

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

app.get("/", (req, res) => {
  res.send("Hello from App Platform");
});

app.listen(process.env.PORT || 8080);

That works fine. I still prefer helmet for consistency across projects, but this is perfectly valid.

Gotchas on DigitalOcean App Platform

A few practical notes from doing this on App Platform:

1. Verify the custom domain, not just the default app URL

Your app may be reachable on a default ondigitalocean.app hostname and one or more custom domains. Check the hostnames users actually visit.

If HSTS is only correct on one host, you haven’t really finished the job.

2. Be careful with includeSubDomains

This directive is great when your domain is clean and consistently HTTPS. It’s dangerous when your DNS has accumulated junk over the years.

I’ve seen old dev, legacy, and status-old subdomains come back to haunt teams right here.

3. Don’t use preload casually

To qualify for preload, you generally need:

  • max-age of at least one year
  • includeSubDomains
  • preload

Example:

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

I don’t recommend enabling preload unless you’ve deliberately audited the whole domain space and know exactly why you want it. Preload is powerful, but it’s not a casual toggle.

4. Test after every deploy

Managed platforms can change behavior at the edge, and app changes can accidentally drop headers.

I like checking with browser dev tools, curl, and a scanner. For a quick read on your live headers, run a scan at headertest.com.

A basic curl check works too:

curl -I https://yourdomain.com

You want to see:

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

What changed for the team operationally

The technical change was tiny. The operational change mattered more.

Before this fix, transport security lived in that vague zone of “the platform handles it.” Afterward, the team treated security headers as part of the application contract, not a side effect of hosting.

That led to two better habits:

  1. Headers were added to deployment verification checklists.
  2. Security middleware became standard in new services.

That’s usually how these improvements stick. Not because one header saves the day, but because the team stops assuming the platform has every edge case covered.

A practical baseline for App Platform apps

If I were shipping a new browser-facing app on DigitalOcean App Platform today, my minimum would be:

  • HTTPS enabled with a valid certificate
  • HTTP redirected to HTTPS
  • HSTS enabled with staged rollout
  • other core headers reviewed and tested

For Express, that’s roughly:

const express = require("express");
const helmet = require("helmet");

const app = express();

app.disable("x-powered-by");
app.use(helmet());

app.get("/", (req, res) => {
  res.send("Secure enough to sleep at night");
});

app.listen(process.env.PORT || 8080);

Then tune HSTS deliberately instead of guessing.

If you want the platform-specific deployment details, check the official DigitalOcean App Platform docs and confirm how your app handles custom domains, ingress, and TLS termination in your current setup. The app code may still be the right place to enforce the final header policy.

That was the real lesson from this case: HTTPS on App Platform is not the same thing as a complete HTTPS policy. HSTS closed the gap.