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, while 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, for example 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, so 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 legal notice).