RE:NODE

Networking13 min read

What a reverse proxy does: TLS, hostnames and forwarded IPs

How a reverse proxy terminates TLS, routes by hostname and passes the real client IP in X-Forwarded-For, and what breaks when your app ignores it.

Updated

0 readers

Your application listens on a port that was allocated to it - 3000, 8000, something with five digits. Visitors type a name and expect port 443 with a padlock. The thing in between is a reverse proxy, and understanding it explains most of what looks like magic in the panel's proxy slot, most of what goes wrong when you deploy something for the first time, and exactly why every one of your visitors appears in your logs as the same IP address.

A reverse proxy is a server that accepts connections on behalf of other servers. "Reverse" distinguishes it from the kind of proxy you configure in a browser: a forward proxy acts for the client and hides who is asking, a reverse proxy acts for the server and hides what is answering. Nginx, Caddy, Traefik, HAProxy and the proxy layer in any hosting panel are all doing the same job.

The request path, in order#

TLS on 443app.example.comapi.example.comanswer, via the proxyReverse proxyholds the certificateNode applistens on :3000Python APIlistens on :8000Visitortypes the hostname
One address on port 443 in front of two apps

Step by step, this is what happens to a single request:

  1. The visitor's browser resolves your domain to an address - the proxy's address, not your application's.
  2. It opens a TCP connection on port 443 and starts a TLS handshake. In the first unencrypted message it names the host it wants, which is the SNI field.
  3. The proxy picks the matching certificate, completes the handshake and decrypts the request. Your application never sees a certificate.
  4. The proxy reads the Host header and decides which backend that hostname belongs to.
  5. It opens a plain HTTP connection to your application on its internal port and repeats the request, adding headers that record who originally asked and how.
  6. Your application answers over that internal connection. The proxy passes the response back down the encrypted one.

The connection between browser and proxy and the connection between proxy and application are two separate TCP connections with different lifetimes. That single fact is the source of nearly every surprise in this post: your application's idea of the client, the scheme, the host and the timeout all describe the second connection, not the first.

What this buys you#

  • Your application never handles certificates. One fewer thing to renew, one fewer private key on a box that runs your code, and no application restart when a certificate rotates.
  • Several names on one address and one port. A single public IP and a single 443 can serve any number of hostnames, because the routing decision is made from SNI and the Host header rather than from the address.
  • Your origin port stays closed. Only the proxy needs to be reachable from the internet. The application can bind 127.0.0.1 and be unreachable from outside entirely.
  • A place to put cross-cutting concerns. Compression, rate limits, request size caps, IP blocks and access logs live in one configuration rather than in every application you run.
  • You can move the application without touching DNS. The proxy's address is what is published. Swapping the backend behind it is a configuration reload.

The cost is one more hop and one more thing that can be misconfigured, which is the rest of this post.

TLS termination and certificates#

Terminating TLS means the proxy holds the private key, decrypts, and speaks plain HTTP to the backend. That last leg is unencrypted, which sounds alarming until you notice it usually runs over loopback or a private network inside one machine. If it crosses an untrusted network, you want TLS on the second leg too - re-encryption rather than termination - and that is a decision to make deliberately rather than by default.

Certificates are issued automatically by almost every proxy now, through ACME. The mechanics are worth knowing because the failure modes are all about them:

  • The HTTP-01 challenge works by the certificate authority requesting a file under /.well-known/acme-challenge/ on port 80. So port 80 must stay open and must reach the proxy, even on a site that redirects everything to HTTPS. Blocking port 80 entirely is the most common self-inflicted renewal failure.
  • The DNS record must already point at the proxy before issuance can succeed. A certificate cannot be issued for a name that does not resolve to the machine being challenged.
  • Renewal happens well before expiry. Certificates are typically valid for 90 days and renewed around the 30-day mark, which leaves a wide window for a transient failure to be retried without anyone noticing.
  • Rate limits are per registered domain, per week. Deleting and recreating a site repeatedly while debugging will exhaust them, and the lockout lasts days.

On RE:NODE, app and web plans include a proxy slot. You point an A record at the address shown in the panel, and the certificate is issued and renewed automatically inside a 21-day window - so a name that stops resolving has three weeks of margin before anything expires. Your domain and its certificate walks through the ordering, and HTTPS and Let's Encrypt explained covers what the validation is actually proving.

Routing by hostname#

A reverse proxy in front of several applications needs to know which one a request is for, and the answer arrives twice in every request.

SNI - Server Name Indication - is a TLS extension. The client puts the hostname in the ClientHello, in the clear, before any encryption exists, precisely so the server knows which certificate to present. Without it, one address could only ever serve one certificate. This is also why the hostname you visit is visible to anything watching the connection even when the rest is encrypted.

The `Host` header arrives inside the encrypted request once the handshake is done. The proxy uses it to pick the backend. The two are normally identical, and a mismatch between them is a thing some proxies reject outright.

What this means in practice: your application is generally unaware of its public name. It answers whatever arrives on its port. Two consequences follow.

If your application builds absolute URLs - redirects, password reset links, canonical tags, sitemaps - it must be told its public hostname, either through the forwarded headers described below or through a configuration value. An app that guesses from the connection will produce http://localhost:3000/reset?token=... in an email, which is the sort of bug that only appears in production.

And because the backend accepts any Host, it will happily serve your site to a request with somebody else's hostname if that request reaches it directly. Bind the application to loopback, or restrict it to the proxy's address, rather than leaving the origin port open on a public interface.

The headers that carry the original request#

Because the proxy makes the connection to your application, your application sees the proxy's address as the client. Every visitor looks like the same person. If you are rate limiting, logging, geolocating or banning by IP, you are doing all of it to your own proxy.

The original details arrive in headers instead:

HeaderCarriesTypical value
X-Forwarded-ForThe client address, then each proxy203.0.113.10, 10.0.0.2
X-Forwarded-ProtoThe scheme the client usedhttps
X-Forwarded-HostThe hostname the client asked forapp.example.com
X-Forwarded-PortThe public port443
X-Real-IPJust the client address203.0.113.10
ForwardedAll of the above, standardisedfor=203.0.113.10;proto=https

X-Forwarded-For is a list, appended to by each proxy in the chain. The leftmost entry is the original client and every entry to its right is a proxy that handled the request. Forwarded is the standardised replacement from RFC 7239 and is correct but less widely supported; the X- headers are the de facto standard and are what you will actually receive.

The security point matters more than the syntax. These headers are just headers. A client can send X-Forwarded-For: 1.2.3.4 and, if your application believes it uncritically, you have built an IP allow-list that anyone can walk through and a rate limiter that anyone can evade by varying a header. The rule is: trust the header only from proxies you control, and count from the right. If you know there is exactly one trusted proxy in front of you, the client address is the last entry your proxy appended, not the first entry in the list. Frameworks express this as a trusted-proxy count or a list of trusted addresses, which is what the next section is about.

On RE:NODE the real client address arrives in X-Forwarded-For, and reading it is the difference between per-visitor rate limiting and a rate limiter that blocks everybody at once - rate limits and abuse covers designing the limit itself.

Telling your framework it is behind a proxy#

Every server framework has a switch for this, off by default, because trusting forwarded headers unconditionally is unsafe. Turning it on makes the framework read the headers and report the real client, scheme and host.

Express
// 1 = trust exactly one proxy in front of usapp.set("trust proxy", 1);// req.ip and req.protocol now describe the real client
Flask
from werkzeug.middleware.proxy_fix import ProxyFixapp.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Django settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")USE_X_FORWARDED_HOST = TrueALLOWED_HOSTS = ["app.example.com"]
Uvicorn and Gunicorn
$ uvicorn main:app --host 0.0.0.0 --port 8000 --proxy-headers$ gunicorn app:wsgi --bind 0.0.0.0:8000 --forwarded-allow-ips="10.0.0.0/8"

Laravel has a TrustProxies middleware with a $proxies property; set it to the proxy's address rather than to everything if you can. Next.js reads X-Forwarded-Host and X-Forwarded-Proto when generating URLs, and needs no switch, but anything you write yourself inside it does.

The number in trust proxy is a hop count, and getting it wrong is subtle rather than loud. Set it too low and you see a proxy address instead of the client. Set it too high and you are reading an entry the client supplied, which is the spoofable case. Count the proxies that are actually in the path - the panel's proxy is one, a CDN in front of it is two - and test by logging req.ip from a machine whose address you know.

Deploying an Express API to production has the rest of the checklist for the first case, and deploying a Node app from GitHub covers getting the code onto the server in the first place.

WebSockets, streaming and timeouts#

An HTTP request is short. A WebSocket is a request that turns into a long-lived connection, and proxies that were not told about it will get in the way.

The upgrade is a normal HTTP request carrying Connection: Upgrade and Upgrade: websocket. A proxy must pass both headers through and must speak HTTP/1.1 on the upstream leg, because the upgrade mechanism does not exist in HTTP/1.0. In nginx that is four lines:

WebSocket upgrade, nginx
location /socket.io/ {    proxy_pass http://127.0.0.1:3000;    proxy_http_version 1.1;    proxy_set_header Upgrade $http_upgrade;    proxy_set_header Connection "upgrade";    proxy_read_timeout 3600s;}

Managed proxies usually handle the upgrade automatically. What they do not handle automatically is the timeout: a proxy with a 60-second read timeout will cut an idle WebSocket every minute, producing a reconnect loop that looks like a network fault and is a configuration value. Send an application-level ping every 20-30 seconds, or raise the timeout, or both.

Server-sent events and streaming responses have a second problem: response buffering. A proxy that buffers will hold your stream until it has enough of it, and the client receives a long silence followed by everything at once. Nginx needs proxy_buffering off on those routes, or the X-Accel-Buffering: no response header from the application.

Sticky sessions come up here too. If you run several instances of an application behind one proxy and your WebSocket library falls back to HTTP long-polling - Socket.IO does - consecutive requests from one client must reach the same instance or the session will not be found. Either enable sticky routing, or use a shared adapter so instances share state. WebSockets behind a reverse proxy goes through each of those in detail.

What a reverse proxy will not do#

This is where expectations are usually wrong, and it is worth being blunt.

It does not carry game traffic. Almost every reverse proxy speaks HTTP, and almost every game speaks UDP with its own protocol. You cannot put Valheim or Counter-Strike 2 behind an HTTP proxy, and pointing a proxied DNS record at a game server produces an address that will never accept a game connection. The same applies to Cloudflare's orange cloud - Cloudflare for websites and game servers is explicit about what it does and does not proxy, and TCP vs UDP for game servers explains why.

It is not a cache unless you configure one. Most proxies forward every request by default. Caching is a feature you turn on and then have to think about invalidating.

It is not DDoS protection. It is a smaller target than your application and it can absorb some nonsense, but a volumetric flood saturates the link in front of it and nothing running on the machine can help with that.

It does not fix a slow application. Adding a proxy in front of a 4-second response makes it a 4-second response with an extra millisecond. Profile the application.

It does not authenticate anybody unless you configure that too. Basic auth and forward-auth are available in most proxies and are worth using for an admin interface, but nothing happens by default.

When it breaks: 502, 504, loops and mixed content#

502 Bad Gateway. The proxy could not get a valid response from the backend. Nine times in ten the application is not running, or it is bound to 127.0.0.1 while the proxy is in a different container or namespace, or it is on a different port from the one configured. Check that the process is alive, then check what it bound to with ss -lntp. If the proxy and the app are in separate containers, the app must listen on 0.0.0.0.

504 Gateway Timeout. The application accepted the connection and did not answer in time. This is a slow request, not a proxy fault. Find the slow endpoint before raising the timeout, because raising it usually just moves the failure to the browser.

A redirect loop. The classic one. Your application forces HTTPS by checking the scheme of its own connection, which is plain HTTP because the proxy terminated TLS. So it redirects to HTTPS, the proxy terminates TLS again, the app sees HTTP again, and the browser gives up after twenty rounds. The fix is to trust X-Forwarded-Proto - the framework switches in the section above - not to remove the redirect.

Mixed content warnings. Same cause, different symptom: the application generates absolute http:// URLs for its own assets because it believes it is being served over HTTP. Fix the scheme detection, or emit relative URLs.

Every visitor has the same IP. The forwarded headers are not being read. See above.

413 Request Entity Too Large. The proxy's request body limit, not the application's. Nginx defaults to 1 MB via client_max_body_size, which is smaller than most upload forms expect.

It works on one hostname and not another. The certificate or the routing rule covers one name and not the other. www.example.com and example.com are different names and both need to exist in DNS and in the proxy.

If you are running the proxy yourself rather than using a managed one, the nginx reverse proxy guide has the complete server block including the TLS and header lines.

FAQ#

Do I still need a reverse proxy if I only run one app?

Usually yes, because it is what gives you HTTPS without the application handling a certificate, and it lets you keep the application's port closed to the internet. For a single internal service on a private network, you can skip it.

Why does my app see the proxy's IP instead of the visitor's?

Because the proxy made the connection, and your framework is not reading the forwarded headers yet. Enable the trusted-proxy setting for your framework and the client address in X-Forwarded-For will be used instead.

Is the connection between the proxy and my app encrypted?

Not by default. It is plain HTTP, which is fine when both are on the same machine or the same private network. If that leg crosses a network you do not control, configure TLS on the upstream connection as well.

Can a reverse proxy sit in front of a game server?

Not an HTTP one. Games use their own protocols, mostly over UDP, and an HTTP proxy has nothing to do with them. Game traffic needs a Layer 4 proxy or a direct connection to the game port.

Why do my WebSockets disconnect every minute?

The proxy's read timeout is closing an idle connection. Raise the timeout on that route and send an application-level ping every 20 to 30 seconds so the connection is never idle for long enough to be cut.

What is the difference between a reverse proxy and a load balancer?

Overlapping jobs rather than different things. A load balancer distributes requests across several identical backends; a reverse proxy routes and transforms requests for one or more different backends. Most software does both, and which word you use depends on which feature you care about.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000