Why this matters
Almost no web app is reached directly. There is a proxy in front - nginx on this box - and when users see 502, 503 or 504, the code is the proxy telling you what went wrong behind it. Read the code and the proxy's log line and you know which component to look at, before anyone opens the app's code.
What you need to know already: HTTP requests, headers and status codes (9.21), refused vs timeout (9.1), nginx as a systemd service and its logs in /var/log/nginx/ (Chapter 7 read them with grep and awk), TLS (9.15).
A reverse proxy is two connections
A proxy is a program that receives your request and makes it on your behalf. A reverse proxy sits in front of servers (clients think it is the server); a forward proxy sits in front of clients (the end of this lesson). The server the proxy forwards to is its upstream.
client --(1)--> nginx --(2)--> upstream (the app)
The client never talks to the app. nginx accepts connection (1), opens connection (2), and translates. Every 5xx a proxy returns describes what happened on (2), and nginx writes the reason to its error.log.
An nginx site is a server { } block: listen the port, server_name the hostname it answers for, and location /api/ { } what to do with paths starting /api/ - here proxy_pass (forward to the app), a timeout, and headers to add:
server {
listen 80;
server_name orders.lab;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 5s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
502, 503, 504, 499 - and the log line for each
502 Bad Gateway - the proxy could not get a valid response from the upstream: nothing listening, the connection was reset, or it answered garbage.
# the orders.lab proxy lab in this chapter, with orders stopped
curl -s -o /dev/null -w '%{http_code}\n' http://orders.lab/api/orders
502
sudo tail -1 /var/log/nginx/error.log
2026/09/23 10:12:01 [error] 906#906: *1843 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: orders.lab, request: "GET /api/orders HTTP/1.1", upstream: "http://127.0.0.1:8080/api/orders", host: "orders.lab"
The line: date, level [error], nginx's process ids, a request number (*1843), the reason, then the client, the site, the request and the upstream it tried. 111: Connection refused - errno 111 (9.8), the upstream is down. Other 502 lines: upstream prematurely closed connection while reading response header (the app crashed or closed mid-request), no live upstreams while connecting to upstream (every server in the upstream block is marked failed).
504 Gateway Timeout - the upstream was reached and did not answer in time:
2026/09/23 10:14:11 [error] 906#906: *1851 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 127.0.0.1, server: orders.lab, request: "GET /api/payments/refund HTTP/1.1", upstream: "http://127.0.0.1:8080/api/payments/refund", host: "orders.lab"
proxy_read_timeout (default 60s) is how long nginx waits for the response header. "while connecting" instead of "while reading" means the connect itself timed out (proxy_connect_timeout) - a network problem, not a slow app.
503 Service Unavailable - no backend available to try at all. Load balancers return it when every backend fails its health check (a regular test request, below). Also: nginx's own rate limiting (limit_req, which caps requests per second) answers 503 by default, not 429, unless you set limit_req_status 429.
499 (nginx only) - the client gave up and closed the connection before nginx had a response. You see it in access.log (the one-line-per-request log you parsed with awk in 7.8), not error.log:
127.0.0.1 - - [23/Sep/2026:10:15:02 +0000] "GET /api/payments/refund HTTP/1.1" 499 0 "-" "curl/8.14.1"
A wall of 499s means your clients' timeouts are shorter than your latency. The client is not broken; you are slow.
The triage in one line: 503 - look at health checks and endpoints; 504 - look at the upstream's latency and what it waits on; 502 - look at the upstream process and the connection to it; 499 - look at your own latency.
X-Forwarded-For and X-Forwarded-Proto
Behind a proxy the app sees the proxy's address, not the client's, and plain HTTP even when the client used HTTPS: TLS ended at the proxy (TLS termination - the proxy holds the certificate and decrypts). The proxy says what it saw in headers:
X-Forwarded-For: 203.0.113.24, 10.0.4.17 client, then each proxy it passed
X-Forwarded-Proto: https the scheme the CLIENT used
X-Real-IP: 203.0.113.24 nginx convention, single value
Two rules: only trust these from your own proxies (anyone can send X-Forwarded-For: 127.0.0.1 - configure the app or set_real_ip_from with the proxy's addresses), and read the left-most untrusted entry, not blindly the first.
The redirect loop
An app that enforces HTTPS ("if the request is not https, redirect to https") behind a proxy that terminates TLS and does not send X-Forwarded-Proto:
client --https--> nginx --http--> app "that was http, go to https://shop.lab/"
client --https--> nginx --http--> app "that was http, go to https://shop.lab/"
...
# the shop.lab lab in this chapter, before the fix:
curl -sL -o /dev/null https://shop.lab/
curl: (47) Maximum (50) redirects followed
curl -sI https://shop.lab/ | grep -i location
location: https://shop.lab/
A redirect to the same URL you requested is the signature. Fix it in the proxy (proxy_set_header X-Forwarded-Proto $scheme; - $scheme is nginx's variable for "http" or "https") and tell the app to trust it (every web framework has a "trust proxy headers" setting).
L4 vs L7
The L-numbers are layers of the networking model you met in Chapter 8: layer 4 is TCP (connections and ports), layer 7 is the application protocol (HTTP).
L4 (TCP) forwards connections. Fast, protocol-blind. Cannot see paths, headers
or status codes; cannot retry a failed HTTP request; one long-lived
HTTP/2 connection = one backend forever.
Most cloud "network load balancers" are L4.
L7 (HTTP) terminates the connection, parses HTTP. Routes by host and path,
terminates TLS, adds headers, retries idempotent requests, health
checks on a real URL, balances per request.
nginx is L7; so are cloud "application gateways".
"Why is this 500 not retried?" - because the thing in front is L4 and never saw a
- "Why does one backend get all the traffic from one client?" - because an L4
balancer picked it once, when the connection opened.
Health checks: active - the LB requests /health on a schedule and stops sending traffic to backends that fail; passive - the LB notices real requests failing and ejects the backend (nginx max_fails/fail_timeout). Use both: active catches dead backends before users do, passive catches the ones that pass /health and fail real work.
Algorithms (how the LB picks a backend): round robin (each in turn - the default everywhere), least connections (better when request cost varies), consistent hashing (the same key - user, cache key - always goes to the same backend, and adding or removing a backend moves few keys).
Later (Ch 16): Kubernetes has both kinds built in - an L4 balancer for every service and L7 "ingress" proxies that are often nginx itself - so everything in this lesson carries over.
Forward proxies: HTTP_PROXY and friends
In a bank, outbound traffic to the internet goes through a forward proxy (Squid is a common one). Tools find it through environment variables (2.21), and they disagree on the details:
http_proxy for http:// URLs. curl reads ONLY the lowercase one (the
uppercase HTTP_PROXY is ignored on purpose - "httpoxy").
https_proxy for https:// URLs. curl accepts HTTPS_PROXY too.
no_proxy comma-separated exceptions: hosts, domain suffixes (.lab),
IPs, CIDRs (curl 7.86+). NO_PROXY also accepted.
$ https_proxy=http://proxy.lab:3128 curl -v -o /dev/null https://api.lab/
* Uses proxy env variable https_proxy == 'http://proxy.lab:3128'
* Trying 10.0.3.30:3128...
* Connected to proxy.lab (10.0.3.30) port 3128
* CONNECT tunnel: HTTP/1.1 negotiated
> CONNECT api.lab:443 HTTP/1.1
> Host: api.lab:443
> User-Agent: curl/8.14.1
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 503 Service Unavailable
< Server: squid/6.13
< X-Squid-Error: ERR_DNS_FAIL 0
<
* CONNECT tunnel failed, response 503
curl: (56) CONNECT tunnel failed, response 503
For HTTPS the client asks the proxy to open a tunnel (CONNECT host:443) and does TLS through it. The proxy sits outside the internal DNS view (split horizon, 8.22), cannot resolve api.lab, and refuses. Internal names must be in no_proxy. The usual traps:
no_proxymissing the internal domain - internal calls go to the proxy and fail with 403 or 503 from Squid. Very common after someone adds a proxy to/etc/environment(a file ofNAME=valuelines that every login session loads)./etc/environmentis read at login (by pam_env, the login module that loads it). Editing it does nothing for the shell you already have, and nothing for systemd services, which get their environment only from the unit (Environment=orEnvironmentFile=). "It works in my shell and not in the service" is usually this.- Java ignores all of these variables. It wants options on the
javacommand line (-Dname=valuesets a Java property):-Dhttps.proxyHost,-Dhttps.proxyPortand-Dhttp.nonProxyHosts="localhost|*.lab"(pipes, and wildcards with*., not leading dots). - 407 Proxy Authentication Required - the proxy wants credentials:
http://user:pass@proxy:3128, which then sits in an environment variable every process can read (2.21: environment variables are not secret).
What you can now do
- Read 502, 503, 504 and 499 as "what happened between the proxy and the app", and find the matching line in nginx's error.log or access.log.
- Explain X-Forwarded-For/-Proto and spot the HTTPS redirect loop.
- Say what an L4 vs an L7 load balancer can see, and fix a proxy rollout with
no_proxy.