Nginx 502 Bad Gateway in Docker: Read the Log, Fix the Cause
Fix nginx 502 Bad Gateway with Docker: read the error log, tell 502 from 503 and 504, check the causes in order of likelihood, and decode Cloudflare's 5xx.
By Matthews WongReviewed on
Key takeaway
A 502 Bad Gateway from nginx means nginx is running but got no valid answer from the app it proxies to: the connection was refused, reset or closed early, or the reply was unusable. With Docker the usual causes are a stopped or restarting container, a wrong port, and an app listening only on its own loopback. nginx's error log names which one.
You will be able to
- Tell 502, 503 and 504 apart, and say which component produced each, including Cloudflare's own 52x codes.
- Read an nginx error-log line and map its message, its while clause and its upstream field to a cause.
- Reproduce and fix the common Docker causes: a stopped container, a wrong port, a loopback-only app and a slow upstream.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
Where a request stops, and what nginx logs
One request through Cloudflare, nginx and a container published on 127.0.0.1:3002, shown healthy and then failing four ways: a stopped container, an app that is not listening, a slow app and nginx itself down. Each failure gives a different status code and error-log line.
Keys: ← → step · Home / End · K play or pause
A visit passes Cloudflare, then nginx on your server, which proxies to the container's port published on 127.0.0.1:3002. Every failure nginx sees goes to its error log.
nginx connects to 127.0.0.1:3002, Docker forwards the connection to the app on port 3000, and the answer travels back. The browser gets 200 and the error log stays quiet.
proxy_pass http://127.0.0.1:3002;Predict the next step Now the container is stopped and the same request arrives. What does the browser get? (502 Bad Gateway · 503 Service Unavailable · 504 Gateway Timeout)With the container stopped, nothing listens on 3002, so the connection is refused at once. nginx answers 502 Bad Gateway and writes the reason to its error log.
connect() failed (111: Connection refused) while connecting to upstreamNow the container runs, but the app listens only on its own 127.0.0.1, or is still starting. Docker accepts on 3002, then the connection is reset: 502 again, different line.
recv() failed (104: Connection reset by peer) while reading response header from upstreamPredict the next step The app is fixed, but one page takes 90 seconds to answer, and nginx keeps its default proxy_read_timeout. What does the browser get? (502 Bad Gateway · 504 Gateway Timeout · 200, after 90 seconds)The app accepts the request but sends nothing for 60 seconds, nginx's default proxy_read_timeout. nginx gives up with 504 Gateway Time-out: a timeout, not a broken connection.
upstream timed out (110: Operation timed out) while reading response header from upstreamBehind Cloudflare, nginx's 502 or 504 arrives on a Cloudflare-branded page. If nginx itself is down, Cloudflare answers 521; if the origin never answers the connection, 522.
521: web server is down 522: connection timed out
What does 502 Bad Gateway mean in nginx?
502 Bad Gateway means the server that answered you is a gateway or proxy, and the server behind it failed. RFC 9110 defines it as a proxy that received an invalid response from an inbound server while trying to fulfil the request. With nginx in front of a Docker app, nginx is that proxy and your container is the inbound server, which nginx calls the upstream.
Read more (1)Show less
So a 502 page that says nginx is good news in one way: nginx is up, it received the request and it built that page itself. The fault sits between nginx and the app. The connection was refused or reset, the app closed it before answering, or its reply was something nginx could not use. The error log says which of these happened, and that decides the fix.
502 vs 503 vs 504: what is the difference?
All three come from a server, but they blame different things. 502 Bad Gateway: the proxy got an invalid response from the server behind it. 504 Gateway Timeout: the proxy did not get a timely response from it. 503 Service Unavailable: the server that answered is itself temporarily overloaded or down for maintenance, and it may send a Retry-After header saying when to try again.
Read more (2)Show less
In nginx, a 504 means a timer ran out. proxy_connect_timeout limits how long nginx waits to open the connection, and proxy_read_timeout how long it waits between two reads of the answer; both default to 60 seconds. The read timer runs between reads, not over the whole response, so a slow answer that keeps sending never trips it. nginx's own page spells it 504 Gateway Time-out.
A 503 is usually not about the connection at all. Either the app answered 503 itself and nginx passed it on, or an nginx rule rejected the request: limit_req answers rejected requests with 503 unless limit_req_status says otherwise. So a 503 sends you to the app's logs or your rate limits, while a 502 or 504 sends you to nginx's error log first.
Where is the nginx error log, and how do you read one line?
The error_log directive decides where errors go and from which level up; its default level, error, logs error, crit, alert and emerg. The official nginx image and Alpine's nginx package both write to /var/log/nginx/error.log, and nginx -T tests and prints your configuration files, so you can find the error_log line that applies to yours. In the official Docker image that file is a link to the container's standard error, so docker logs shows it.
Read more (2)Show less
Every line has the same shape. Here is one from a test of this lesson's setup, nginx on the host and the container stopped: 2026/10/18 02:14:40 [error] 405#405: *3 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:3002/", host: "127.0.0.1:8080".
Read three parts. The message says what failed, with the system error number and its text. The while clause says at which stage: while connecting to upstream means nginx never got a connection, and while reading response header from upstream means it connected but the start of the answer never came, came broken or came too late. The upstream field says exactly where nginx tried to go, which catches a wrong port in seconds.
What causes a 502 with Docker, most likely first?
The container is down, restarting or still starting. With nginx on the host proxying to a loopback port, as on this site, a stopped container leaves nothing on that port: connect() failed (111: Connection refused). A container that runs while its app is not listening yet gets past the port and is then dropped: recv() failed (104: Connection reset by peer) while reading response header from upstream. Check docker ps and the app's own logs first; a crash loop shows up there.
Read more (3)Show less
The port is wrong. If proxy_pass names a port nothing publishes, the connection is refused, and the upstream field shows the port nginx used. If the ports entry points at a container port the app does not use, the published port accepts and then resets, exactly like an app that is not listening. Compare three numbers: the port in proxy_pass, the two in the ports entry, and the one the app prints when it starts.
The app listens only on the container's own 127.0.0.1. Traffic through a published port arrives on the container's network address, not its loopback, as lesson 16 shows, so the app never sees it and nginx logs the same reset. Run netstat -tln inside the container with docker exec, if the image has it: 127.0.0.1:3000 is the bug, 0.0.0.0:3000 is right. This site's Dockerfile rules it out with HOSTNAME=0.0.0.0.
When nginx and the app are both containers on one network, the same three causes read differently, because there is no published port in between. In a test, an app on the wrong port or on its loopback both gave connect() failed (111: Connection refused), and a stopped app gave connect() failed (113: Host is unreachable) after a pause of several seconds.
Which other nginx messages end in a 502?
nginx runs in a container too, and proxy_pass says localhost. Inside nginx's container, localhost is nginx's own container. In a test, nginx tried both addresses localhost resolves to, [::1] and then 127.0.0.1, and logged a refused connection for each. Put both containers on one user-defined network and proxy to the service name and the container port, such as http://web:3000; Docker's embedded DNS resolves the name.
Read more (3)Show less
localhost resolves to two addresses even when it works. The proxy_pass reference says that when a name resolves to several addresses, nginx uses all of them in turn. In a test with an app listening on 127.0.0.1 only, every request still answered 200, yet each one logged a refused connection to [::1] first. proxy_pass http://127.0.0.1:3002 removes both the guesswork and the noise.
nginx keeps the address it looked up. It resolves a name like web when it loads its configuration, which is why a name that does not resolve stops it from starting with host not found in upstream. Docker's Compose docs note that a recreated container receives a new IP address. In a test, nginx kept sending to the old one until nginx -s reload made it look again. The upstream module's resolve parameter, in open-source nginx since 1.27.3, follows such changes without a reload; it needs a resolver directive and a shared-memory zone.
Two more messages appear while reading response header from upstream. upstream prematurely closed connection: the app accepted the request and closed the connection without answering, which is what a crash mid-request looks like. upstream sent too big header: the response headers, cookies included, do not fit proxy_buffer_size, one memory page by default, 4K or 8K depending on the platform. Both are 502s even though the app was reachable.
Cloudflare in front: whose 502 is it?
Cloudflare adds a hop and codes of its own. For 502 and 504, its docs give a simple rule: a Cloudflare-branded error page means the 502 or 504 came from your origin web server, so nginx's error log is the place to look. A blank page without Cloudflare's branding means Cloudflare generated it, for example over a compression problem at the origin.
Read more (2)Show less
The 52x codes are Cloudflare's alone and describe its connection to your origin. 520: the origin returned an empty, unknown or unexpected response. 521: the origin refused the connection, typically because the web server is offline or blocks Cloudflare. 522: no SYN+ACK within 19 seconds, or no ACK of the request within 90. 523: no route to the origin. 524: connected, but no HTTP response within the Proxy Read Timeout, 125 seconds by default.
Those numbers have a consequence. nginx's proxy_read_timeout defaults to 60 seconds, well under Cloudflare's 125, so a slow app normally produces nginx's 504 on a Cloudflare-branded page, not a 524. In this setup you see 524 only when nginx is told to wait longer than Cloudflare does. To take the edge out of the picture, run curl on the server against nginx, then against the app's own port, and see where the error starts.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
ports:
# Bind to loopback only — nginx terminates TLS and proxies to this port.
- "127.0.0.1:${APP_PORT:-3002}:3000"This is the upstream nginx proxies to: host port 3002 on the host's loopback, forwarded to the app's port 3000. When the container is stopped, nothing listens on 3002 and nginx logs a refused connection. When the container runs but nothing answers on 3000 inside it, the published port accepts and then resets, and nginx logs a reset. The two messages tell you which side of this line to fix.
ENV PORT=3000
ENV HOSTNAME=0.0.0.0These two lines rule out the loopback cause. Next.js's standalone server reads PORT and HOSTNAME when it starts, and 0.0.0.0 makes it listen on every address of the container, so traffic forwarded from the published port gets an answer. With HOSTNAME=127.0.0.1 the container would run and its own health check would pass, yet nginx would log a reset on every request.
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://127.0.0.1:3000/robots.txt",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20sDocker runs this check inside the container, so it tests the app on its own loopback, not the path nginx takes. That makes it a useful split: if docker ps shows the container unhealthy, start with the app; if it shows healthy while nginx logs 502s, look between nginx and the container, at the port and the listen address. start_period gives a fresh container 20 seconds before failed checks count; nginx gives no such grace.
Mistakes that cost real time
A few seconds of 502 on every deploy
Recreating the container leaves a gap: the old one is gone and the new app is not listening yet, so nginx logs refused connections, then resets, then nothing. If the 502 lines stop by the time docker ps shows the new container healthy, that gap is the whole story. Closing it takes a second copy of the app to switch to, which is what blue-green deploys do.
Still 502 after the app container came back
With nginx in a container on the same network, nginx resolved the app's name when it loaded its config. A recreated container gets a new address, and nginx keeps sending to the old one: connect() failed with Connection refused or Host is unreachable. Run nginx -s reload in the nginx container so it looks the name up again, or use the upstream resolve parameter so it follows changes by itself.
nginx will not start: host not found in upstream
nginx resolves every name in proxy_pass when it loads its config and exits with [emerg] host not found in upstream if one does not resolve, so docker logs shows the line and the container stops. Check the name against the Compose service name, and check that nginx and the app share a network.
upstream sent too big header, and raising one buffer fails
A response whose headers outgrow proxy_buffer_size gets a 502 even though the app answered. In a test, raising proxy_buffer_size to 16k alone failed nginx -t with "proxy_busy_buffers_size" must be less than the size of all "proxy_buffers" minus one buffer. Adding proxy_buffers 8 16k passed and fixed the 502. Trimming oversized cookies or headers is the better fix.
| Code | Generated by | Means | Look first at |
|---|---|---|---|
| 502 Bad Gateway | nginx (on a Cloudflare-branded page behind Cloudflare) | Invalid or no response from the upstream | nginx's error log, then docker ps |
| 503 Service Unavailable | Usually the app, or nginx's limit_req | Temporarily overloaded or in maintenance | The app's logs and your rate limits |
| 504 Gateway Timeout | nginx (on a Cloudflare-branded page behind Cloudflare) | No timely response from the upstream | The slow endpoint, then proxy_read_timeout |
| 520 | Cloudflare | Empty, unknown or unexpected response from the origin | Origin crashes and nginx's error log |
| 521 | Cloudflare | The origin refused the connection | Is nginx running, and does the firewall allow Cloudflare? |
| 522 | Cloudflare | Timed out connecting to the origin | Firewall, the origin address in DNS, server load |
| 524 | Cloudflare | Connected, but no response within 125 seconds | Long-running requests at the origin |
Do it on your own machine
All you need is Docker on a laptop and one terminal. You will run nginx and a tiny app on one network, break the app three ways, read nginx's error log each time, then make it slow enough for a 504. Give each new container a second to start. The only published port is on 127.0.0.1, and the last step cleans up.
Create a network and a tiny app that listens on the HOST and PORT you give it, port 3000 by default. /slow answers after 10 seconds. APP keeps the code for the steps that recreate it.
docker network create demo502-net APP="require('http').createServer((req, res) => setTimeout(() => res.end('hello\n'), req.url === '/slow' ? 10000 : 0)).listen(process.env.PORT || 3000, process.env.HOST)" docker run -d --name demo502-app --network demo502-net -e HOST=0.0.0.0 node:22-alpine node -e "$APP"Write an nginx config that proxies to the app by name, with a 5-second read timeout so the 504 comes quickly, and start nginx on the same network. curl prints hello.
printf 'server {\n listen 80;\n location / {\n proxy_pass http://demo502-app:3000;\n proxy_read_timeout 5s;\n }\n}\n' > demo502.conf docker run -d --name demo502-nginx --network demo502-net -p 127.0.0.1:8090:80 \ -v "$PWD/demo502.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine curl http://127.0.0.1:8090Break it once: stop the app. After a pause of several seconds, curl gets nginx's own 502 page, and the log says connect() failed (113: Host is unreachable) while connecting to upstream. nginx still uses the address it looked up at start, and nothing answers there now.
docker stop demo502-app curl -i http://127.0.0.1:8090 docker logs demo502-nginx 2>&1 | grep '\[error\]' | tail -1Break it twice: run the app on port 3001 while nginx still proxies to 3000, and reload nginx so it looks the name up again. Now the log says connect() failed (111: Connection refused), and netstat inside the app shows 0.0.0.0:3001.
docker rm -f demo502-app docker run -d --name demo502-app --network demo502-net -e HOST=0.0.0.0 -e PORT=3001 node:22-alpine node -e "$APP" docker exec demo502-nginx nginx -s reload curl -i http://127.0.0.1:8090 docker logs demo502-nginx 2>&1 | grep '\[error\]' | tail -1 docker exec demo502-app netstat -tln | grep 300Break it a third time: the right port, but the app listens only on the container's 127.0.0.1. The log shows the same refused connection, and netstat shows why: 127.0.0.1:3000 instead of 0.0.0.0:3000.
docker rm -f demo502-app docker run -d --name demo502-app --network demo502-net -e HOST=127.0.0.1 node:22-alpine node -e "$APP" docker exec demo502-nginx nginx -s reload curl -i http://127.0.0.1:8090 docker logs demo502-nginx 2>&1 | grep '\[error\]' | tail -1 docker exec demo502-app netstat -tln | grep 300Fix it with HOST=0.0.0.0, and curl prints hello again. Then ask for /slow: after 5 seconds nginx gives up with 504 Gateway Time-out, logged as upstream timed out (110: Operation timed out) while reading response header from upstream.
docker rm -f demo502-app docker run -d --name demo502-app --network demo502-net -e HOST=0.0.0.0 node:22-alpine node -e "$APP" docker exec demo502-nginx nginx -s reload curl http://127.0.0.1:8090 curl -i http://127.0.0.1:8090/slow docker logs demo502-nginx 2>&1 | grep '\[error\]' | tail -1Clean up the containers, the network and the config file.
docker rm -f demo502-nginx demo502-app docker network rm demo502-net rm demo502.conf
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.
Two questions from earlier lessons
Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.
Answer in your head, then check
1.Your browser shows 502 Bad Gateway with nginx at the bottom of the page. Which component failed?Show the answer
Answer:Not nginx: it took the request and built that page. Its upstream, the app behind it, refused, reset or closed the connection, or sent something nginx could not use. The error-log line says which.
2.What is the difference between while connecting to upstream and while reading response header from upstream?Show the answer
Answer:The first means nginx never got a connection: nothing listening, a wrong port or a stale address. The second means it connected, but the start of the answer never came, came broken or came too late.
3.A 502 page carries Cloudflare's branding. Where do you look first?Show the answer
Answer:At your origin. Cloudflare's docs say a branded 502 or 504 came from the origin web server, so read nginx's error log. A blank, unbranded 502 is Cloudflare's own.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- RFC 9110, HTTP Semantics: 502, 503 and 504
- nginx docs: ngx_http_proxy_module (proxy_pass, timeouts, buffers)
- nginx docs: the error_log directive
- nginx docs: ngx_http_upstream_module (the resolve parameter)
- nginx docs: ngx_http_limit_req_module (limit_req_status)
- nginx docs: command-line parameters (-T, -s reload)
- Cloudflare docs: Cloudflare 5xx errors
- Cloudflare docs: Error 502 or 504
- Cloudflare docs: Error 520
- Cloudflare docs: Error 521
- Cloudflare docs: Error 522
- Cloudflare docs: Error 523
- Cloudflare docs: Error 524
- Docker docs: Networking in Compose (service names, new IPs on recreate)
- Docker docs: Networking overview (embedded DNS)
- Docker docs: Port publishing and mapping
- Docker docs: Dockerfile reference, HEALTHCHECK
- Next.js docs: output standalone (PORT and HOSTNAME)