Troubleshooting

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.

· 17 min read · How we verify this

Key points

  • Caddy writes the Go dial error into the msg field of its http.log.error line, and the wording names the cause. connect: connection refused means the address answered but nothing listens on that port. no such host means the name did not resolve. i/o timeout means no answer at all.
  • In Docker, connection refused has three usual causes: the published host port instead of the container port, an app bound to 127.0.0.1 inside its own container, or localhost in the Caddyfile, which is Caddy's own container. A container that is being recreated gives no such host instead, 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_timeout default. The one exception is plain http to an HTTPS-only upstream: the upstream's own 400 passes through, and Caddy logs nothing.
  • handle_errors catches 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 on debug while you diagnose.
  • Retries are off by default (lb_try_duration 0). With lb_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 logWhat happenedUsual causeStatushandle_errors fires
dial tcp 10.201.13.2:9999: connect: connection refusedAddress reachable, nothing listening on the portPublished host port used instead of the container port; app not running502Yes
dial tcp 10.201.13.3:8000: connect: connection refusedThe other container refusedApp bound to 127.0.0.1 inside its own container502Yes
dial tcp [::1]:8000: connect: connection refusedCaddy dialled its own loopbacklocalhost or 127.0.0.1 in the Caddyfile, Caddy in a container502Yes
dial tcp: lookup api2 on 127.0.0.11:53: no such hostDocker's DNS has no such nameNot on the same Docker network, or the container is being recreated502Yes
dial tcp 10.201.13.4:8000: i/o timeoutNo reply within dial_timeout (3s)Firewall drops the packets502Yes
EOFConnected, then closed with no responseUpstream crashed or closed on purpose502Yes
read tcp A->B: read: connection reset by peerConnected, then resetUpstream reset; a stale keep-alive connection, on a POST502Yes
tls: first record does not look like a TLS handshakeCaddy spoke TLS, the upstream did nothttps:// in front of a plain HTTP upstream502Yes
tls: failed to verify certificate: x509: certificate signed by unknown authorityTLS fine, certificate not trustedPrivate CA or self-signed upstream502Yes
noneThe upstream answered 400Plain http to an HTTPS-only upstream400No
dial unix /sock/app.sock: connect: permission deniedSocket exists, Caddy's user may not use itSocket file mode or owner502Yes
dial unix /sock/missing.sock: connect: no such file or directoryNo socket at that pathWrong path, app not started, or volume not shared502Yes

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:

text
{"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:8000 logged tls: 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 wrote HTTP/1.1 400 Bad Request straight back. The wording http: server gave HTTP response to HTTPS client did not appear in any test on 2.11.7. Fix: write http://, or drop the scheme.
  • https:// to a non-HTTP service. A port that answers with an SSH banner gave the same first record message. 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 with tls_trusted_ca_certs in a transport http block, as the Caddy reverse proxy guide shows.
  • Plain http to an HTTPS-only upstream. reverse_proxy tlsapp:8443, where the upstream was a Caddy serving only HTTPS, gave the client 400 with the body Client sent an HTTP request to an HTTPS server. That is the upstream's answer, passed through. Caddy logged no error, and handle_errors did not fire. An nginx upstream answers the same mistake with 400 The plain HTTP request was sent to HTTPS port, explained in the plain HTTP request was sent to HTTPS port. Fix: add https:// 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:

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

  1. Run docker compose logs caddy and find the http.log.error line. Suppose it reads dial tcp 172.19.0.3:8080: connect: connection refused.
  2. The line has an address, so web resolved. The network is fine, and this is not no such host.
  3. It says connection refused, not i/o timeout, so a machine answered. No firewall is involved.
  4. So nothing listens on port 8080 at web's address. The mapping 8080:3000 says the app listens on 3000 inside its container. Port 8080 only exists on the host.
  5. Change the line to reverse_proxy web:3000 and 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.

  1. Caddy documentation, reverse_proxy directive (dial_timeout, lb_try_duration, lb_retry_match, keepalive, unix sockets)
  2. Caddy documentation, global options (log, default level INFO, debug)
  3. Caddy documentation, log directive (default output stderr, format)
  4. Caddy documentation, handle_errors directive
  5. Go standard library, package net (OpError, DNSError, i/o timeout)
  6. Go source, src/net/net.go (OpError.Error string format)
  7. Go source, src/net/http/transport.go (retry on a reused connection)
  8. 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#