Why does Caddy reverse_proxy return 502 with dial tcp ... connect: connection refused?
Caddy logs the Go dial error word for word, and each wording has its own cause. Tested on Caddy 2.11.7: refused, no such host, i/o timeout, EOF, TLS, unix sockets.
Key points
- Caddy writes the Go dial error into the
msgfield of itshttp.log.errorline, and the wording names the cause.connect: connection refusedmeans the address answered but nothing listens on that port.no such hostmeans the name did not resolve.i/o timeoutmeans no answer at all. - In Docker,
connection refusedhas three usual causes: the published host port instead of the container port, an app bound to127.0.0.1inside its own container, orlocalhostin the Caddyfile, which is Caddy's own container. A container that is being recreated givesno such hostinstead, because its name is missing from Docker's DNS. - Every case tested on Caddy 2.11.7 returned 502, including the dial timeout, which gave up at the 3s
dial_timeoutdefault. The one exception is plainhttpto an HTTPS-only upstream: the upstream's own 400 passes through, and Caddy logs nothing. handle_errorscatches every one of these 502s. It also moves the error line down to DEBUG level, so on the default INFO level the cause vanishes from the log. Turn ondebugwhile you diagnose.- Retries are off by default (
lb_try_duration0). Withlb_try_duration 10s, a request made while the backend was down got 200 once it came back up, 3.6 seconds later.
Caddy returns 502 with dial tcp ... connect: connection refused when it reached the upstream's address and nothing was listening on that port. The machine is right, or at least reachable; the port, or the address the app listens on, is wrong. Caddy copies Go's network error into its log word for word, so the rest of the line tells you which case you have. In Docker the usual three are a host port where the container port belongs, an app bound to 127.0.0.1 in its own container, and localhost in the Caddyfile. The other wordings, no such host, i/o timeout, EOF and the TLS messages, each point somewhere else:
msg in Caddy's error log | What happened | Usual cause | Status | handle_errors fires |
|---|---|---|---|---|
dial tcp 10.201.13.2:9999: connect: connection refused | Address reachable, nothing listening on the port | Published host port used instead of the container port; app not running | 502 | Yes |
dial tcp 10.201.13.3:8000: connect: connection refused | The other container refused | App bound to 127.0.0.1 inside its own container | 502 | Yes |
dial tcp [::1]:8000: connect: connection refused | Caddy dialled its own loopback | localhost or 127.0.0.1 in the Caddyfile, Caddy in a container | 502 | Yes |
dial tcp: lookup api2 on 127.0.0.11:53: no such host | Docker's DNS has no such name | Not on the same Docker network, or the container is being recreated | 502 | Yes |
dial tcp 10.201.13.4:8000: i/o timeout | No reply within dial_timeout (3s) | Firewall drops the packets | 502 | Yes |
EOF | Connected, then closed with no response | Upstream crashed or closed on purpose | 502 | Yes |
read tcp A->B: read: connection reset by peer | Connected, then reset | Upstream reset; a stale keep-alive connection, on a POST | 502 | Yes |
tls: first record does not look like a TLS handshake | Caddy spoke TLS, the upstream did not | https:// in front of a plain HTTP upstream | 502 | Yes |
tls: failed to verify certificate: x509: certificate signed by unknown authority | TLS fine, certificate not trusted | Private CA or self-signed upstream | 502 | Yes |
| none | The upstream answered 400 | Plain http to an HTTPS-only upstream | 400 | No |
dial unix /sock/app.sock: connect: permission denied | Socket exists, Caddy's user may not use it | Socket file mode or owner | 502 | Yes |
dial unix /sock/missing.sock: connect: no such file or directory | No socket at that path | Wrong path, app not started, or volume not shared | 502 | Yes |
Each row was produced on Caddy 2.11.7, and each is explained below.
What a dial error is, and where Caddy logs it#
To dial is Go's word for opening a network connection. When a request arrives, Caddy's reverse_proxy picks an upstream, the backend server it forwards to, and dials it. If the dial fails, nothing has been sent yet. If the dial works, Caddy writes the request and waits for the response. A failure there is a round-trip error, not a dial error. The difference matters for retries, as shown below.
Go reports a failed network call as an OpError. Its fields are the operation, such as dial or read, the network, such as tcp or unix, the addresses, and the underlying error. The Error() method in Go's net.go joins them in that order: dial, a space, tcp, a space, the address, a colon, then the reason. That is why every line in the table looks alike. The last part is the one to read. connect: connection refused is the operating system's answer. lookup api2 on 127.0.0.11:53: no such host is a DNSError, which Go formats as "lookup {name} on {server}: {error}". i/o timeout is the text of Go's own timeout error.
Caddy logs the failure as one JSON line from the logger http.log.error, at level error:
{"level":"error","logger":"http.log.error","msg":"dial tcp 10.201.13.2:9999: connect: connection refused",
"duration":0.001016478,"status":502,"err_id":"pucy24dqw","err_trace":"reverseproxy.statusError (reverseproxy.go:1695)"}The request object is cut from that line. status is what the client got. duration shows how long Caddy waited, which separates a refusal (a millisecond) from a timeout (three seconds). The log directive's reference says logs go to stderr by default, in json format when stderr is not a terminal. In a container that means docker logs caddy. You do not need a log directive to get this line. That directive turns on access logs, which record the status but not the reason.
How the cases were reproduced#
Everything below was observed on 3 November 2026 unless it is attributed to documentation. Caddy 2.11.7 ran from the official caddy:2 image on Docker Engine 28.1.1, on a Linux host, as a non-root user. It sat on a user-defined Docker network with its backends. Each case had its own site block with one reverse_proxy line. Backends were Python 3.13.17 http.server containers, a small Python socket server that could close, reset or misbehave on purpose, and a second Caddy serving HTTPS with tls internal. The firewall case used iptables -A INPUT -p tcp --dport 8000 -j DROP inside the backend container. Requests came from curl 8.23.0 in a third container. Docker Desktop was not tested.
connection refused: right machine, wrong port or wrong bind#
connection refused means a machine answered at that address and said no one listens on that port. On Linux it is immediate: about one millisecond in every test. Three Docker mistakes produce it, and the log line tells them apart by the address it names.
The published port instead of the container port. With reverse_proxy app:9999 and the app listening on 8000, the line was dial tcp 10.201.13.2:9999: connect: connection refused. The address is the app container's, so the name resolved. Only the port is wrong. This is the mistake in the Caddy Community thread that tops the search results for this error. The poster proxied to https://test-php-nginx-web-server-1:9999, a port published on the host. The fix given there was reverse_proxy http://test-php-nginx-web-server-1:80, the port inside the container. A ports: mapping such as 9999:80 only exists on the host's interfaces. Containers on a shared network talk to each other on the container port. The Nginx Proxy Manager 502 guide shows the same mistake from nginx, which logs error 111 for it.
An app bound to 127.0.0.1 in its own container. With the app started as --bind 127.0.0.1, the line was dial tcp 10.201.13.3:8000: connect: connection refused. That is the right container and the right port. But the app only listens on that container's loopback, so the container's network address refuses. Make the app listen on 0.0.0.0.
localhost in the Caddyfile. With reverse_proxy localhost:8000 the line was dial tcp [::1]:8000: connect: connection refused. Inside a container, localhost is that container, so Caddy dialled itself. The [::1] is the IPv6 loopback address. The log names only that one address, even though localhost also means 127.0.0.1. Why localhost is the wrong machine in a container, and what to write for a backend on the host, is set out in nginx in Docker and proxy_pass localhost. Every rule there holds for Caddy: use the service name for another container, and host.docker.internal with host-gateway for the host.
Outside Docker, the same wording means the app is down, still starting, or on another port. Check what is really listening with ss -ltnp on the backend's machine.
no such host: the name, not the port#
dial tcp: lookup api2 on 127.0.0.11:53: no such host came from reverse_proxy api2:8000 when api2 sat on a different Docker network. The line names no address, because Caddy never got one. 127.0.0.11 is Docker's embedded DNS server. Service names resolve only between containers that share a user-defined network. Put Caddy and the app on one network, or attach Caddy to both.
A second cause looks like the first. A container that does not exist at that moment has no DNS record. With the backend container removed, reverse_proxy late:8000 logged dial tcp: lookup late on 127.0.0.11:53: no such host. It did not log connection refused. So while docker compose up replaces the app, Caddy can log no such host for a few seconds, even though the network setup is right. Caddy looked the name up again on later dials. In the retry test below, it found the container as soon as it was created. nginx can behave differently here, as nginx 502 after a container restart shows.
i/o timeout: nothing answered at all#
With a firewall dropping packets to port 8000, the line was dial tcp 10.201.13.4:8000: i/o timeout and the request took 3.00 seconds. The reverse_proxy reference gives dial_timeout as "The maximum duration to wait when connecting to the upstream socket. Default: 3s." Setting dial_timeout 1s cut the wait to 1.00 second, with the same message.
The status was 502, not 504. That surprises people who come from nginx, where a connect timeout is a 504. In Caddy 2.11.7 the dial timeout is a 502 like every other dial failure. A 502 that always takes exactly three seconds is a dropped connection: a firewall, a security group, or an address with nothing behind it. It is not a slow app. A slow app has already connected, and Caddy waits for it with response_header_timeout, which has no default. The general reading of these codes is in 502 vs 503 vs 504.
EOF and connection reset: the upstream hung up#
These are not dial errors. The connection opened, then broke.
EOF, alone, came from an upstream that read the request and closed the connection without writing a byte. Go's word for "the other end closed" is EOF, short for end of file. Look in the app's own log: it crashed, it hit a limit, or it is not an HTTP server.
read tcp 10.201.13.9:46616->10.201.13.5:9002: read: connection reset by peer came from an upstream that read the request and reset the connection. A reset is an abrupt close: the other side sent a TCP RST packet instead of a normal goodbye. The two addresses are Caddy's side and the upstream's side.
A reset also shows the most common intermittent 502, the keep-alive race. Caddy keeps upstream connections open for reuse; the reverse_proxy reference gives keepalive a default of 2m. If the upstream closes an idle connection just as Caddy sends a new request on it, the request hits a dead connection. To reproduce it, a test upstream answered one request, kept the connection open, and reset it when the next request arrived. The results split by method:
- GET: three requests, three 200s. Go retried the second GET on a fresh connection, and the client never saw the reset.
- POST with a body: 200, then 502 with
read: connection reset by peer, then 200.
Go's transport.go gives the rule. It retries on a reused connection only when "the request is idempotent and either has no body or has its Request.GetBody defined". Idempotent means safe to repeat: GET, HEAD, OPTIONS and a few others. So a 502 that hits only POSTs, now and then, is usually this race. The fix is on the upstream: keep its idle timeout longer than Caddy's. Keep-alive and upstream connection pooling works through the timer order.
TLS mismatches: four wordings, one of them not a 502#
A scheme in the upstream address decides whether Caddy speaks TLS. The reference says https:// "will use the http transport with tls enabled". The four mistakes:
https://to a plain HTTP upstream.reverse_proxy https://app:8000loggedtls: first record does not look like a TLS handshake, 502. Caddy sent a TLS hello and got back bytes that are not TLS. The same message came from a plain Caddy upstream and from a server that wroteHTTP/1.1 400 Bad Requeststraight back. The wordinghttp: server gave HTTP response to HTTPS clientdid not appear in any test on 2.11.7. Fix: writehttp://, or drop the scheme.https://to a non-HTTP service. A port that answers with an SSH banner gave the samefirst recordmessage. Check that the port is the web port.https://to an upstream with a private certificate.tls: failed to verify certificate: x509: certificate signed by unknown authority, 502. The connection worked and the certificate was not trusted. Give Caddy the CA withtls_trusted_ca_certsin atransport httpblock, as the Caddy reverse proxy guide shows.- Plain
httpto an HTTPS-only upstream.reverse_proxy tlsapp:8443, where the upstream was a Caddy serving only HTTPS, gave the client 400 with the bodyClient sent an HTTP request to an HTTPS server.That is the upstream's answer, passed through. Caddy logged no error, andhandle_errorsdid not fire. An nginx upstream answers the same mistake with400 The plain HTTP request was sent to HTTPS port, explained in the plain HTTP request was sent to HTTPS port. Fix: addhttps://to the upstream address.
Inside one Docker network, plain HTTP between Caddy and the app is usually enough. That was the advice in the Community thread, too.
Unix sockets: permission denied and no such file#
The reference writes a Unix socket upstream as unix//var/php.sock. Two errors came up.
dial unix /sock/app.sock: connect: permission denied. The socket was owned by uid 1000 with mode 0600, and Caddy ran as uid 2000, so Caddy's user had no access to the file. Fix it in the app that creates the socket. Make it group-writable for a group Caddy's user is in, or run both as the same user. A chmod by hand is lost when the app makes a new socket at its next start.
dial unix /sock/missing.sock: connect: no such file or directory. Nothing exists at that path. Either the path is wrong, the app has not created it yet, or, in Docker, the directory is not a volume both containers mount. A socket made inside the app's container is not visible in Caddy's container unless the directory is shared.
handle_errors hides the reason, but not the 502#
Every 502 above triggered handle_errors. With a block that answered handled {err.status_code}, each case returned that body with status 502, including the timeout. This matches the custom error page guide, which found it fires for an unreachable, closed or timed-out upstream. The only case it skipped was the 400 from the HTTPS-only upstream. The handle_errors reference explains why: reverse_proxy "may write a response with an HTTP status which is classified as an error", and such a response "will not trigger the error routes". An upstream's own error is a response. A failed dial is not.
What surprised was the log. With handle_errors in the site block, the http.log.error line was still written, but at level debug instead of error. The global options reference gives the default log level as INFO, so on a normal setup the line never appears. A Caddy with a custom error page and a broken upstream therefore shows the page and logs no reason. An access log, turned on with log, recorded status: 502 at level error, still without the message. To see the cause, add the debug global option for the length of the test:
{
debug
}That brings back http.log.error with the full msg. It also adds an upstream roundtrip line from http.handlers.reverse_proxy with the same text in its error field. The reference warns that debug mode makes logging "very verbose in production", so remove it once you know the cause.
Retries are off until you turn them on#
Caddy does not retry a failed dial by default. The reverse_proxy reference states it twice: for lb_try_duration, "By default, retries are disabled (zero duration)", and for lb_retries, "By default, retries are disabled (zero)". So with one upstream, a dial failure is a 502 at once. In the test that took 0.03 seconds.
With lb_try_duration 10s, Caddy keeps trying for up to ten seconds, every lb_try_interval, which the reference gives as 250ms by default. A request sent while the backend was missing waited. The backend was started two seconds later, and the request got 200 after 3.56 seconds in all. With the backend gone for good, the same setting returned 502 after 10.15 seconds. A short lb_try_duration therefore covers a deploy or a restart, where the app is gone for a few seconds. It does nothing for a wrong port, except make the 502 slower.
The method does not limit this kind of retry. The reference says lb_retry_match applies "if the connection to the upstream succeeded but the subsequent round-trip failed. If the connection to the upstream failed, a retry is always allowed." A POST that could not connect is retried. A POST whose connection was reset after it was sent is not, unless lb_retry_match allows it, and by default "only GET requests are retried".
Passive health checks are also off by default, since fail_duration defaults to zero. A dead upstream is not marked down after its failures, and each new request dials it again. That is right for one upstream. For several, see health checks and failover.
Worked case: one 502 traced from its log line#
Take a Compose file with two services, caddy and web. The web service has ports: ["8080:3000"], and the Caddyfile says reverse_proxy web:8080. Every page returns 502. Walk it through.
- Run
docker compose logs caddyand find thehttp.log.errorline. Suppose it readsdial tcp 172.19.0.3:8080: connect: connection refused. - The line has an address, so
webresolved. The network is fine, and this is notno such host. - It says
connection refused, noti/o timeout, so a machine answered. No firewall is involved. - So nothing listens on port 8080 at
web's address. The mapping8080:3000says the app listens on 3000 inside its container. Port 8080 only exists on the host. - Change the line to
reverse_proxy web:3000and reload Caddy.
If the line had instead read dial tcp 172.19.0.3:3000: connect: connection refused, step 4 changes. The port is right, so the app is up but bound to 127.0.0.1, or not up at all. Run docker compose logs web and look for the address it says it listens on.
If no http.log.error line appears at all, look for a handle_errors block before anything else. Then add debug and repeat the request.
Where this answer stops applying#
The wordings and statuses here are Caddy 2.11.7 on Linux, with Docker Engine 28.1.1 and the caddy:2 image. Other 2.x releases may word or classify an error differently, so read your own log line rather than trusting the table blindly. Docker Desktop on macOS and Windows routes container traffic differently and was not tested. Each upstream here was HTTP/1.1. The h2c transport, the fastcgi transport for PHP-FPM, and gRPC upstreams can fail with other messages. With several upstreams, health checks and load balancing change which upstream is dialled and when a 503 replaces the 502. A CDN or a second proxy in front of Caddy has its own error pages and may hide Caddy's status. Last, the keep-alive race was forced with a test server that reset on cue. On a real app it is timing-dependent, so one clean test run does not prove it is gone.
Frequently asked questions#
What does dial tcp connect: connection refused mean in Caddy?#
Caddy reached the upstream's address, and nothing was listening on that port. It is not a DNS or firewall problem. In Docker, check three things: you used the container port, not the published host port; the app listens on 0.0.0.0, not 127.0.0.1; and the Caddyfile does not say localhost.
Why does reverse_proxy localhost fail when Caddy runs in Docker?#
Inside a container, localhost is the container itself. Caddy dials its own loopback and logs dial tcp [::1]:8000: connect: connection refused. Use the backend's service name on a shared Docker network, or host.docker.internal with host-gateway for a service on the host.
What does dial tcp: lookup ... no such host mean?#
Docker's DNS at 127.0.0.11 has no record for that name. Either Caddy and the backend are on different networks, or the backend container does not exist at that moment. While Compose recreates a container its name is missing from DNS, so a deploy can give no such host for a few seconds.
Why does Caddy return 502 and not 504 for a timeout?#
In Caddy 2.11.7, a dial timeout returned 502 with dial tcp ...: i/o timeout after the 3s dial_timeout default. That timeout means packets were dropped, usually by a firewall. A slow app that has connected is a different timer, response_header_timeout, which has no default.
Why is there no error in the Caddy log when I get a 502?#
Check for a handle_errors block. With one in place, Caddy 2.11.7 logged the http.log.error line at debug level, which the default INFO level hides. Add the debug global option, repeat the request, and the full message appears.
Does Caddy retry a failed upstream connection?#
Not by default: lb_try_duration and lb_retries are both zero. Set lb_try_duration 5s to ride out a restart. Requests that failed to connect are then retried whatever their method. Requests that failed after sending are retried only if lb_retry_match allows it, which by default means GET only.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- Caddy documentation, reverse_proxy directive (dial_timeout, lb_try_duration, lb_retry_match, keepalive, unix sockets)
- Caddy documentation, global options (log, default level INFO, debug)
- Caddy documentation, log directive (default output stderr, format)
- Caddy documentation, handle_errors directive
- Go standard library, package net (OpError, DNSError, i/o timeout)
- Go source, src/net/net.go (OpError.Error string format)
- Go source, src/net/http/transport.go (retry on a reused connection)
- Caddy Community, "Caddy causes HTTP ERROR 502 connect: connection refused"
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.
More in troubleshooting proxies#
- ERR_TUNNEL_CONNECTION_FAILED: what the proxy answered
- Cntlm vs Px: which one for an NTLM or Kerberos proxy?
- Corporate proxies and developer tooling
- Access-Control-Allow-Origin multiple values: the fix
- curl through a proxy
- Header and body size limits at the proxy
- HTTPS_PROXY: http:// or https://? Tested in 12 clients
- netsh winhttp vs Windows proxy settings: who reads which?
- nginx 413 Request Entity Too Large: find the limit
- nginx 499: what it means, and its ALB, HAProxy, Envoy names
- nginx 502 after Docker restart: fix the stale IP
- nginx drops headers with underscores: why, and the fix
- nginx 400 plain HTTP request sent to HTTPS port: fix
- nginx proxy_cache always MISS: every cause, tested
- nginx connect() failed to [::1] with localhost: the fix
- nginx (13: Permission denied) to upstream: SELinux fix
- nginx SSL handshaking to upstream errors: fix each one
- nginx cuts off large downloads: find which of 5 causes
- nginx upstream sent no valid HTTP/1.0 header: the fix
- nginx worker_connections: the right number for a proxy
- The no_proxy environment variable
- POST becomes GET after a redirect: proxies and clients
- A proxy debugging playbook
- Squid TCP_DENIED/403 on every request: the http_access fix
- Traefik upload fails after 60 seconds: the readTimeout fix