What a 502 actually tells you
A 502 is never generated by the application you are trying to reach. It comes from something in front of it: nginx, a load balancer, a CDN, an ingress controller. RFC 9110 defines it as a gateway or proxy that “received an invalid response from an inbound server it accessed while attempting to fulfill the request”.
In practice “invalid response” covers more than malformed HTTP. A proxy also answers 502 when it couldn’t open the connection at all (refused, missing socket), when the backend closed the connection halfway through, or when the backend’s headers didn’t fit in the proxy’s buffer. What these have in common: the proxy is up, it tried, and what came back from behind it was unusable.
That separates 502 from its neighbours:
| Code | What the proxy experienced | Typical timing |
|---|---|---|
| 502 | Got a broken answer, or the connection failed outright | Usually immediate |
| 504 | Connected (or tried to), waited, and its timer ran out | A round number of seconds: 30, 60, 100 |
| 503 | Decided not to forward at all: maintenance, rate limit, no healthy backends | Immediate |
Timing is a cheap first clue. A 502 that arrives in 20 milliseconds is a refused connection or a crashed process. A “502” that takes exactly 30 seconds deserves a second look: some app servers kill slow requests themselves, and the proxy then sees a dropped connection and reports 502 for what is really a timeout (more on that below).
If you searched for “505 Bad Gateway”: the bad gateway code is 502. 505 is an unrelated and rare error, HTTP Version Not Supported.
If you’re visiting the site
There is nothing on your device to fix. Your browser reached the site’s proxy fine; the failure is between that proxy and the server behind it. Reload after a minute. A 502 during a deploy usually clears within seconds; one that persists for hours is an outage only the owner can resolve.
The one exception: if only your office network shows the error and the error page carries your company’s proxy branding, the 502 came from your own corporate proxy. Try the site from a phone on mobile data to confirm.
Find out which layer sent it
Most production setups have two or three proxies in a row, and any of them can produce the 502. Fixing nginx when the load balancer is the one complaining wastes an afternoon. Look at the response headers and the error page body:
curl -sv -o /dev/null https://example.com/ 2>&1 | grep -iE '^< (HTTP|server|via|x-cache|cf-ray|x-amz-cf-id)'
What the common layers look like:
- nginx:
Server: nginx(or with a version). The default page is a bare “502 Bad Gateway” heading with an “nginx” footer line. - AWS Application Load Balancer:
Server: awselb/2.0, and a minimal HTML page with no footer. - Amazon CloudFront:
X-Cache: Error from cloudfrontand a page that says “502 ERROR … The request could not be satisfied” and “Generated by cloudfront (CloudFront)”. - HAProxy: the default page reads “The server returned an invalid or incomplete response.”
- Apache mod_proxy: a “Proxy Error” page: “The proxy server received an invalid response from an upstream server.”
- Cloudflare: every proxied response carries
Server: cloudflareand aCF-Rayheader, so the header doesn’t tell you who generated the error. The page body does. A Cloudflare-branded page came from Cloudflare; a plain page with an nginx footer is your origin’s 502, passed through. Problems between Cloudflare and your origin usually show up as Cloudflare’s own 52x codes instead, such as 522 or 525.
The HTTP check shows the final status code and the response headers, including Server, from outside your network, which is handy when you can’t reproduce the error from your own machine.
Once you know which layer generated it, the cause is always the next hop behind that layer. A 502 with an ALB signature means the ALB’s targets (often your nginx) gave it a bad answer. A 502 with an nginx signature means nginx’s upstream (your app, PHP-FPM, a container) did.
Read the nginx error log
If nginx generated it, its error log names the reason in one line. On most distributions:
sudo tail -n 50 /var/log/nginx/error.log
# or, in containers:
docker logs --tail 50 <nginx-container>
Every 502 leaves an [error] entry. The part after the errno is what matters. Here is what the common ones look like, and what each means.
Connection refused: nothing is listening
Something like:
2026/10/07 09:12:44 [error] 2114#2114: *8812 connect() failed (111: Connection refused) while connecting to upstream, client: 198.51.100.7, server: example.com, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:3000/", host: "example.com"
The app isn’t running, crashed, or listens on a different port or address than proxy_pass says. Check from the proxy host itself:
sudo ss -ltnp | grep 3000
curl -v http://127.0.0.1:3000/
systemctl status myapp
journalctl -u myapp --since "15 min ago"
If the process keeps dying, look for the kernel’s OOM killer, a classic silent cause: journalctl -k | grep -i "killed process".
Three variants of this that catch people regularly:
localhostresolving to IPv6.proxy_pass http://localhost:3000makes nginx resolvelocalhostto both127.0.0.1and::1and use both. If the app only listens on IPv4, roughly half the requests fail withupstream: "http://[::1]:3000/"in the log. Write127.0.0.1explicitly.- Docker networking. Inside an nginx container,
127.0.0.1is the nginx container itself, not the app. Use the app’s service or container name on a shared network, and make sure the app binds to0.0.0.0, not127.0.0.1, inside its own container. - The deploy window. Every restart leaves a few seconds where nothing listens. If 502s cluster around deploys, use graceful reloads, or rolling deploys behind health checks so traffic only reaches instances that are ready.
Socket missing or not permitted: PHP-FPM
Something like:
connect() to unix:/run/php/php8.2-fpm.sock failed (2: No such file or directory) while connecting to upstream
connect() to unix:/run/php/php8.2-fpm.sock failed (13: Permission denied) while connecting to upstream
“No such file” means PHP-FPM isn’t running or its socket lives somewhere else. This often appears right after a PHP upgrade, when the new version’s socket has a new name (php8.3-fpm.sock) but fastcgi_pass still points at the old one. Compare the listen = line in the pool config (for example /etc/php/8.2/fpm/pool.d/www.conf) with fastcgi_pass in nginx -T.
“Permission denied” means the socket exists but the nginx worker user can’t open it. Set listen.owner, listen.group and listen.mode in the pool so the nginx user (www-data or nginx) has access, then restart PHP-FPM.
On RHEL-family systems, a (13: Permission denied) on a TCP upstream such as 127.0.0.1:8080 usually isn’t file permissions at all but SELinux blocking nginx from making network connections. sudo setsebool -P httpd_can_network_connect 1 is the standard fix; ausearch -m avc -ts recent shows the denial.
Upstream closed or reset the connection
Something like:
upstream prematurely closed connection while reading response header from upstream
recv() failed (104: Connection reset by peer) while reading response header from upstream
nginx connected and sent the request, and the backend hung up before finishing its answer. Common reasons:
- The app crashed on this request. A segfault, an unhandled exception that kills the worker, an out-of-memory kill. The application’s own log at the same timestamp usually shows it.
- The app server killed a slow worker. Gunicorn’s default worker timeout is 30 seconds; when it fires, gunicorn logs
[CRITICAL] WORKER TIMEOUTand kills the worker, and nginx reports a 502. PHP-FPM does the same withrequest_terminate_timeout. This is a timeout in disguise: the fix is the slow request (or the timeout), as in 504 Gateway Timeout. Which code you see depends on which timer is shorter: the app server’s kill gives 502, nginx’s ownproxy_read_timeoutgives 504. - Keep-alive races. The proxy reuses an idle connection at the same moment the backend decides to close it. The classic case is Node.js behind an AWS ALB: Node’s default
keepAliveTimeoutis 5 seconds, the ALB’s idle timeout defaults to 60, and a small fraction of requests get intermittent 502s. The rule is that the backend’s keep-alive timeout must be longer than the proxy’s. For Node, setserver.keepAliveTimeoutabove the ALB idle timeout (for example 65 seconds) andserver.headersTimeoutslightly above that.
Headers too big for the buffer
Something like:
upstream sent too big header while reading response header from upstream
The backend’s response headers don’t fit in nginx’s header buffer, which by default is one memory page (4 KB or 8 KB, depending on the platform). Large Set-Cookie headers from session or analytics cookies are the usual trigger, followed by large authentication or CSP headers. It tends to hit only some users (the ones with the big cookies) or only after login.
Raise the buffer and the buffers together, otherwise nginx -t complains about the busy buffer size:
# proxy_pass
proxy_buffer_size 16k;
proxy_buffers 8 16k;
# PHP-FPM / fastcgi_pass
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
Then sudo nginx -t && sudo systemctl reload nginx. Also ask why the headers are that big. A cookie that keeps growing will outgrow any buffer.
No live upstreams
Something like:
no live upstreams while connecting to upstream, client: 198.51.100.7, server: example.com, request: "GET / HTTP/1.1", upstream: "http://app_backend/", host: "example.com"
This is the result, not the cause. Every server in the upstream block failed recently (max_fails within fail_timeout), so nginx stopped trying and returns 502 without connecting. Scroll up in the log for the earlier errors that marked the servers down; those tell you the real reason.
TLS to the upstream failed
If proxy_pass uses https://, look for something like SSL_do_handshake() failed … while SSL handshaking to upstream. The backend may not speak TLS on that port, or it may need SNI: nginx does not send SNI to upstreams by default, so add proxy_ssl_server_name on; (and proxy_ssl_name if the upstream name differs).
When the 502 comes from the load balancer
If the Server header says awselb/2.0, the ALB’s target answered badly. AWS documents several reasons for an ALB 502, among them a target that closed the connection or sent a malformed response, and a target that answered with an invalid TLS handshake when the target group uses HTTPS. The keep-alive mismatch above is by far the most common in practice. The ALB access logs record elb_status_code and target_status_code separately: if the target status is -, the target never produced a valid response.
For Kubernetes ingress-nginx the log format is nginx’s, so the messages above apply. A Pod that is restarting or crashlooping gives Connection refused; a Service selector that matches nothing usually gives 503 rather than 502.
Checking from outside
When you can’t reproduce the 502 from the server itself, two outside checks help narrow it down:
- The HTTP check to see the status code and headers a normal visitor gets, and whether the 502 is on every request or intermittent.
- A DNS lookup to confirm the hostname points where you think it does. After a migration, an old A record can send part of the traffic to a retired server whose proxy has nothing behind it.
The port check only helps when the upstream is publicly reachable, for example an origin IP behind a CDN. Internal upstreams like 127.0.0.1:3000 must be tested from the proxy host with curl and ss, as above.
After the fix
- Reload the failing URL several times. Intermittent 502s (keep-alive races, one bad instance out of several) need more than one request to rule out.
- Watch the error log while you do it:
sudo tail -f /var/log/nginx/error.log. A fixed 502 stops producing new lines. - If the 502s lined up with deploys, run a deploy while watching. That is the only real test of a zero-downtime setup.
Related errors
- 504 Gateway Timeout: the proxy connected but waited too long for an answer.
- 503 Service Unavailable: the service is deliberately refusing, overloaded, or has no healthy backends.
- Cloudflare error 522: Cloudflare couldn’t open a TCP connection to your origin.
- ERR_CONNECTION_REFUSED: the same “nothing is listening” problem, seen directly from the browser with no proxy in between.
Check your own domain now
Free, no sign-up. Runs the exact check this guide describes and shows what to fix.