Home › DevOps › 502 Bad Gateway in Nginx: Fix the Failing Upstream
Intermediate 8 min · September 23, 2026

502 Bad Gateway in Nginx: Fix the Failing Upstream

Restart the crashed backend to clear an Nginx 502 fast.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 11 min
  • ✓Basic HTTP and terminal comfort
  • ✓Access to Nginx config and logs
  • ✓A backend service running behind the proxy
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Fix the backend first: restart the crashed app server (or correct the stale target), then reload Nginx — most 502s clear the moment the upstream answers again
  • A 502 means Nginx got an invalid or empty reply from upstream (refused, truncated, hung-up, oversized headers), so the fault usually sits behind the proxy rather than in it
  • Confirm with a direct curl against the backend port or socket plus tail of error.log: connect() failed means dead/wrong address, prematurely closed means hung-up, timed out means slow, too big header means buffers
  • If the backend is healthy, fix proxy_pass (port, service name, socket perms, SELinux), align keepalive and buffers, and raise proxy_read_timeout only for endpoints proven legitimately slow
✦ Definition~90s read
What is HTTP 502 Bad Gateway Fix?

A 502 Bad Gateway is what an Nginx reverse proxy returns when it cannot get a usable response from the upstream server behind it. The request path has two hops: client to Nginx, then Nginx to your app via the proxy_pass target. The first hop worked — Nginx is up and answering — while the second hop produced a refusal, an empty reply, bytes Nginx couldn't forward, or headers bigger than its buffers.

★
Picture a restaurant receptionist who takes your order to the kitchen.

The 502 page is Nginx honestly reporting that its errand failed, which is why the investigation starts behind the proxy rather than inside it.

The failure modes split five ways. The backend may be down entirely: crashed process, failed deploy, OOM kill, or a K8s probe murdering pods mid-request, with the kernel refusing connections on its port. The socket may be missing or blocked: (2: No such file) when the app server never started, (13: Permission denied) when SELinux, AppArmor, or group membership bars Nginx.

The proxy may be misconfigured: proxy_pass naming a stale port, a Docker service name resolved to the wrong container, or an unloaded config file, so a healthy app never receives the request. The keepalive conversation may race: Nginx reusing an idle connection the backend just closed.

Or the backend may be too slow: alive but exceeding proxy_connect_timeout, proxy_send_timeout, or proxy_read_timeout, so Nginx abandons the wait. Error.log fingerprints each one — connect() failed, prematurely closed, timed out, too big header, no live upstreams — and a direct curl to the backend port settles the split in seconds.

What a 502 is NOT completes the picture. It is not a 503, which means the service deliberately declined with overload or maintenance semantics and often a Retry-After hint. It is not a 504, which means the upstream was reachable but slower than the proxy's patience.

It is not a Cloudflare-origin mystery either: a branded 502 is your origin's own 502 passed through, a bare one means the edge can't reach you. And it is not an Nginx failure: restarting the proxy fixes nothing when the app behind it is dead, misaddressed, blocked, or crawling.

Treat the 502 as a pointer past the proxy, follow it with error.log and curl, and most incidents resolve at the backend within minutes.

Plain-English First

Picture a restaurant receptionist who takes your order to the kitchen. One evening she returns empty-handed: 502 is her telling you the kitchen didn't answer — the stove went out, the chefs moved rooms, the pass-through window got boarded up (permissions!), or they're just slow. Yelling at the receptionist (restarting Nginx) never relights the stove. Check the kitchen directly: walk back there yourself (curl the backend), read her note (error.log tells you exactly which failure — refused, hung-up, too slow, tray too big for the window), and fix the kitchen before retraining her patience (timeouts).

Checkout is down. Every browser shows the same stark page: 502 Bad Gateway. Nginx is up, the servers are up, CPU graphs are flat — yet every request through the proxy dies. Someone suggests restarting Nginx. Someone else blames the CDN. Meanwhile the actual culprit, a crashed app server one hop behind the proxy, sits quietly untouched.

A 502 is Nginx telling you the truth about someone else: it asked the upstream for a response and got garbage, silence, or a slammed door. The fix is almost always behind the proxy, not in it — a dead process, a proxy_pass pointing at last month's port, a socket with wrong permissions, or an app slower than the proxy's patience. One log file read correctly splits those cases in under a minute: connect() failed, prematurely closed, timed out, too big header.

This guide gives you that minute back. You'll classify any 502 from its error.log line, split backend death from config drift with a direct curl, apply production proxy blocks with keepalive, retries, and buffers set sanely, and stop confusing 502 with its cousins 503 and 504. Check the backend first, then the target, then the timeouts — every step uses commands you can run as-is.

502 Means the Proxy Got a Bad Answer From Upstream

A 502 Bad Gateway is the proxy reporting a failed conversation, not a failed proxy. Nginx accepted your request, opened a connection to the upstream named in proxy_pass, and received something it couldn't use: a refused connection, an empty reply, a truncated response, or headers so malformed they can't be forwarded. HTTP semantics pin the blame precisely — the gateway received an invalid response from the inbound server — which is why seasoned operators read a 502 as upstream trouble until proven otherwise.

That conversation breaks at distinct moments, and error.log names each one. connect() failed (111: Connection refused) means nothing listens at the configured address. connect() to unix:/run/myapp/app.sock failed (2: No such file or directory) means the app server isn't running; the same line with (13: Permission denied) is SELinux, AppArmor, or socket group membership blocking Nginx. upstream prematurely closed connection and recv() failed (104: Connection reset by peer) mean the app accepted then hung up mid-response. upstream sent too big header means the response outgrew your proxy buffers. no live upstreams means every peer tripped max_fails and got marked dead.

Don't debug the wrong proxy. A Cloudflare-branded 502 page means your origin handed Cloudflare a 502; a bare unbranded 502 means Cloudflare itself couldn't reach or parse your origin — dead tunnel, broken gzip, partial HTTP/2 support. An AWS ALB splits it the same way with HTTPCode_ELB_502_Count versus target 502s. Confirm the 502 in your own Nginx error.log first: if it isn't there, the fault lives a layer above you.

The payoff is where you spend your first five minutes: past the proxy, not at it. Restarting Nginx never revives a dead app server, and purging CDN caches never fixes a stale proxy_pass. What follows makes the correct reflex concrete — the full phrase catalog, the bypass curl, sane proxy blocks with keepalive and buffers, and status codes that tell the truth.

📊 Production Insight
First-minute instincts decide 502 incidents: teams that grep error.log and curl the backend recover in minutes, while teams that restart Nginx donate half an hour to a healthy proxy.
🎯 Key Takeaway
Nginx is healthy enough to complain, so the fault sits behind it — socket, process, target, or timing. Aim every first command past the proxy, not at it.

Backend Down vs Proxy Misconfigured: Tell Them Apart

Every 502 investigation starts with one split: dead backend or misdirected proxy? Curl the backend directly with the proxy removed from the path — a 200 means the app's alive and Nginx is looking in the wrong place, while connection refused means the process is down no matter what the config says. For socket upstreams, ls -l the .sock file: missing means the app server never started, present-but-unreachable screams permissions.

A dead backend announces itself loudly once you look. systemctl status shows inactive or failed; docker ps shows the container gone or restarting, with docker logs ending in an OOM kill or an unhandled exception. In Kubernetes, kubectl get pods shows CrashLoopBackOff while describe pod reports Liveness probe failures and exit 137 — probes murdering pods mid-request manufacture 502s from healthy code. Fix the crash, the leak, or the probe threshold first. No Nginx directive revives a dead process.

A living backend behind a persistent 502 means Nginx is asking the wrong address. Diff proxy_pass against ss -ltnp, and in Docker use the service name — proxy_pass http://backend:3000 works where http://127.0.0.1:3000 fails, because localhost inside the Nginx container isn't your app container. When error.log shows (13: Permission denied), suspect SELinux or AppArmor: check ausearch -m avc and enable httpd_can_network_connect. Port moves, hostname renames, and upstream blocks copied between environments all produce healthy apps with 502s on top.

Lock in the order: curl the backend, check the process, then read the config. Editing config over a dead process wastes the exact minutes checkout is down. File one more distinction now: upstream timed out blames app slowness against proxy_read_timeout, while upload stalls belong to client_body_timeout on the client leg. This whole split runs in under thirty seconds in one terminal.

bypass-proxy.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Ask the backend directly, bypassing Nginx entirely
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/health

# Time it: slow-but-answering (tune app/timeout) vs refused (dead backend)
curl -w 'connect:%{time_connect} start:%{time_starttransfer} total:%{time_total}\n' -o /dev/null http://127.0.0.1:3001/health

# Is the app process even alive, and on which port?
systemctl status myapp --no-pager
ss -ltnp | grep 3001

# Unix-socket upstreams: socket present? who may touch it?
ls -l /run/myapp/app.sock
namei -l /run/myapp/app.sock

# Containers: running state plus recent crash output
docker ps --filter name=myapp
docker logs --tail 50 myapp

# Kubernetes: restarts and probe kills manufacturing 502s
kubectl get pods -l app=myapp
kubectl describe pod -l app=myapp | grep -A 5 -i 'probe\|last state'
📊 Production Insight
Port drift and container-localhost confusion are the classic misdirects: the app moved to 3001 or another container, the config still says 3000 or 127.0.0.1, and every dashboard except error.log looks healthy.
🎯 Key Takeaway
Direct curl plus process and socket checks split every 502 into dead backend, wrong address, or blocked socket in under thirty seconds. Fix the process before editing proxy config.

Read Nginx error.log Like a Map to the Fault

Three phrases cover most 502s, but production throws eight — learn them all and you'll classify any incident in seconds. connect() failed (111: Connection refused) means nothing listens at the configured address: dead process or wrong port, settled by the direct curl. connect() to unix:/run/myapp/app.sock failed (2: No such file or directory) means the app server isn't running; the same line with (13: Permission denied) is SELinux, AppArmor, or socket group membership — check namei -l and ausearch -m avc before touching anything else.

upstream prematurely closed connection means the app accepted then hung up: a crash between accept and reply, a Gunicorn worker murdered by --timeout mid-request, or a keepalive race where the backend closed an idle connection just as Nginx reused it. recv() failed (104: Connection reset by peer) is the same story one layer down. upstream sent too big header means the response outgrew your proxy buffers — that's a sizing fix, not a restart. no live upstreams means every peer tripped max_fails, so check the fleet, not one box.

Pair the phrase with the live targets. nginx -t validates syntax without touching traffic. nginx -T dumps the effective running config with includes resolved, so grepping proxy_pass shows true targets instead of the file you wish were loaded. When disk and -T disagree, you've found the sites-enabled symlink or include-path problem — the classic edit-a-file-Nginx-never-loads outage. In Docker, a missing resolver turns service-name lookups into host-not-found 502s, so pin resolver 127.0.0.11 valid=30s.

Lock in the reflex: tail, grep the phrase, dump live targets, run the diagnostic script below. Alert on error.log upstream phrases rather than bare 5xx rates — connect() failed pages the app owner while status-code dashboards just count damage. Repair without that frame is how one-digit typos survive for 38 minutes.

read-error-log.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Fresh upstream errors with context, newest last
sudo tail -n 200 /var/log/nginx/error.log | grep -E 'upstream|connect\(\)|timed out|recv\(\)|no live upstreams|too big header'

# Classify at a glance: count each failure mode in the last 5k lines
sudo tail -n 5000 /var/log/nginx/error.log | grep -oE 'connect\(\) failed|prematurely closed|upstream timed out|recv\(\) failed|no live upstreams|too big header' | sort | uniq -c | sort -rn

# Validate syntax before touching anything live
sudo nginx -t

# Print the running config's proxy targets (includes resolved)
sudo nginx -T | grep -B 2 -A 6 'proxy_pass|upstream '

# One-shot triage: log verdict + live target + backend answer
echo '--- error.log verdict ---'
sudo tail -n 100 /var/log/nginx/error.log | grep -Eo 'connect\(\) failed.*|upstream prematurely closed[^,]*|upstream timed out[^,]*' | tail -5
echo '--- live targets ---'
sudo nginx -T | grep proxy_pass
echo '--- backend answer ---'
curl -s -o /dev/null -w "%{http_code} in %{time_total}s\n" http://127.0.0.1:3001/health
💡Three Phrases Split Every 502
Grep for the three phrases, not the status code: connect() failed means dead or misaddressed, prematurely closed means the app hung up, and timed out means too slow. The phrase is the diagnosis.
📊 Production Insight
Access logs record that a 502 happened; only error.log records why. Phrase-rate alerts (connect() failed, prematurely closed, no live upstreams) page the right owner while 5xx graphs just count damage.
🎯 Key Takeaway
The error.log phrase names the fault class and nginx -T names the live target. Collect both plus a direct curl before changing anything, and the repair becomes obvious.

Fix proxy_pass and Upstream Blocks Without Guessing

The minimal correct proxy block has five ingredients: an upstream naming the real host and port, a location forwarding to it, the two headers backends need for correct URLs and client IPs, and HTTP/1.1 with an empty Connection header so keepalive to upstream actually works. The upstream block earns its place the day you add a second app server — one name to change instead of five location blocks. Until then it documents intent: this name is the app, and the app lives here.

Port accuracy is the whole game. The port in the upstream or proxy_pass line must equal the port the app bound, verified with ss -ltnp rather than memory. Render the port from the same variable or service-discovery entry the app uses so the two can't drift. In Docker, point at the Compose or K8s service name (http://backend:3000), never 127.0.0.1, which is the Nginx container talking to itself. Respect trailing-slash semantics too: proxy_pass http://app_backend; under location /api/ preserves the full path, while proxy_pass http://app_backend/v1/; strips the matched prefix. Mixing them up converts a fixed 502 into a wave of 404s — curl one real endpoint after every change.

Healthy stacks still 502 without keepalive alignment. Nginx reuses idle upstream connections while backends close them on their own schedule — Gunicorn defaults to a 2s keepalive against Nginx's 60s assumption, so Nginx keeps sending requests down dead sockets and logging prematurely closed. Set keepalive 32 with keepalive_timeout 60s and keepalive_requests 1000 in the upstream block, keep the backend's idle timeout above Nginx's, and add proxy_next_upstream error timeout http_502 with tries capped at 2 so idempotent requests retry once on a sibling instead of failing the user.

Size buffers before fat headers bite: proxy_buffer_size 16k with proxy_buffers 8 16k absorbs chunky auth cookies and JWTs that otherwise die as upstream sent too big header. Apply changes with nginx -t then nginx -s reload, and confirm with nginx -T that live matches intent. When 502s persist past a correct reload, the fault was never the config: return to the backend, because a perfect proxy still 502s against a dead app.

/etc/nginx/sites-available/appNGINX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
upstream app_backend {
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    keepalive 32;
    keepalive_timeout 60s;
    keepalive_requests 1000;
}

server {
    listen 80;

    location /api/ {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 10s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        proxy_next_upstream error timeout http_502;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 10s;

        proxy_buffer_size 16k;
        proxy_buffers 8 16k;
        proxy_busy_buffers_size 32k;
    }
}
# Unix-socket variant: proxy_pass http://unix:/run/myapp/app.sock:/;
# Docker variant:    server backend:3000;  (service name, never 127.0.0.1)
⚠ Reload, Don't Restart
A reload is not a restart: nginx -s reload applies config with zero dropped connections, while restart kills them. Validate with nginx -t first, because a reload with a broken config keeps the old one and lies about it.
📊 Production Insight
The deadliest proxy faults are almost-right configs: correct host with the port off by one digit, or keepalive assumed but never enabled — green dashboards over one location nobody smoke-tested after reload.
🎯 Key Takeaway
Name the real host and port once, forward it with keepalive and sane buffers, respect trailing-slash rewrites, and confirm live config after reload. Then re-test through the proxy.

Timeouts vs Slow Apps: Tune Without Hiding Outages

Nginx carries three patience knobs guarding three phases. proxy_connect_timeout caps the TCP handshake — keep it at 5–10s so dead backends fail fast instead of stacking connections. proxy_send_timeout caps pushing the request body uphill; 60s covers all but pathological uploads. proxy_read_timeout caps the gap between response bytes, and it's the one slow endpoints actually hit — 60s default, raised deliberately per location. Error.log mirrors them: connection timed out versus upstream timed out.

The order is non-negotiable: prove the app slow before extending patience. You need upstream timed out in error.log plus direct curls that eventually succeed — the backend answers, just slower than the current read timeout. Scope every raise to that location block, as below, never globally: a blanket 300s read timeout turns each future backend death into connections that hang for five minutes, filling worker pools with doomed waits. Timeouts shape slow success; they can't create success from refusal, and raising them over connect() failed converts crisp 502s into a site that freezes instead of erroring.

Bound your retries with the same discipline. proxy_next_upstream error timeout lets a failed request try the next peer, with proxy_next_upstream_tries 3 and proxy_next_upstream_timeout 10s capping the blast radius — unbounded, one slow fleet multiplies every user's wait by your peer count. Keep non_idempotent off the list unless you've thought hard: retrying a POST that already charged a card is how timeouts become double-billing incidents.

Align the app's own clocks above the proxy's. Gunicorn's --timeout 30 murders workers and their in-flight requests a full 90 seconds before a 120s proxy_read_timeout gives up — set the app timeout strictly higher. Node servers behind keepalive need keepAliveTimeout above Nginx's keepalive_timeout. And keep slow-client knobs separate: client_body_timeout and client_max_body_size govern the user-to-Nginx leg, proxy_read_timeout governs Nginx-to-app. When the report endpoint still takes 90 seconds after all this, paginate it, cache it, or background it — a 120s timeout blessed forever is latency debt.

/etc/nginx/sites-available/appNGINX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Default patience for the API: fail fast on dead, firm on slow
location /api/ {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_connect_timeout 10s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;
    proxy_next_upstream error timeout http_502;
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 10s;
}

# Generous patience, scoped to the one endpoint proven legitimately slow
location /api/reports/ {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_connect_timeout 10s;
    proxy_send_timeout 120s;
    proxy_read_timeout 180s;
    # No next-upstream retry here: report POSTs are not idempotent
}
📊 Production Insight
Global timeout raises are outage camouflage: crisp 502s turn into hanging requests that exhaust worker pools, converting a visible failure into a cascading one — while unbounded retries multiply every slow reply by your peer count.
🎯 Key Takeaway
Connect short, send normal, read generous only where proven slow — and set app timeouts above the proxy's. Timeouts describe slow success, never dead backends.

502 vs 503 vs 504: Return the Status That Tells the Truth

These three statuses describe three different truths, and returning the wrong one sends the next incident down the wrong path. A 502 says the upstream answered badly: refused connection, truncated reply, garbage headers, or a vanished socket. It indicts the conversation between proxy and app, and its remedy lives in process health, proxy targets, or buffer sizing. Count 502s as broken conversations — a spike means a bad deploy, a dead fleet, or config drift, and error.log already names which.

A 503 says the service knowingly declined: overloaded, in maintenance, or shedding load, ideally with Retry-After telling clients when to return. It indicts capacity or intent, and its remedy is scaling, draining, or waiting. Have backends emit honest 503s instead of hanging until Nginx manufactures a 502 — balancers retry 503s elsewhere by design, while a 502 looks like a poisoned conversation they'll keep hammering. Your maintenance pages and overload shedding must return 503 deliberately.

A 504 says the upstream never answered in time: the proxy waited the full proxy_read_timeout and gave up. It indicts slowness, and its remedy is faster handlers or deliberately longer waits. The error.log distinction stays crisp — refused and prematurely closed versus upstream timed out — so classify from the log line whenever the status code and the story seem to disagree.

Map the edge layers too. Behind Cloudflare, a branded 502 is your origin's own 502 passed through; an unbranded one means Cloudflare can't reach or parse your origin — broken gzip with a stale content-length, partial HTTP/2 support, or a dead tunnel. On AWS, split ELB_502 counts (the balancer's complaint) from target 502s (your app's). gRPC upstreams need grpc_pass with a matching HTTP version, and WebSocket locations need Upgrade plus Connection headers with a long read timeout, or quiet sockets die as 502s with zero app-side trace. Dashboard 502 and 504 trends separately: blended 5xx graphs hide a deploy-driven 502 spike inside normal 504 noise for exactly as long as it takes to miss the rollback.

📊 Production Insight
Blended 5xx dashboards are incident camouflage: a 502 spike from a bad deploy hides inside normal 504 noise, delaying the rollback by the exact length of the confusion — split them and the deploy stands out instantly.
🎯 Key Takeaway
502 means bad reply, 503 means deliberate refusal, 504 means too slow. Log the distinction, alert on each separately, and have backends emit honest 503s with Retry-After.
● Production incidentPOST-MORTEMseverity: high

A One-Digit Port Typo Returned 502 on Checkout for 38 Minutes

Symptom
Within a minute of the 11:02 deploy, 100% of /api/checkout requests returned 502 while the homepage served fine. The outage lasted 38 minutes across roughly 4,100 failed checkouts at a steady ~108 per minute, with checkout error rate pinned at 100% and p99 latency replaced by instant failures. Nginx stayed up with flat CPU and memory; only error.log told the truth with connect() failed (111: Connection refused) on every proxied request. The status-code dashboard took 6 minutes to page because 502s hid inside normal 5xx noise.
Assumption
The team assumed the deploy had broken application code, so three engineers spent 25 minutes reading a diff that was perfectly fine — about two-thirds of the total outage burned on a healthy codebase. Nobody looked at the proxy config because the checklist called it untouched, and nobody ran the bypass curl that would have split backend from proxy in ten seconds.
Root cause
The release moved the Node API from port 3000 to 3001 while the Nginx site file kept proxy_pass http://127.0.0.1:3000. Every proxied request hit a closed port and error.log filled with connect() failed (111: Connection refused) — roughly 4,100 refusals in 38 minutes. The app itself was healthy on 3001 the entire time with 200s on direct curls; Nginx simply never asked it. Nobody caught it because the deploy checklist labeled proxy config untouched infrastructure, and no smoke test hit /api/checkout through the proxy after reload.
Fix
The fix was one line plus a safe reload: proxy_pass changed from port 3000 to 3001, validated with nginx -t, applied with nginx -s reload in under a second with zero dropped connections. Checkout recovered fully within 4 minutes of the reload as error rate fell 100% to baseline. The port is now rendered from the same variable the app's systemd unit uses, CI fails the build when nginx -T targets don't match listening ports, and a post-deploy smoke test curls /api/checkout through the proxy before the deploy is marked green.
Key lesson
  • Proxy targets are code: review and test proxy_pass changes with the same rigor as application diffs, including a through-the-proxy smoke test on every deploy.
  • Alert on error.log upstream phrases, not just status codes. A connect() failed rate alert would have paged in under a minute instead of letting 502s hide in 5xx noise for 6.
  • Bypass-and-compare is the fastest split in proxy incidents: one direct curl separates backend death from config drift in ten seconds — run it before reading any diff.
Production debug guideFive checks that separate dead backends from bad proxy config in minutes, with the exact commands for each.5 entries
Symptom · 01
All or some requests return 502 through Nginx
→
Fix
Run sudo tail -n 200 /var/log/nginx/error.log | grep -E 'upstream|connect\(\)|timed out|recv\(\)|no live upstreams|too big header' and read the verdict: connect() failed (111) means dead backend or wrong port; (2: No such file) means a missing socket; (13: Permission denied) means SELinux/AppArmor; prematurely closed or recv() failed (104) means the app hung up; upstream timed out (110) means too slow; too big header means undersized buffers; no live upstreams means every peer tripped max_fails. The phrase picks your next step — never skip it.
Symptom · 02
You can't tell whether Nginx or the backend is at fault
→
Fix
Run curl -s -o /dev/null -w '%{http_code} in %{time_total}s\n' http://127.0.0.1:3001/health against the real backend port (swap in your app's port, or the socket path for unix upstreams). A 200 blames proxy config; a refusal blames the backend process. Add -v for headers and curl -w 'connect:%{time_connect} start:%{time_starttransfer}' when you need timing that separates slow apps from dead ones.
Symptom · 03
Direct curl to the backend also fails
→
Fix
Run systemctl status myapp --no-pager for systemd units, or docker ps --filter name=myapp plus docker logs --tail 50 myapp for containers — look for inactive, OOMKilled, or restart loops. In Kubernetes run kubectl get pods -l app=myapp and describe for probe failures and exit 137. On (13: Permission denied) also run ausearch -m avc -ts recent for SELinux denials. Fix the crash, OOM, probe, or denial first — no proxy setting revives a dead or blocked process.
Symptom · 04
Backend is healthy but the 502 persists
→
Fix
Run sudo nginx -t to validate syntax, then sudo nginx -T | grep -B 2 -A 6 'proxy_pass' to print the live upstream targets with includes resolved. Compare the host and port against ss -ltnp | grep <port> on the app box — and in Docker, confirm proxy_pass uses the Compose/K8s service name, not 127.0.0.1. A mismatch of one digit or one hostname is a full outage for that location block.
Symptom · 05
error.log shows upstream timed out instead of refused
→
Fix
Run curl -w 'connect:%{time_connect} start:%{time_starttransfer} total:%{time_total}\n' -o /dev/null <backend-url> to measure the app's true response time, then compare against proxy_read_timeout in that location block. If the app answers slower than the timeout, fix the app first and raise the timeout only for endpoints proven legitimately slow — and confirm the app's own worker timeout (Gunicorn --timeout, Node keepAliveTimeout) sits strictly above the proxy's, or the app murders requests the proxy still waits for.
502 Bad Gateway Causes Compared
Root CauseHow to ConfirmFixPrevention
Backend process crashed or never startederror.log shows connect() failed (111: Connection refused) or socket (2: No such file); direct curl refused; systemctl/docker shows it downRestart the app service, fix the crash or OOM, correct probe thresholdssystemd auto-restart, OOM and CrashLoop alerts, through-the-proxy smoke tests
proxy_pass points at the wrong port, host, or container nameDirect curl to the real port succeeds while Nginx logs refused on the configured one; nginx -T disagrees with the app's ss -ltnpCorrect proxy_pass (service name in Docker) and run nginx -t plus reloadRender ports from one source of truth, nginx -T checks in CI, deploy smoke tests
App slower than proxy timeoutserror.log shows upstream timed out (110); direct curl with timing eventually succeeds past proxy_read_timeoutSpeed up the app, then raise proxy_read_timeout scoped to that location onlyAlert on p95 latency before it crosses the timeout; keep app worker timeout above proxy's
Backend OOM-killed or probe-killed under loadContainer exit code 137 or kernel OOM lines; K8s Liveness failures; docker ps shows restarts climbingRaise memory limits, fix the leak or spike, relax the probeMemory alerts at 80%, load-test before launches, probe failure-rate alerts
Upstream closed a reused keepalive connection or sent oversized headerserror.log shows prematurely closed on healthy backends, or too big header on header-heavy endpointsAlign keepalive timeouts both sides, retry idempotent requests, size proxy_buffers upLoad-test with keepalive enabled, watch error.log, test fat-cookie/JWT endpoints
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
bypass-proxy.shcurl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/healthBackend Down vs Proxy Misconfigured
read-error-log.shsudo tail -n 200 /var/log/nginx/error.log | grep -E 'upstream|connect\(\)|timed ...Read Nginx error.log Like a Map to the Fault
etcnginxsites-availableappupstream app_backend {Fix proxy_pass and Upstream Blocks Without Guessing
etcnginxsites-availableapplocation /api/ {Timeouts vs Slow Apps

Key takeaways

1
A 502 means Nginx got an invalid or empty reply from upstream
refused, truncated, or timed-out — so investigate behind the proxy first.
2
error.log phrases split the cause in seconds
connect() failed, prematurely closed, timed out, too big header, or no live upstreams.
3
Curl the backend directly (plus ls the socket, ss the port) to separate proxy misconfiguration from backend failure.
4
Fix the backend before tuning timeouts, and scope every timeout raise to the proven-slow location
never global.
5
502 is a bad reply, 503 is deliberate refusal with Retry-After, 504 is too slow
return and dashboard each deliberately.
6
Validate with nginx -t, reload with zero downtime, confirm live targets with nginx -T, and align keepalive plus buffers.

Common mistakes to avoid

5 patterns
×

Raising proxy timeouts before checking whether the backend is alive

Symptom
Timeouts grow to 300s while the backend is actually crashed, turning fast 502s into slow ones that hold connections open.
Fix
Check the backend first: direct curl to its port or socket plus tail error.log for connect() failed vs timed out. Tune proxy_read_timeout only after curls prove the app alive but slower than the timeout.
×

Editing a config file Nginx never loads

Symptom
proxy_pass is correct on disk but 502s continue because the live config still points at the old port.
Fix
Edit the file under sites-available, confirm the symlink exists in sites-enabled, then run nginx -t && nginx -s reload and verify with nginx -T | grep proxy_pass — disk and live must agree.
×

Restarting Nginx instead of the backend

Symptom
Reloads report success while 502s persist, because the crashed app server was never touched.
Fix
Restart or fix the app server behind the proxy (systemctl restart myapp, docker restart, kubectl rollout restart) — and check OOM kills and probe thresholds. Reloading Nginx only applies config; it can't resurrect a dead upstream.
×

Misreading proxy_pass trailing-slash behavior

Symptom
The 502 clears but every API call 404s, because the upstream now receives a rewritten path it doesn't serve.
Fix
Remember location /api/ with proxy_pass http://host:3001; preserves the path, while a URI in proxy_pass (http://host/v1/;) rewrites it — and in Docker the host must be the service name, not 127.0.0.1. Test with curl and read access logs on both sides.
×

Blaming the CDN or DNS for a backend outage

Symptom
Hours spent purging caches and checking DNS while the app server sits crashed one hop behind Nginx.
Fix
Treat a 502 as proxy-plus-upstream evidence: confirm it in your Nginx error.log (connect() failed, prematurely closed) and curl the backend directly. Cloudflare-branded versus bare 502s and ALB ELB_502 versus target 502s tell you which layer to blame before purging anything.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does 502 Bad Gateway mean on an Nginx reverse proxy?
Q02JUNIOR
How do you tell a 502 from a 504 in error.log?
Q03SENIOR
Walk me through debugging a 502 in production.
Q04SENIOR
What do the three Nginx proxy timeouts control?
Q05SENIOR
Why would a healthy backend still cause intermittent 502s?
Q01 of 05JUNIOR

What does 502 Bad Gateway mean on an Nginx reverse proxy?

ANSWER
A 502 means the proxy received an invalid, empty, or no response from upstream: backend crashed, proxy_pass points at the wrong port or service name, socket missing or permission-blocked (13: Permission denied), or the app hung up mid-reply. The proxy itself is healthy — the conversation behind it failed — so triage starts with error.log plus a direct backend curl.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does a 502 mean Nginx is broken?
02
How do I test the backend without the proxy?
03
Should I just raise the proxy timeouts?
04
What is the difference between 502, 503, and 504?
05
Can a deploy cause a 502 with no code bug?
06
How do I safely apply a proxy_pass fix?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Networking. Mark it forged?

8 min read · try the examples if you haven't

←
Previous
Docker COPY vs ADD Instruction
1 / 5 · Networking
Next
Kubernetes CrashLoopBackOff Fix
→