Setting Up Cloudflare Tunnel Docker

Cloudflare Tunnel is free and needs no payment method. Here is the current setup with Docker Compose, the two management models, and how to restrict who can reach the service.

Share
Setting Up Cloudflare Tunnel Docker

Cloudflare Tunnel lets you publish a service on the internet without opening a port on your router or exposing your public IP. Instead of forwarding inbound traffic to your server, a small daemon called cloudflared makes an outbound-only connection to Cloudflare's network, and requests come back down that connection.

The practical effect: no static IP, no port forwarding, no firewall pinhole, and nothing on your network listening for inbound connections from the internet. It works behind CGNAT, which is the situation a lot of people are in now and where port forwarding simply isn't available.

This guide covers the Docker setup as it works today. If you're following a 2023-era tutorial, note two things that have changed: Cloudflare Tunnel no longer requires a payment method, and the dashboard navigation moved.

What it costs

Cloudflare Tunnel is free on every Cloudflare plan, with unlimited bandwidth. You do not need to add a payment method, and you do not need a paid plan.

What you actually need is:

  • A free Cloudflare account
  • A domain whose nameservers point at Cloudflare
  • Something to expose

Access policies (the part that restricts who can reach the service) are free for up to around 50 users on the Zero Trust free tier. Beyond that, or if you want the paid Zero Trust features, it costs money. For a home lab or a small team, the free tier covers everything in this guide.

If a tutorial tells you to "select the Free plan and proceed to payment", it is out of date. You'll be asked for a plan when you first open Zero Trust, but the tunnel itself carries no charge and no card.

How it works

The model is worth understanding before you copy a command, because it explains why nothing needs to be exposed.

  1. You install cloudflared (a lightweight daemon) on your server or as a container.
  2. cloudflared establishes outbound, post-quantum encrypted connections to Cloudflare's global network. No inbound ports, no firewall changes.
  3. You map public hostnames to local services, app.example.com to http://localhost:8080, for example. Cloudflare calls these ingress rules.
  4. Requests to the hostname travel through Cloudflare's network and down the existing connection to your origin, where caching, WAF, bot management and DDoS protection are applied on the way through.

A tunnel is a persistent object identified by a UUID, and it's the logical link between your origin and Cloudflare. You can run as many cloudflared processes (connectors) against the same tunnel as you like, which is how you get redundancy, and how a single dashboard change updates every connector at once.

Prerequisites

  • A domain on Cloudflare DNS. This is genuinely required to publish a hostname through a tunnel. You can keep your registrar; you only need to point the nameservers at Cloudflare.
  • A server (Linux, macOS or Windows) or anywhere Docker runs.
  • Docker and Docker Compose, if you're following the container path below.
  • A tunnel token, which you generate in the dashboard in the next step.

You don't need a static IP, and you don't need to touch your router.

The two management models

This is the fork in the road, and picking deliberately saves you rework later.

Remotely managed (token). The routing rules live in the Cloudflare dashboard. The container needs nothing but a TUNNEL_TOKEN environment variable. Adding a hostname is a form in the dashboard, and it applies to every connector on that tunnel immediately. This is what Cloudflare defaults to and what I'd recommend for a Docker setup, fewer files to lose, and you get health status and live logs in the dashboard.

Locally managed (config file). The routing lives in config.yml on the host, alongside a credentials JSON file. More moving parts, but the configuration is a file you can version, template, or generate, which is what you want if you're managing a fleet or doing GitOps.

Both run the same binary and the same protocol. You can start remotely managed and convert later.

Setting it up with Docker

1. Create the tunnel

In the Cloudflare dashboard, go to Zero Trust → Networks → Tunnels → Create a tunnel.

Choose Cloudflared as the connector type, give the tunnel a name, and save.

Older guides will tell you this lives under the "Access" tab. It moved to Networks, if you can't find it, that's why.

2. Get the token

On the "install connector" screen, choose Docker. Cloudflare shows you a docker run command with a long token baked into it.

You don't need the whole command, just the token string after --token. That token both authenticates the connector and tells it which tunnel it belongs to, which is why it needs to be treated as a secret. Anyone holding it can connect a connector to your tunnel.

3. Run it with Compose

The docker run command works, but Compose is the better default: it survives reboots cleanly, keeps your configuration in a file, and doesn't leave you pasting a token into shell history.

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    network_mode: host

Then put the token in a .env file next to the Compose file, and make sure that file isn't in Git:

echo 'TUNNEL_TOKEN=your-token-here' > .env
chmod 600 .env
docker compose up -d
docker compose logs -f cloudflared

The logs are the fastest way to confirm the connector registered. You're looking for a line reporting the connection to Cloudflare being established, and it should stay up.

On network_mode: host: it lets cloudflared reach services on localhost and on other Docker networks without extra plumbing, which is what most people want. If you'd rather isolate it, drop that line and put cloudflared on the same Docker network as the service you're exposing, then point the ingress rule at the container name instead of localhost.

4. Publish a hostname

Back in the dashboard, open the tunnel and select Public hostname, then Add a public hostname.

For each service you need:

  • Subdomain and domain, what the public hostname will be
  • Service type, HTTP or HTTPS
  • URL, where the service actually lives, e.g. localhost:8080

If the service uses a self-signed certificate, open Additional application settings and set TLS Verify to off. Otherwise cloudflared will reject your own certificate and you'll get a 502 while the service is plainly running.

Repeat for each hostname. Test by opening the hostname from outside your network, the point is that it works without any inbound port being open.

Adding access control

A tunnel makes a service reachable. It does not make it private. If you've just published your dashboard, anyone who guesses the hostname is looking at a login page.

To restrict it, add an Access policy:

  1. Zero Trust → Access → Applications → Add an application
  2. Choose Self-hosted
  3. Set the application domain to the public hostname you just published
  4. Add a policy, give it a name, set the session duration, and pick the rule. The useful options are requiring a specific email address or email domain, an IP range, a country, or a client certificate
  5. Save

Now the service sits behind a Cloudflare login that enforces your rule before any traffic reaches your origin. Testing is straightforward: open the hostname in a private window. You should get a Cloudflare authentication page rather than your service.

For anything with real value behind it, this step is not optional. A tunnel solves the exposure problem; Access solves the authorisation problem. They're different problems and the tunnel only does the first one.

Gotchas worth knowing before you hit them

Delete hostnames before you delete a tunnel. If you remove a tunnel but leave its public hostnames attached, creating a new tunnel with the same names can fail. Clean up the hostnames first.

Watch for IP changes if you're not using a tunnel. If you have DNS records pointing directly at an IP elsewhere in your setup, a dynamic IP will break them. The tunnel itself handles this (cloudflared re-registers with Cloudflare continuously) but anything else you've pointed at an address is on its own.

Keep the token out of version control. It's the whole credential. .env with chmod 600, and a .gitignore entry if you keep the Compose file in a repo.

Set up a notification for the tunnel going down. Cloudflare shows connector health in the dashboard, but you want to know before a user tells you.

Where this fits

The tunnel handles inbound exposure, but it isn't a substitute for securing the service itself. If you're exposing something on the same box, how to harden NGINX covers the TLS and header side, and installing Open-appsec adds a WAF layer in front of the application. If you're setting up the server from scratch, the first 24 hours on a new VPS covers the baseline hardening, and CrowdSec gives you a crowd-sourced IPS for the host.

For exposing a self-hosted service specifically, self-hosting Vaultwarden is a worked example of the pattern this guide describes.

## Convertkit Newsletter