# 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.

URL: https://www.matthewswong.com/en/learn/devops-from-zero/nginx-502-bad-gateway-docker/

DevOps from zero · Lesson 25 of 25 · Reviewed on 18 October 2026 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> 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.

## 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.

1. **Meet the parts.** 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.
2. **A healthy request.** 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:* Now the container is stopped and the same request arrives. What does the browser get? (502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout)
3. **Container stopped: 502.** 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 upstream
   ```
4. **Running, not listening.** Now 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 upstream
   ```

   *Predict:* 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)
5. **Too slow: 504.** 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 upstream
   ```
6. **Cloudflare in front.** Behind 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.

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.

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.

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.

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.

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.

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

`docker-compose.yml:24-26`

```yaml
    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.

`Dockerfile:45-46`

```dockerfile
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
```

These 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.

`docker-compose.yml:29-42`

```yaml
    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: 20s
```

Docker 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.

## What breaks

- **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.

### The 5xx codes you meet with nginx behind Cloudflare: who generates each, what it means, and where to look first. As RFC 9110, nginx and Cloudflare document them.

| 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 |

## Try it yourself

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.

1. 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.

   ```bash
   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"
   ```
2. 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.

   ```bash
   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:8090
   ```
3. Break 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.

   ```bash
   docker stop demo502-app
   curl -i http://127.0.0.1:8090
   docker logs demo502-nginx 2>&1 | grep '\[error\]' | tail -1
   ```
4. Break 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.

   ```bash
   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 300
   ```
5. Break 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.

   ```bash
   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 300
   ```
6. Fix 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.

   ```bash
   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 -1
   ```
7. Clean up the containers, the network and the config file.

   ```bash
   docker rm -f demo502-nginx demo502-app
   docker network rm demo502-net
   rm demo502.conf
   ```

## Quick quiz

1. **nginx on the host logs connect() failed (111: Connection refused) while connecting to upstream, upstream: "http://127.0.0.1:3002/". What is the most likely cause?**
   A. The app takes too long to answer
   B. Nothing listens on 3002: the container is stopped or the port is wrong
   C. The app's response headers are too big
   Answer: B. while connecting to upstream means nginx never got a connection, and Connection refused means nothing listens at that address and port. A slow app gives a timeout, and oversized headers give upstream sent too big header after connecting.

2. **One endpoint takes 90 seconds. nginx keeps its default proxy_read_timeout and sits behind Cloudflare. What does the visitor see?**
   A. A Cloudflare-branded 504 that nginx generated
   B. Cloudflare's 524
   C. A 502 from nginx
   Answer: A. nginx's 60-second read timeout runs out first and nginx answers 504. Cloudflare shows an origin's 504 on a branded page. A 524 needs the origin to stay silent past Cloudflare's 125 seconds.

3. **Inside the app container, netstat -tln shows 127.0.0.1:3000. nginx proxies to the published port and logs Connection reset by peer. What is the fix?**
   A. Raise proxy_read_timeout
   B. Publish the port on 0.0.0.0 instead of 127.0.0.1
   C. Make the app listen on 0.0.0.0 inside the container
   Answer: C. Traffic through the published port arrives on the container's network address, and an app on the container's own loopback never sees it. Opening the host port to every address changes nothing inside the container and exposes the port.

## Think it through

- **Your browser shows 502 Bad Gateway with nginx at the bottom of the page. Which component failed?** 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.
- **What is the difference between while connecting to upstream and while reading response header from upstream?** 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.
- **A 502 page carries Cloudflare's branding. Where do you look first?** 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.

## Go deeper

- [How to Set Up Nginx Reverse Proxy for Docker and Cloudflare](https://www.matthewswong.com/en/blog/cara-setting-nginx-reverse-proxy-docker-cloudflare/)
- [Nginx Load Balancer in Production: Configuration, Health Checks, and Failover](https://www.matthewswong.com/en/blog/load-balancer-production-config/)
- [How to View Docker Logs: Tail, Follow and Compose Guide](https://www.matthewswong.com/en/blog/cara-melihat-docker-logs-untuk-pemula/)

## Sources

- [RFC 9110, HTTP Semantics: 502, 503 and 504](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.6.3)
- [nginx docs: ngx_http_proxy_module (proxy_pass, timeouts, buffers)](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [nginx docs: the error_log directive](https://nginx.org/en/docs/ngx_core_module.html#error_log)
- [nginx docs: ngx_http_upstream_module (the resolve parameter)](https://nginx.org/en/docs/http/ngx_http_upstream_module.html)
- [nginx docs: ngx_http_limit_req_module (limit_req_status)](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)
- [nginx docs: command-line parameters (-T, -s reload)](https://nginx.org/en/docs/switches.html)
- [Cloudflare docs: Cloudflare 5xx errors](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/)
- [Cloudflare docs: Error 502 or 504](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-502-504/)
- [Cloudflare docs: Error 520](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-520/)
- [Cloudflare docs: Error 521](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-521/)
- [Cloudflare docs: Error 522](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-522/)
- [Cloudflare docs: Error 523](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-523/)
- [Cloudflare docs: Error 524](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-524/)
- [Docker docs: Networking in Compose (service names, new IPs on recreate)](https://docs.docker.com/compose/how-tos/networking/)
- [Docker docs: Networking overview (embedded DNS)](https://docs.docker.com/engine/network/)
- [Docker docs: Port publishing and mapping](https://docs.docker.com/engine/network/port-publishing/)
- [Docker docs: Dockerfile reference, HEALTHCHECK](https://docs.docker.com/reference/dockerfile/#healthcheck)
- [Next.js docs: output standalone (PORT and HOSTNAME)](https://nextjs.org/docs/app/api-reference/config/next-config-js/output)

---

- HTML version: https://www.matthewswong.com/en/learn/devops-from-zero/nginx-502-bad-gateway-docker/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/devops-from-zero/nginx-502-bad-gateway-docker/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
