When does TLS termination happen in the backend?
When a browser sends an HTTPS request to your backend server, where does the encryption actually get undone? Or in other words, where does the TLS handshake and termination happen?
In most cases, you spin up a Next.js app on Vercel, point your domain at it, and you’re done — there’s a perfectly-valid TLS certificate being served on your behalf, rotated for you, with HTTP/2 and HTTP/3 negotiated transparently, and you’ve never had to write the word “TLS” anywhere in your codebase. Vercel, Netlify, Cloudflare Pages, Render, Fly.io all do the same thing: they own the edge, terminate TLS there, and forward plain HTTP to your application. The mundane details — handshakes, certs, cipher suites, renewal cron jobs — get stripped away so cleanly that it’s possible to ship serious production apps without ever thinking about any of it. Which is great, right up until you find yourself running an application that doesn’t live behind one of those platforms, and you suddenly need to know which box is responsible for the encrypted bytes.
What TLS termination actually means
TLS termination is the point in the network path where the encrypted TLS bytes get turned back into plain HTTP. Browsers and clients send HTTPS over the wire; some box on your side has to perform the handshake, decrypt the request, hand the plaintext to your application, encrypt the response, and send it back. That box — wherever it lives — is doing TLS termination.
There are exactly two places it can live in practice. Either it’s a load balancer or reverse proxy sitting in front of your application server — an AWS ALB, Nginx, Caddy — or it’s the application server itself, with the TLS certificate loaded directly into the process. Both work. Both look identical to the client. The operational story is wildly different, though, and that’s the part worth thinking about.
What the code looks like
The simplest way to see the difference is to look at how the server boots in each case.
If you’re terminating at a proxy in front of you, the application server speaks plain HTTP and doesn’t know or care that the original client connection was encrypted:
app = express();
server = http.createServer(app);
app.set('trust proxy', 1);
initWebSockets(server);
The trust proxy setting matters here. It tells Express that there’s exactly one reverse proxy in front, and that the app should trust the X-Forwarded-* headers it sets — X-Forwarded-For for the real client IP, X-Forwarded-Proto for the original protocol, and so on. Without it, your app sees the proxy’s IP and assumes the request came in over http, which breaks log accuracy and any code that branches on whether the request was TLS-protected.
If you’re terminating in the server itself, you swap http.createServer for https.createServer and hand it the cert and key directly:
app = express();
server = https.createServer({
cert: fs.readFileSync(path.join(__dirname, 'cert.pem')),
key: fs.readFileSync(path.join(__dirname, 'key.pem')),
}, app);
app.set('trust proxy', 1);
app.set('x-powered-by', false);
initWebSockets(server);
Now the Node process is doing the handshake, decrypting bytes off the socket, and (more interestingly) responsible for the lifecycle of those cert.pem and key.pem files. That last responsibility is most of what makes the two patterns feel so different in production.
Why I default to terminating at the proxy
For anything that runs in AWS — which is most of what I build — the answer is almost always to terminate at the load balancer and let the application speak plain HTTP behind it. Four reasons compound on each other.
The first is cert rotation. AWS Certificate Manager, like any managed cert store, rotates certificates without the application restarting. The ALB picks up the new cert and keeps serving traffic; my app never knows it happened. If I read cert.pem and key.pem from disk at process boot, rotating certs requires a redeploy, or a hot-reload mechanism I have to build and test myself. Neither is hard, but neither is free, and ACM is doing it for me already.
The second is performance and offload. The TLS handshake is the expensive part of the connection lifecycle, and it’s also where OCSP stapling, ALPN negotiation, HTTP/2 upgrade, and HTTP/3 all get handled. ALB does all of that out of the box, in C, at scale. A Node process terminating TLS is single-process JavaScript and is noticeably slower under load — and you’d be building HTTP/2 and HTTP/3 support yourself if you wanted them.
The third is operational uniformity. Once TLS terminates at the ALB, everything that lives at the edge — WAF rules, request logging, mTLS for admin paths, health checks, rate limiting — sits in one place. If TLS terminates inside the Node process, those concerns either get duplicated or split awkwardly between the LB and the app. You end up with two sources of truth for “what does an incoming HTTPS request look like” and that surface area gets inconsistent fast.
The fourth is the WebSocket upgrade path. The handshake that promotes ws:// to wss:// flows through the load balancer too, and you really don’t want two TLS hops in the middle of it. Terminating once, cleanly, at the ALB keeps the WebSocket story simple.
When terminating in the server makes sense
It’s not never. The case where I’d actually do it is what you might call the single-person, self-hosted setup — a project running on one box, where I’m comfortable owning the cert lifecycle (typically via certbot and an automated renewal hook), and where the operational machinery of a load balancer would be more weight than the project deserves. There’s a whole genre of “host your personal app on a Hetzner box” tutorials that ends up here, and for good reason: when there’s no team and no compliance overhead, the simplest thing is often best.
The other case is when a proxy isn’t an option: an embedded device, an air-gapped network, a protocol that doesn’t TLS-terminate cleanly at the LB layer. These are rare in the kind of work I do, but they exist, and when they come up, terminating in the server is the right call.
What you generally don’t want to do is terminate in both places at once — TLS at the ALB and TLS at the Node process behind it, so the LB decrypts and then re-encrypts before talking to your app. That pattern is occasionally justified, usually by a compliance requirement that demands encryption-in-transit even inside a private VPC, but it doubles the handshake cost, doubles the cert lifecycle complexity, and offers no security benefit over a properly-configured VPC in most threat models. If you find yourself reaching for it, check whether the regulation actually requires it or whether someone just felt nervous.
The mental model
The thing that helped me stop overthinking this is to remember that “where TLS terminates” is really the same question as “where the boundary between the trusted and untrusted network lives.” If your application servers are inside a VPC, behind a load balancer that has a public ACM cert, then the trust boundary is the ALB and the inside of the VPC is your trusted network. The Node process speaking plain HTTP isn’t a security gap; it’s a design choice that says “we trust the VPC.”
If your application server is the trust boundary — exposed to the public internet directly, with no LB in front — then it has to terminate TLS itself, because there’s nothing else to do it. That’s the self-hosted case, and it’s a legitimate place to land.
So the real question isn’t “should my server use HTTPS?” It’s “what’s between my server and the public internet, and does that thing already terminate TLS?” If the answer is “an ALB,” let the ALB do its job and keep your application code boring.