Tech Digest

Guide

Why does Cloudflare Tunnel return 502 Bad Gateway with cloudflared in Docker?

A 502 from Cloudflare Tunnel means the tunnel is up and cloudflared could not reach the service URL in your route. We ran cloudflared 2026.10.7 in Docker behind real quick tunnels and broke the route seventeen ways. Every cause printed the same first sentence and a different ending.

Last reviewed

Why does Cloudflare Tunnel return 502 Bad Gateway when cloudflared runs in Docker?

Because cloudflared reached Cloudflare but not your app. In a container, localhost is the cloudflared container itself, so a route to http://localhost:PORT is refused. Use the app's container name on a shared Docker network and the container's own port. Read docker logs cloudflared: connection refused is the wrong address or port, no such host is a name not on cloudflared's networks, i/o timeout is blocked traffic, and x509 or tls: lines are a scheme or certificate mismatch.

Because the tunnel reached Cloudflare and cloudflared could not reach the service URL in your route. When cloudflared runs in Docker Engine, the usual reason is localhost. Inside a container, localhost is that container, so a route to http://localhost:3001 asks cloudflared itself, and nothing there listens on 3001. Read the log:

bash
docker logs --tail 20 cloudflared 2>&1 | grep ERR

Every failure starts with the same sentence: Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared:. The cause is the text after that colon.

The terms this answer turns on#

Cloudflare Tunnel lets a server publish a website without opening a port. A small program, cloudflared, runs on your side and dials out to Cloudflare. Visitors reach Cloudflare, and Cloudflare sends each request down that connection.

A route (an ingress rule in a config file) maps a public hostname to a service URL, such as http://uptime-kuma:3001. cloudflared connects to that URL for each request. Cloudflare's docs call the thing behind it the origin service.

502 Bad Gateway is the status a proxy sends when it could not get an answer from the server behind it. Cloudflare's troubleshooting page says that for a tunnel, a 502 means "the tunnel itself is connected to the Cloudflare network, but cloudflared cannot reach the origin service defined in your ingress rule." So the break is always on your machine, between cloudflared and the app.

A quick tunnel is a temporary tunnel on a random trycloudflare.com address. It needs no account. Cloudflare says quick tunnels are for testing and development. That is what our lab used.

Two answers circulate, and the log settles which is yours#

The first answer is "change localhost to the container name." It is right for the most common setup: cloudflared in one container, the app in another. It is wrong when cloudflared runs as a service on the host itself, where localhost is the server. That is where Cloudflare's own examples come from, which is why http://localhost:8080 appears everywhere.

The second answer is "turn on No TLS Verify." It fixes exactly one row of the table below, the certificate rows. For a refused connection or an unknown name it does nothing, because TLS never starts.

Neither answer has to be guessed. The text after the colon tells you which layer failed before you change anything.

What we measured#

The lab ran the cloudflare/cloudflared:2026.10.7 image, the latest release, published 30 October 2026. It ran on Docker Engine 29.9.2 inside a Docker-in-Docker host. Each case got its own quick tunnel. We sent one HTTPS request to the tunnel's address from outside and read cloudflared's log. Small Python, nginx 1.30 and Caddy 2.11 servers were the apps. "Host service" means a server on the Docker host itself, outside any container.

Service URLVisitor gotLog text after the colon
http://localhost:8090, host service502dial tcp [::1]:8090: connect: connection refused
http://127.0.0.1:8090, host service502dial tcp 127.0.0.1:8090: connect: connection refused
http://host.docker.internal:8090, no extra_hosts502dial tcp: lookup host.docker.internal on 127.0.0.11:53: no such host
http://app2:8080, app on another network502dial tcp: lookup app2 on 127.0.0.11:53: no such host
http://app:8080, app container stopped502dial tcp: lookup app on 127.0.0.11:53: no such host
http://app:9999, wrong port502dial tcp 172.18.0.2:9999: connect: connection refused
App listens on 127.0.0.1 inside its container502dial tcp 172.18.0.3:8080: connect: connection refused
Gateway 172.18.0.1:8091, host service on 127.0.0.1502dial tcp 172.18.0.1:8091: connect: connection refused
Gateway 172.18.0.1:8090, host firewall drops the port502 at 30.3 sdial tcp 172.18.0.1:8090: i/o timeout
IP of a container on another network502 at 30.3 sdial tcp 172.19.0.2:8080: i/o timeout
https:// to a plain HTTP port502tls: first record does not look like a TLS handshake
https://tlsapp:443, certificate for example.com502tls: failed to verify certificate: x509: certificate is valid for example.com, not tlsapp
http:// to a Python TLS port502read tcp ...->172.18.0.5:8443: read: connection reset by peer
http:// to nginx's TLS port400Nothing logged
http:// to Caddy's TLS port400Nothing logged
App answers after 130 s524 at 125.3 sIncoming request ended abruptly: context canceled
cloudflared stopped530, page says 1033Nothing, it is not running

Rows without a time took 0.26 to 0.46 seconds at our client. Each failed request wrote two ERR lines with the same text. One ends in originService=, the URL from the route. The other starts Request failed and names the public hostname. Both show at cloudflared's default log level.

These routes all returned 200: the gateway address to a host service listening on 0.0.0.0, host.docker.internal with extra_hosts, cloudflared with network_mode: host and localhost, and the TLS backend once the certificate was trusted.

A worked case: Uptime Kuma behind a dashboard route#

Take a server where Uptime Kuma runs in its own compose project with ports: "3001:3001". cloudflared runs in a second project. The route in the Cloudflare dashboard says http://localhost:3001, copied from a guide. The status page shows 502.

Start with the log. Suppose it ends dial tcp [::1]:3001: connect: connection refused. That is the first row. cloudflared asked its own loopback. The [::1] is IPv6 loopback, the address the log gave for localhost. It does not mean IPv6 is the problem.

The clean fix is a shared network. Create one with docker network create tunnel. Add it to both compose files as an external network, then change the route to http://uptime-kuma:3001. The port is the container's port, which here happens to match the published one. The published port is no longer needed for the tunnel at all.

Suppose you try http://host.docker.internal:3001 instead. Without extra_hosts, that gives the no such host row, because Docker Engine on Linux does not define the name. Add extra_hosts: ["host.docker.internal:host-gateway"] to cloudflared. The request now goes to the host's bridge address. It works only if the port is published on 0.0.0.0 and the host firewall lets Docker's subnets in. If the firewall drops it, the log changes to i/o timeout and the 502 arrives after 30 seconds.

Refused, no such host, timeout: what each one means#

connection refused comes back fast because something answered: the target machine, saying no program listens on that port. The address is right. The port is wrong, the app is not up yet, or the app listens only on its own loopback. For localhost and 127.0.0.1, that machine is the cloudflared container. Check the app's port inside its container, not the port in its ports: mapping.

no such host means Docker's DNS server at 127.0.0.11 has no record for the name. Docker's bridge docs say user-defined networks give "automatic DNS resolution between containers" on that network. The default bridge gives none. So the name is on another network, misspelled, or belongs to a stopped container. A stopped container's name leaves Docker DNS. How that server works is in Why a Docker container cannot resolve DNS.

i/o timeout means packets went out and nothing came back. Docker's docs say containers on different bridge networks "can only communicate with each other using published ports". Its rules drop the rest, and cloudflared waits out its connectTimeout, 30 seconds by default. The same timeout appears when a host firewall drops traffic from containers. Reaching a host service from a container covers that firewall layer and the host-gateway address.

Do not enter container IP addresses. After we stopped and started one container, its address moved from 172.18.0.2 to 172.18.0.27. Names follow the container. Addresses do not.

TLS lines: the scheme or the certificate#

tls: first record does not look like a TLS handshake means the URL says https:// and the app speaks plain HTTP. Change the scheme to http://. Most apps behind a tunnel should be plain HTTP inside. The visitor's HTTPS ends at Cloudflare.

The reverse mistake, http:// to a TLS port, depends on the app. Cloudflare's docs show a malformed HTTP response line for it. We did not get that line. nginx and Caddy sent their own 400 pages ("The plain HTTP request was sent to HTTPS port" and "Client sent an HTTP request to an HTTPS server.") and the tunnel passed them through. A Python TLS server reset the connection and gave a 502. If the browser shows a 400 with the app's own wording, switch the scheme to https://.

x509: lines mean cloudflared opened TLS and did not accept the certificate. This is where cloudflared differs from Nginx Proxy Manager, which does not check the backend at all. cloudflared checks by default, and it reports one problem at a time:

  1. certificate is valid for example.com, not tlsapp: the name in the URL is not on the certificate. Set originServerName to a name that is. Cloudflare says cloudflared otherwise uses the hostname from the service URL.
  2. certificate signed by unknown authority: the name now matches, but cloudflared does not trust whoever signed it. Point caPool at that CA's PEM file.
  3. Both set: 200.

In a config file, both go under the rule's originRequest:

yaml
ingress:
  - hostname: app.example.com
    service: https://tlsapp:443
    originRequest:
      originServerName: example.com
      caPool: /etc/cloudflared/ca.pem
  - service: http_status:404

noTLSVerify: true also gave 200, and Cloudflare calls it "a temporary last resort". The troubleshooting page adds that the --no-tls-verify and --origin-ca-pool flags apply only to a single origin set with --url. With ingress rules, the settings go under originRequest.

502, 524 or 1033: which status points where#

The status alone narrows it. A 502 is between cloudflared and the app. That includes connections that time out, which on Nginx Proxy Manager would be a 504. We never saw a 504 from the tunnel.

A 524 means the app accepted the request and did not answer in time. Cloudflare's 524 page gives the limit as 125 seconds. Our app that took 130 got the 524 at 125.3 s, and cloudflared logged context canceled when Cloudflare gave up.

A 1033, served with HTTP status 530, means no cloudflared is connected. When we stopped the container, its quick tunnel address returned 530 within 0.05 s. Cloudflare's page adds a dashboard case: a tunnel listed as Active with a DNS record that points at the wrong tunnel also gives 1033. Restarting the app will not fix a 1033. Check that cloudflared is running and connected.

The one-command test#

The cloudflared image has no shell, so docker exec cloudflared curl fails with executable file not found. Run a test container inside cloudflared's network instead:

bash
docker run --rm --network container:cloudflared alpine \
  wget -q -T 5 -O /dev/null http://uptime-kuma:3001

It shares cloudflared's localhost and DNS, so it fails the same way. Silence means it worked. wget: can't connect to remote host: Connection refused matches connection refused. wget: bad address 'app2:8080' matches no such host. download timed out after 5 seconds matches i/o timeout.

When this answer stops applying#

This page covers cloudflared in a Linux Docker container reaching web apps over HTTP and HTTPS. The lab used quick tunnels, not routes made in the dashboard. The log lines we got match the ones Cloudflare's troubleshooting page prints for configured routes. We did not test cloudflared installed on the host, Docker Desktop (which defines host.docker.internal), Podman, Kubernetes, private network routes for WARP clients, or Access policies. And noTLSVerify turns off the check that the server on that hop is the one you meant, so treat it as a test, not a fix.

What to do next#

Read the text after the colon, match it to the table, and confirm with the wget test before you change the route. If the app now loads but you are still choosing between this and a VPS you control, Pangolin vs Cloudflare Tunnel weighs the two. If you would rather reach your apps without publishing them at all, Remote access without port forwarding sets out the options.

Questions#

Why does localhost not work for cloudflared in Docker?

Because each container has its own network, and its own loopback. Inside the cloudflared container, localhost means cloudflared, where nothing listens on your app's port. Our route to http://localhost:8090 was refused in well under a second, and the log named [::1]:8090. Put cloudflared and the app on one Docker network and use the app's name, such as http://uptime-kuma:3001. Or run cloudflared with network_mode: host, where localhost is the server.

Is a Cloudflare Tunnel timeout a 502 or a 504?

A 502, in every timeout we produced. When cloudflared could not open a connection, it gave up after 30 seconds, the default connectTimeout, and the visitor got 502 at 30.3 s. We never saw a 504. An app that accepted the request and then took too long gave a 524 at 125.3 s, which matches the 125 second Proxy Read Timeout in Cloudflare's docs.

What is the difference between Cloudflare error 1033 and 502?

Where the break is. Cloudflare's docs say 1033 means the tunnel is not connected, so no cloudflared is there to take the request. A 502 means the tunnel is connected and cloudflared could not reach the app. When we stopped cloudflared, the quick tunnel's address answered with HTTP status 530 and a page naming error 1033. So check whether cloudflared is running first, then read its log.

Does cloudflared check the origin's TLS certificate?

Yes, by default. Cloudflare's docs list noTLSVerify as false by default. A route to https://tlsapp:443 with a certificate for example.com failed with x509: certificate is valid for example.com, not tlsapp. Setting originServerName to example.com moved the error to certificate signed by unknown authority. Adding the private CA with caPool as well gave 200. This differs from Nginx Proxy Manager, which does not check the backend certificate.

How do I test the connection from inside the cloudflared container?

You cannot open a shell in it. The cloudflare/cloudflared image has no sh and no ls, and runs as user 65532. Start a throwaway container in its network namespace instead: docker run --rm --network container:cloudflared alpine wget -q -T 5 -O /dev/null http://app:8080. It sees the same localhost and the same Docker DNS as cloudflared. Connection refused and bad address there match the log's connection refused and no such host.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.