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.
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).