Skip to content
← All posts
· 4 min read·Emre Yurtbay

Cloudflare Tunnel: Expose Services Without Open Ports

Use cloudflared to bring a service online securely without opening a single inbound port – step by step via the CLI and as a Docker container.

Cloudflare TunnelcloudflaredDockerReverse ProxySelf-HostingNetworkingDevOps

Anyone running a service behind a home connection or inside a locked-down network knows the problem: port forwarding on the router, a dynamic IP, and the nagging worry of making something directly reachable from the internet. Cloudflare Tunnel flips the model. Instead of opening a port, a small daemon called cloudflared establishes an outbound connection to Cloudflare. Traffic flows back through that tunnel to your service – the firewall stays completely closed from the outside. This post walks through a working minimal tunnel in under 15 minutes.

What Cloudflare Tunnel does – and when it fits

cloudflared runs on your host and connects only outbound to Cloudflare's network, by default over QUIC (UDP), with HTTP/2 (TCP) as a fallback. All it needs is outbound port 7844. No inbound port forwarding is required, your public IP stays hidden, and a dynamic IP no longer matters. Cloudflare accepts the requests to your domain and passes them through the existing tunnel.

This fits anywhere you want to make a web service, an API, or an internal service reachable without creating the attack surface of an open port – behind CGNAT, on a home network, or in a restrictive cloud environment.

                                +---------------------+
   Visitor --HTTPS-->           |     Cloudflare      |
   (app.example.com)            |       Edge          |
                                +----------+----------+
                                           ^
                  outbound, port 7844      |  (no open port)
                                           |
                                +----------+----------+
                                |     cloudflared     |
                                |     (your host)     |
                                +----------+----------+
                                           |
                                  http://localhost:8080
                                     [  your service  ]

Two operating models

Cloudflare distinguishes two models. With a remotely-managed tunnel you set everything up in the dashboard (under Networking → Tunnels); the configuration lives at Cloudflare, and cloudflared starts with a token. This is the path Cloudflare recommends for most cases. With a locally-managed tunnel you create the tunnel via the CLI, and the routing rules live in a local config.yml. We show the latter first because every rule is then visible and version-controllable.

The minimal tunnel via the CLI

After installing cloudflared, you log in once and create the tunnel:

cloudflared tunnel login
cloudflared tunnel create mein-tunnel

login opens the browser and authorizes cloudflared for one of your domains. create creates the tunnel and writes a credentials file to ~/.cloudflared/<TUNNEL-UUID>.json. You will need this UUID shortly.

Now the routing rules in ~/.cloudflared/config.yml. Each rule maps a hostname to a local service; the last rule must be a catch-all rule:

tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /root/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json

ingress:
  - hostname: app.example.com
    service: http://localhost:8080
  - service: http_status:404

tunnel is the UUID from the previous step, credentials-file the path to the generated JSON file. Under ingress, each hostname gets a block with service as its target. The trailing service: http_status:404 catches everything else – without this catch-all rule the configuration is invalid.

What remains is the DNS record and starting the tunnel:

cloudflared tunnel route dns mein-tunnel app.example.com
cloudflared tunnel run mein-tunnel

route dns automatically creates the matching CNAME pointing to <UUID>.cfargotunnel.com. run starts the tunnel. A request to https://app.example.com now reaches your service on port 8080 via Cloudflare – with nothing open on your router.

Running it as a Docker container

For continuous operation, cloudflared usually runs as a container. For a remotely-managed tunnel you copy the token from the dashboard; you then manage routing and hostnames there, and no config.yml is needed:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}

Store the token as the environment variable TUNNEL_TOKEN (for example in a .env file), never in plaintext in the compose file. A docker compose up -d is all it takes.

Three common pitfalls

1. The catch-all rule is missing. A config.yml with ingress rules must end with service: http_status:404. Without the closing rule, the tunnel will not start.

2. localhost inside the container is not the host. When cloudflared runs in a container, service: http://localhost:8080 points to the container itself, not to your service. Point it at the service name on the same Docker network or at host.docker.internal instead. A wrong target is answered by Cloudflare with a 502.

3. DNS conflict on the hostname. If an A or CNAME record already exists for the hostname, creating it fails. The record must be a CNAME pointing to <UUID>.cfargotunnel.com – delete the old record or pick a different hostname.

Where to go from here

You now have a service securely online without a single open port. Natural next steps: add more hostnames as additional ingress rules, run cloudflared permanently as a systemd service or in a container, and put Cloudflare Access in front of it for access control if needed. Go deeper in the Cloudflare Tunnel overview, in creating a remotely-managed tunnel, in the config.yml reference, and in the firewall and port notes.

Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).

Discuss your project