{"course":{"slug":"cloud-and-shipping","title":"Shipping It","url":"https://addaly.com/learn/cloud-and-shipping","level":"intermediate","track":"build"},"lesson":{"slug":"behind-the-proxy","title":"What sits in front of your app","url":"https://addaly.com/learn/cloud-and-shipping/behind-the-proxy","position":7,"of":74,"minutes":9,"answer":"A reverse proxy takes the port, the certificate and the client's IP address away from your app, and every one of those has a default that will surprise you.","keyIdea":"A reverse proxy takes the port, the certificate and the client's IP address away from your app, and every one of those has a default that will surprise you."},"body":{"format":"markdown","text":"## Your app should not be listening on 443\n\nOn Unix, ports below 1024 require root, and you do not want your application code running as root. So the shape almost everybody ends up with is this: your app listens on a high port (3000, 8000, 8080) as an ordinary user, and something else listens on 80 and 443 and forwards to it.\n\nThat something is a **reverse proxy**. On a virtual machine it is nginx, Caddy or Traefik, all free. On a managed platform it is the platform's own edge, and you never configure it, but it is still there and it still has opinions.\n\n> Figure: What sits between the browser and your code. Layers, top to bottom: Browser (connects to port 443, checks the certificate); Reverse proxy: Caddy, nginx or the platform edge (terminates TLS, serves static files, limits body size, forwards the real IP in a header); Your application on 127.0.0.1:3000 (plain HTTP, runs as its own user, trusts X-Forwarded-Proto for redirects); Database on a private address (reachable from the app only, never from the internet). The proxy owns the ports, the certificate and the client's real address. Your app listens on a high port as an ordinary user and only ever hears from the proxy.\n\nWhat it buys you:\n\n- TLS terminates in one place, so certificates are one component's problem.\n- Static files are served without ever waking your runtime.\n- Compression, request size limits and connection handling happen before your code.\n- Several applications share one machine and one IP.\n- You can restart your app without dropping the connection the browser already holds.\n\n## `127.0.0.1` versus `0.0.0.0`\n\n`127.0.0.1` means \"accept connections that originate on this machine only\". `0.0.0.0` means \"accept on every network interface\".\n\nBind to `127.0.0.1` when a proxy on the same machine is the only thing that will ever connect. It is a free firewall.\n\nBind to `0.0.0.0` inside a container. This is the single most common \"it works on my laptop and times out in Docker\" bug: the container publishes port 3000, the request arrives on the container's network interface, and your server is listening only on the container's own loopback. Nothing is wrong with your code, your networking or the image. One address is wrong.\n\n## You have just lost the client's IP address\n\nEvery request now arrives from the proxy, so your logs fill with `127.0.0.1`. The proxy is supposed to tell you who it was, in headers:\n\n```\nX-Forwarded-For: 203.0.113.7, 198.51.100.4\nX-Forwarded-Proto: https\n```\n\n`X-Forwarded-For` is a list, appended to at each hop. Here is the part people get wrong in both directions. It is an ordinary header: anyone can send `X-Forwarded-For: 1.2.3.4` in their first request. Trust only the entry that a proxy **you control** appended: in practice, the rightmost one, not the leftmost. Behind Cloudflare, use `CF-Connecting-IP`, which Cloudflare overwrites rather than appends.\n\nGet this wrong one way and every visitor looks like the same client, so your rate limiter throttles the whole internet as one person. Get it wrong the other way and the limiter is bypassed by typing a fake header.\n\nMost frameworks have a \"trust proxy\" setting. It is off by default for exactly this reason, and turning it on blindly is how the bypass appears.\n\n## The defaults that will bite you\n\nThese are nginx's, and every proxy has an equivalent:\n\n- `client_max_body_size` defaults to **1 MB**. A larger upload gets a 413 and your application never sees the request, so there is nothing in your logs to find.\n- `proxy_read_timeout` defaults to **60 seconds**. A slow report returns a 504 to the user while your app finishes happily and logs a 200. Two logs disagreeing about the same request is the signature of a proxy timeout.\n- **Response buffering is on.** Server-sent events and streamed responses arrive all at once at the end, or never. You need `proxy_buffering off` and often `X-Accel-Buffering: no`.\n- **WebSockets need explicit permission** to upgrade (`Upgrade` and `Connection` headers passed through) or the handshake fails with a plain 400.\n- **Header size limits** produce a 431 once someone's cookie jar gets fat.\n\n## The redirect loop you get exactly once\n\nThe proxy terminates TLS and speaks plain HTTP to your app. Your app therefore believes it is running on HTTP. It builds an absolute redirect: `http://example.com/dashboard`. The proxy sees an HTTP request and redirects it to HTTPS. Which reaches your app, which believes it is on HTTP, which redirects to `http://`.\n\n`ERR_TOO_MANY_REDIRECTS`. The fix is to trust `X-Forwarded-Proto` when generating URLs, or to emit relative redirects and let the browser keep the scheme.\n\n## Two lines of Caddy\n\n```\naddaly.com {\n  reverse_proxy 127.0.0.1:3000\n}\n```\n\nThat is a working configuration including a certificate, its renewal, HTTP-to-HTTPS redirection and modern TLS settings. nginx will ask for thirty lines and give you finer control, and far more of the internet's accumulated knowledge is written about nginx. Both are free; start with whichever you can read."},"check":{"question":"You containerise a Node app that works perfectly on your laptop. The container starts with no errors, `docker ps` shows port 3000 published, and the log line says \"listening on 3000\", but every request from the host hangs and then times out. What is the most likely cause?","options":[{"text":"The server is bound to 127.0.0.1 inside the container, so it is not listening on the interface the forwarded traffic arrives on","correct":true},{"text":"The published port mapping is reversed, so the host port and container port are the wrong way round","correct":false,"tempting":"Would produce a connection refused almost immediately, not a hang, and `docker ps` would show the mapping you could read back."},{"text":"The container has no TLS certificate, so the connection is dropped during the handshake","correct":false,"tempting":"Imports HTTPS into a plain HTTP problem; nothing in this setup is terminating TLS, and a handshake failure is an error rather than a hang."},{"text":"The app is running as a non-root user and cannot open a port below 1024","correct":false,"tempting":"A real constraint, but port 3000 is above 1024, and it would fail loudly at startup rather than logging that it is listening."}]},"prev":{"slug":"tls-and-the-padlock","title":"The padlock, and what it does not prove","url":"https://addaly.com/learn/cloud-and-shipping/tls-and-the-padlock"},"next":{"slug":"one-request-end-to-end","title":"One request, with a stopwatch","url":"https://addaly.com/learn/cloud-and-shipping/one-request-end-to-end"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'What sits in front of your app', in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/behind-the-proxy (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"What sits in front of your app","courseTitle":"Shipping It","module":{"index":1,"slug":"how-the-internet-reaches-you","title":"How the internet reaches your process"},"answer":null,"keyIdea":"A reverse proxy takes the port, the certificate and the client’s IP address away from your app, and every one of those has a default that will surprise you.","hero":{"kind":"figure","figure":{"t":"figure","label":"Figure","figure":{"type":"stack","title":"What sits between the browser and your code","layers":[{"label":"Browser","detail":"connects to port 443, checks the certificate"},{"label":"Reverse proxy: Caddy, nginx or the platform edge","detail":"terminates TLS, serves static files, limits body size, forwards the real IP in a header"},{"label":"Your application on 127.0.0.1:3000","detail":"plain HTTP, runs as its own user, trusts X-Forwarded-Proto for redirects"},{"label":"Database on a private address","detail":"reachable from the app only, never from the internet"}],"caption":"The proxy owns the ports, the certificate and the client’s real address. Your app listens on a high port as an ordinary user and only ever hears from the proxy."},"alt":"Figure: What sits between the browser and your code. Layers, top to bottom: Browser (connects to port 443, checks the certificate); Reverse proxy: Caddy, nginx or the platform edge (terminates TLS, serves static files, limits body size, forwards the real IP in a header); Your application on 127.0.0.1:3000 (plain HTTP, runs as its own user, trusts X-Forwarded-Proto for redirects); Database on a private address (reachable from the app only, never from the internet). The proxy owns the ports, the certificate and the client’s real address. Your app listens on a high port as an ordinary user and only ever hears from the proxy."}},"blocks":[{"t":"h2","id":"your-app-should-not-be-listening-on-443","runs":[{"text":"Your app should not be listening on 443"}]},{"t":"p","runs":[{"text":"On Unix, ports below 1024 require root, and you do not want your application code running as root. So the shape almost everybody ends up with is this: your app listens on a high port (3000, 8000, 8080) as an ordinary user, and something else listens on 80 and 443 and forwards to it."}]},{"t":"p","runs":[{"text":"That something is a "},{"text":"reverse proxy","b":true},{"text":". On a virtual machine it is nginx, Caddy or Traefik, all free. On a managed platform it is the platform’s own edge, and you never configure it, but it is still there and it still has opinions."}]},{"t":"p","runs":[{"text":"What it buys you:"}]},{"t":"ul","items":[[{"text":"TLS terminates in one place, so certificates are one component’s problem."}],[{"text":"Static files are served without ever waking your runtime."}],[{"text":"Compression, request size limits and connection handling happen before your code."}],[{"text":"Several applications share one machine and one IP."}],[{"text":"You can restart your app without dropping the connection the browser already holds."}]]},{"t":"h2","id":"127-0-0-1-versus-0-0-0-0","runs":[{"text":"127.0.0.1","code":true},{"text":" versus "},{"text":"0.0.0.0","code":true}]},{"t":"p","runs":[{"text":"127.0.0.1","code":true},{"text":" means “accept connections that originate on this machine only”. "},{"text":"0.0.0.0","code":true},{"text":" means “accept on every network interface”."}]},{"t":"p","runs":[{"text":"Bind to "},{"text":"127.0.0.1","code":true},{"text":" when a proxy on the same machine is the only thing that will ever connect. It is a free firewall."}]},{"t":"p","runs":[{"text":"Bind to "},{"text":"0.0.0.0","code":true},{"text":" inside a container. This is the single most common “it works on my laptop and times out in Docker” bug: the container publishes port 3000, the request arrives on the container’s network interface, and your server is listening only on the container’s own loopback. Nothing is wrong with your code, your networking or the image. One address is wrong."}]},{"t":"h2","id":"you-have-just-lost-the-client-s-ip-address","runs":[{"text":"You have just lost the client’s IP address"}]},{"t":"p","runs":[{"text":"Every request now arrives from the proxy, so your logs fill with "},{"text":"127.0.0.1","code":true},{"text":". The proxy is supposed to tell you who it was, in headers:"}]},{"t":"code","lang":"","label":"Text","text":"X-Forwarded-For: 203.0.113.7, 198.51.100.4\nX-Forwarded-Proto: https"},{"t":"p","runs":[{"text":"X-Forwarded-For","code":true},{"text":" is a list, appended to at each hop. Here is the part people get wrong in both directions. It is an ordinary header: anyone can send "},{"text":"X-Forwarded-For: 1.2.3.4","code":true},{"text":" in their first request. Trust only the entry that a proxy "},{"text":"you control","b":true},{"text":" appended: in practice, the rightmost one, not the leftmost. Behind Cloudflare, use "},{"text":"CF-Connecting-IP","code":true},{"text":", which Cloudflare overwrites rather than appends."}]},{"t":"p","runs":[{"text":"Get this wrong one way and every visitor looks like the same client, so your rate limiter throttles the whole internet as one person. Get it wrong the other way and the limiter is bypassed by typing a fake header."}]},{"t":"p","runs":[{"text":"Most frameworks have a “trust proxy” setting. It is off by default for exactly this reason, and turning it on blindly is how the bypass appears."}]},{"t":"h2","id":"the-defaults-that-will-bite-you","runs":[{"text":"The defaults that will bite you"}]},{"t":"p","runs":[{"text":"These are nginx’s, and every proxy has an equivalent:"}]},{"t":"ul","items":[[{"text":"client_max_body_size","code":true},{"text":" defaults to "},{"text":"1 MB","b":true},{"text":". A larger upload gets a 413 and your application never sees the request, so there is nothing in your logs to find."}],[{"text":"proxy_read_timeout","code":true},{"text":" defaults to "},{"text":"60 seconds","b":true},{"text":". A slow report returns a 504 to the user while your app finishes happily and logs a 200. Two logs disagreeing about the same request is the signature of a proxy timeout."}],[{"text":"Response buffering is on.","b":true},{"text":" Server-sent events and streamed responses arrive all at once at the end, or never. You need "},{"text":"proxy_buffering off","code":true},{"text":" and often "},{"text":"X-Accel-Buffering: no","code":true},{"text":"."}],[{"text":"WebSockets need explicit permission","b":true},{"text":" to upgrade ("},{"text":"Upgrade","code":true},{"text":" and "},{"text":"Connection","code":true},{"text":" headers passed through) or the handshake fails with a plain 400."}],[{"text":"Header size limits","b":true},{"text":" produce a 431 once someone’s cookie jar gets fat."}]]},{"t":"h2","id":"the-redirect-loop-you-get-exactly-once","runs":[{"text":"The redirect loop you get exactly once"}]},{"t":"p","runs":[{"text":"The proxy terminates TLS and speaks plain HTTP to your app. Your app therefore believes it is running on HTTP. It builds an absolute redirect: "},{"text":"http://example.com/dashboard","code":true},{"text":". The proxy sees an HTTP request and redirects it to HTTPS. Which reaches your app, which believes it is on HTTP, which redirects to "},{"text":"http://","code":true},{"text":"."}]},{"t":"p","runs":[{"text":"ERR_TOO_MANY_REDIRECTS","code":true},{"text":". The fix is to trust "},{"text":"X-Forwarded-Proto","code":true},{"text":" when generating URLs, or to emit relative redirects and let the browser keep the scheme."}]},{"t":"h2","id":"two-lines-of-caddy","runs":[{"text":"Two lines of Caddy"}]},{"t":"code","lang":"","label":"Text","text":"addaly.com {\n  reverse_proxy 127.0.0.1:3000\n}"},{"t":"p","runs":[{"text":"That is a working configuration including a certificate, its renewal, HTTP-to-HTTPS redirection and modern TLS settings. nginx will ask for thirty lines and give you finer control, and far more of the internet’s accumulated knowledge is written about nginx. Both are free; start with whichever you can read."}]}],"check":{"question":[{"text":"You containerise a Node app that works perfectly on your laptop. The container starts with no errors, "},{"text":"docker ps","code":true},{"text":" shows port 3000 published, and the log line says “listening on 3000”, but every request from the host hangs and then times out. What is the most likely cause?"}],"options":[{"text":[{"text":"The server is bound to 127.0.0.1 inside the container, so it is not listening on the interface the forwarded traffic arrives on"}],"correct":true,"tempting":null},{"text":[{"text":"The published port mapping is reversed, so the host port and container port are the wrong way round"}],"correct":false,"tempting":[{"text":"Would produce a connection refused almost immediately, not a hang, and "},{"text":"docker ps","code":true},{"text":" would show the mapping you could read back."}]},{"text":[{"text":"The container has no TLS certificate, so the connection is dropped during the handshake"}],"correct":false,"tempting":[{"text":"Imports HTTPS into a plain HTTP problem; nothing in this setup is terminating TLS, and a handshake failure is an error rather than a hang."}]},{"text":[{"text":"The app is running as a non-root user and cannot open a port below 1024"}],"correct":false,"tempting":[{"text":"A real constraint, but port 3000 is above 1024, and it would fail loudly at startup rather than logging that it is listening."}]}],"order":[1,0,2,3],"why":null},"prev":{"slug":"tls-and-the-padlock","title":"The padlock, and what it does not prove","minutes":9},"next":{"slug":"one-request-end-to-end","title":"One request, with a stopwatch","minutes":10}}}