502 Bad Gateway: What It Means and How to Fix It
A 502 Bad Gateway means a proxy, load balancer or CDN in front of the site asked the server behind it for a response and got an invalid one, or none at all. The component that shows the error is working; the fault is in the backend behind it or in the connection between the two.
What a 502 tells you
RFC 9110 defines 502 for a server acting as a gateway or proxy that received an invalid response from an inbound server it accessed while trying to fulfill the request. Most sites have at least one such hop: nginx or Apache in front of an application server, a load balancer in front of several servers, a CDN in front of the origin, or all three.
So a 502 splits your stack in two: whatever produced the error page is healthy enough to answer, and something behind it is not. Two details narrow it down. The page often says who generated it: nginx prints its name at the bottom, Apache titles the page "Proxy Error", and CDNs and cloud load balancers use their own branded pages. And a fast 502 most likely means a refused connection; one that takes 30 or 60 seconds, a backend that hung until a timeout cut the request off.
502 vs 503, 504 and Cloudflare 520-522
All of these codes come from the layer between the visitor and the application, and the 5xx overview covers the whole class. What separates them is what happened on the connection to the backend.
| Error | What happened on the backend connection | Where to look first |
|---|---|---|
| 502 Bad Gateway | Refused connection, dropped connection, or a response the proxy could not parse | Proxy error log, then whether the backend process runs |
| 503 Service Unavailable | A server declined the request: overload, maintenance, or no healthy backend to send it to | Maintenance flags, capacity, health checks |
| 504 Gateway Timeout | The backend accepted the request but did not answer within the proxy's timeout | Slow queries and slow dependencies |
| Cloudflare 520 | The origin's answer was empty, malformed or cut off | Origin crash logs, response header size |
| Cloudflare 521 | The origin refused Cloudflare's TCP connection | Whether the web server runs, firewall rules for Cloudflare's IP ranges |
| Cloudflare 522 | Cloudflare's TCP connection to the origin timed out | Firewall drops, server load, the origin IP in DNS |
The mapping is not universal, because each proxy chooses its own code for the same fault:
- nginx returns 502 when it cannot connect or the connection breaks, and 504 when its connect or read timeout runs out.
- Apache
mod_proxy_httpreturns 503 when it cannot connect, and 502 when the backend hangs up orProxyTimeoutexpires before a status line arrives. - Envoy and gateways built on it return 503 for refused and reset connections ("upstream connect error or disconnect/reset before headers") and 502 for protocol errors.
- ingress-nginx in Kubernetes returns 503 when a Service has no ready endpoints.
- Cloudflare turns its own failed connections to the origin into 52x codes, and shows a Cloudflare-branded 502 when your origin returned one.
Common causes
- The backend is not running: a crashed app server, a stopped PHP-FPM pool, an exited container.
- The backend died mid-request: an out-of-memory kill, a segmentation fault, or a worker killed by its own timeout.
- Wrong address, port or socket path, often left over from a migration, a PHP upgrade or a container rebuild.
- The backend listens on
127.0.0.1, so another container, pod or host cannot reach it. - Unix socket permissions: the web server's user cannot open the PHP-FPM or app socket.
- Scheme mismatch: TLS sent to a plain HTTP port, or plain HTTP to a TLS-only one.
- Response headers too large for the proxy's header buffer, such as an oversized cookie.
- Keep-alive races: the proxy reuses an idle connection at the moment the backend closes it.
- Restarts and empty pools: the gap during a deploy, or an upstream group whose servers are all marked failed.
Reading the proxy error log
The status code is generic, but the line the proxy writes to its error log at the same second is specific. Linux packages of nginx write it to /var/log/nginx/error.log:
[error] connect() failed (111: Connection refused) while connecting to upstream,
upstream: "http://127.0.0.1:8080/"
[error] upstream prematurely closed connection while reading response header
from upstream
[error] recv() failed (104: Connection reset by peer) while reading response
header from upstream
[error] upstream sent too big header while reading response header from upstream
[error] no live upstreams while connecting to upstream
Connection refused: nothing listens on that address and port. The backend is stopped, still starting, bound to another interface, or on another port.upstream prematurely closed connection: the backend took the request and hung up before sending headers. Look for a crash, a memory kill or a worker timeout in the backend's own log. From PHP-FPM it readsupstream prematurely closed FastCGI stdout.Connection reset by peer: the same, but reset instead of closed. Typical of a killed worker, a firewall in between, or a pooled connection the backend had already closed.upstream sent too big header: a buffer limit in nginx, not a backend fault (see the buffer settings below).no live upstreams: every server in anupstreamgroup is marked failed. By default one failure takes a server out for 10 seconds (max_fails=1,fail_timeout=10s), so scroll up for the error that tripped it.
On Apache, a refused connection gives a 503; a backend that hangs past ProxyTimeout or closes without answering gives the 502:
[proxy:error] (111)Connection refused: AH00957: HTTP: attempt to connect to
127.0.0.1:8080 (127.0.0.1:8080) failed
[proxy:error] AH00959: ap_proxy_connect_backend disabling worker for
(127.0.0.1:8080) for 60s
[proxy_http:error] AH01114: HTTP: failed to make connection to backend: 127.0.0.1
[proxy_http:error] (70007)The timeout specified has expired: AH01102: error
reading status line from remote server 127.0.0.1:8080
[proxy:error] AH00898: Error reading from remote server returned by /report
The "disabling worker" line explains why Apache can keep failing after the backend recovers: it stops sending requests to that backend for the retry period, 60 seconds by default, and answers 503 meanwhile. retry=0 on the ProxyPass line removes the delay.
How to fix it
- Test the backend directly from the proxy host:
A 200 here with a 502 on the public URL puts the fault in the proxy config or the path between the two. A refused connection means the backend is down.curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/ - Check what is listening where the proxy dials:
sudo ss -ltnpfor TCP ports,sudo ss -xlpfor Unix sockets. - Match the proxy log line at the time of the 502 against the lists above before changing anything. On managed hosting, find it in the control panel or ask the host.
- Read the backend log for the same second. A crash, a worker timeout or an out-of-memory kill (
journalctl -k | grep -i "out of memory") confirms the "prematurely closed" case. - Check the upstream address, port, socket path and scheme.
sudo nginx -Tprints the whole loaded configuration. - Apply the fix for your stack from the sections below, then
sudo nginx -t && sudo systemctl reload nginx. - Make deploys overlap: start the new process, wait for its health check, then drain and stop the old one.
PHP-FPM
On PHP sites, nginx names the PHP-FPM problem directly:
[crit] connect() to unix:/run/php/php8.2-fpm.sock failed (2: No such file or
directory) while connecting to upstream
[crit] connect() to unix:/run/php/php8.2-fpm.sock failed (13: Permission denied)
while connecting to upstream
[error] connect() to unix:/run/php/php8.2-fpm.sock failed (11: Resource
temporarily unavailable) while connecting to upstream
- No such file or directory: PHP-FPM is stopped, or
fastcgi_passnames the wrong socket. Debian and Ubuntu name the socket after the PHP version, so an upgrade from 8.1 to 8.2 moves it. - Permission denied: the user nginx runs as (
www-dataon Debian and Ubuntu,nginxin the nginx.org and RHEL packages) needs read and write access to the socket, set bylisten.owner,listen.groupandlisten.mode. - Resource temporarily unavailable: every child is busy and the socket's queue is full. The PHP-FPM log shows
server reached pm.max_children setting (5), consider raising it; 5 is the value in the sample pool file PHP ships. Requests that wait in the queue instead end as 504s once they passfastcgi_read_timeout.
; /etc/php/8.2/fpm/pool.d/www.conf (Debian and Ubuntu layout)
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
request_terminate_timeout = 60s
Size pm.max_children from memory: divide the RAM you can give PHP by the resident memory of one busy child, as top shows it. Going higher trades 502s for out-of-memory kills. request_terminate_timeout kills a child that runs too long and logs execution timed out (...), terminating; nginx sees the connection drop and returns 502, so a slow script can surface as a 502 rather than a 504. With Apache in front, these socket failures appear as AH02454 and AH01079, with a 503.
Node.js, Gunicorn and other app servers
An app server that crashes leaves nothing listening until systemd, pm2 or a Docker restart policy brings it back, and the proxy logs connection refused for that whole window. journalctl -u myapp --since "15 min ago" shows the exit reason; in Node.js, an uncaught exception ends the process. Three defaults cause 502s in otherwise healthy apps:
- Bind address. Gunicorn binds to
127.0.0.1:8000unless--bindor aPORTvariable says otherwise, and another container cannot reach that address. Usegunicorn --bind 0.0.0.0:8000 app:appin a container; the same goes for a Node app started withapp.listen(3000, "127.0.0.1"). - Worker timeout. With the default sync workers, Gunicorn kills a worker silent for longer than
--timeout(30 seconds by default) and logs[CRITICAL] WORKER TIMEOUT (pid:4242). The proxy sees the connection drop, so a slow Django or Flask view shows up as a 502 at the 30-second mark. - Keep-alive timeout. In current Node.js release lines,
server.keepAliveTimeoutdefaults to 5 seconds, while an AWS Application Load Balancer keeps idle connections for 60. The balancer can send a request down a connection Node is closing, and that request gets a 502; AWS recommends an application idle timeout longer than the balancer's.
const server = app.listen(3000, "0.0.0.0");
server.keepAliveTimeout = 65000; // longer than the balancer's 60-second idle timeout
Docker
- Container port, not host port. With
-p 8080:3000, another container on the same network reaches the app atapp:3000;app:8080is refused. - Listen on all interfaces. A process bound to
127.0.0.1inside the container is unreachable from other containers and through published ports. - Restarts and memory kills.
docker inspect app | grep -E '"(Status|RestartCount|OOMKilled)"'shows a crash loop or a memory kill. - Stale addresses. nginx resolves a
proxy_passhostname once, at startup, so after the app container is recreated with a new IP, nginx keeps dialing the old one. Reload nginx, or resolve at request time through Docker's embedded DNS:
resolver 127.0.0.11 valid=10s;
location / {
set $app http://app:3000;
proxy_pass $app;
}
Load balancers and Kubernetes ingress
AWS's list of 502 causes for its Application Load Balancer matches a proxy's: the target refused or reset the connection, sent a malformed response or oversized headers, closed a keep-alive connection while a request was outstanding, or failed the TLS handshake. With no registered targets, it answers 503 instead.
A Kubernetes ingress controller proxies straight to the pod addresses in the Service's EndpointSlices. With no ready endpoints (a selector that matches no pods, or every pod failing its readiness probe), ingress-nginx answers 503. A 502 means it had addresses and the connection failed: a targetPort that differs from the port the container listens on, an app bound to 127.0.0.1, or pods stopped mid-request during a rollout. Start with kubectl get endpointslices -l kubernetes.io/service-name=web and kubectl describe pods -l app=web.
Kubernetes removes a terminating pod from the EndpointSlices at the same time as it starts shutting it down, so the ingress can still send it requests for a moment. A short preStop sleep keeps the pod serving until the ingress catches up; it counts against terminationGracePeriodSeconds, 30 seconds by default.
# Service
spec:
selector:
app: web
ports:
- port: 80
targetPort: 8080 # the port the app really listens on
---
# Deployment pod template
containers:
- name: web
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
lifecycle:
preStop:
exec:
command: ["sleep", "10"]
Timeouts, keep-alive and buffer settings
Whether a slow backend ends as a 502 or a 504 depends on which timeout fires first. If the backend's limit runs out first (Gunicorn's --timeout, PHP-FPM's request_terminate_timeout), it drops the connection and nginx returns 502. If nginx's proxy_read_timeout or fastcgi_read_timeout runs out first (60 seconds by default), nginx returns 504 and logs upstream timed out (110: Connection timed out). Apache's mod_proxy_http returns 502 either way.
upstream app {
server 127.0.0.1:3000;
keepalive 16;
keepalive_timeout 4s; # shorter than the backend's own idle timeout
}
server {
location / {
proxy_pass http://app;
proxy_connect_timeout 5s;
proxy_read_timeout 120s;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
}
proxy_connect_timeoutcovers the TCP connect only (60 seconds by default); a short value fails fast on an unreachable host.proxy_read_timeoutis the longest gap between two reads from the backend, not a limit on the whole response.proxy_buffer_sizeholds the first part of the response, including the headers, and defaults to one memory page (4k or 8k). Raiseproxy_bufferswith it: on a 4k-page system, raisingproxy_buffer_sizealone to 16k makesnginx -tfail with aproxy_busy_buffers_sizeerror. Then find out why the headers grew.keepalivereuses backend connections. Since nginx 1.29.7 it is on by default (32 idle connections per worker, HTTP/1.1); older versions also needproxy_http_version 1.1and an emptyConnectionheader. Keepkeepalive_timeout(60 seconds by default) below the backend's idle timeout, so nginx closes idle connections first.
nginx retries a request that failed on a reused connection, but not a POST or PATCH it has already sent, so keep-alive races show up as occasional 502s on form submissions and API writes. For PHP-FPM the same settings carry a fastcgi_ prefix.
What a check from several locations shows
A curl from your machine tells you the site returned 502 once. The same request from several regions at once tells you which layer to suspect:
- Every location gets a fast 502: the proxy is up and the backend refuses connections.
- Every location gets a 502 after the same long wait, such as 30 or 60 seconds: a timeout cut the request off. Compare the delay with Gunicorn's, PHP-FPM's or Apache's limit.
- Some checks fail and some pass, in every region alike: one bad server in a pool, PHP-FPM at
pm.max_children, or keep-alive races. - Only locations behind one region or load balancer fail: one deployment or edge-to-origin path is broken while the rest works.
The HTTP check sends the request from 300+ locations and reports the status code, response time and error each location saw.
Frequently asked questions
Is a 502 my fault or the website's?
As a visitor, almost always the website's. The error comes from a server on their side, so clearing your cache or changing DNS will not fix it, and a 502 caused by a restart clears by itself once the backend is back. The exception is a proxy or VPN on your side, which is also a gateway: if every site shows a 502, check that first.
Do 502 errors hurt SEO?
Short ones do not. Google's crawlers slow down while a site returns 5xx errors and speed up again once it returns 2xx, and indexed URLs are kept. URLs that keep returning server errors are eventually dropped, so a 502 that lasts for days becomes a search problem as well.
Why does Cloudflare show a 502 when my server seems fine?
According to Cloudflare's documentation, a Cloudflare-branded 502 page means your origin returned the 502, and a blank page without branding means Cloudflare produced it. Test the origin with the edge out of the way: curl -skI --resolve example.com:443:203.0.113.10 https://example.com/ with your origin IP (-k because a Cloudflare origin certificate fails public checks). For 520, 521 or 522, use the guides in the table above.
Why do 502s come and go?
Intermittent 502s usually come from one unhealthy server in a pool, a worker pool that fills up at peak traffic, keep-alive races, or the restart window of each deploy. Line up the error times with deploys, traffic peaks and the backend's warnings.
Catching a 502 that lasts twenty seconds
Deploy-time and crash-loop 502s are short: a backend restarts, errors for twenty seconds and recovers, and by the time anyone looks there is nothing left to see. A check that runs on a schedule from several locations records each of those windows with a timestamp, so they can be matched against deploys, restarts and the backend's logs. HostTracker runs such checks from 300+ checkpoints in 158 cities and alerts by email, SMS, voice call, Slack, Telegram and more; see distributed availability monitoring.
Sources and further reading
- RFC 9110, HTTP Semantics, section 15.6.3: 502 Bad Gateway
- nginx documentation: ngx_http_proxy_module (timeouts, buffers, retries)
- PHP manual: FPM configuration (socket permissions, pm.max_children, request_terminate_timeout)
- Cloudflare documentation: Error 502 bad gateway or error 504 gateway timeout