Expose Self-Hosted Services Safely with Cloudflare Tunnel

Photo by Chi-Hung Lin via Wikimedia Commons (CC BY-SA 3.0)
No. The cloudflared daemon makes only outbound connections to Cloudflare's edge, so your router and firewall stay fully closed on the inbound side. This is why it works behind carrier-grade NAT where port forwarding is impossible, and why it is safe to run on a hardened VPS with no public ports bound.
The tunnel decides how traffic reaches your service — it is the transport that carries requests from Cloudflare's edge down to your origin with no open ports. Access decides who is allowed through, adding an identity-based login check at the edge before any request is routed into the tunnel. You often use both together: the tunnel for connectivity, Access for authentication.
The most common cause is a missing catch-all rule. Your ingress list must end with a rule that has no hostname, such as one returning HTTP status 404. Cloudflare evaluates rules top to bottom and requires that final fallback, so if the daemon will not boot, confirm the last entry has no hostname key.
Yes. Create the tunnel in the Zero Trust dashboard and it gives you a connector token that already contains the tunnel identity and routing configuration. Pass that token to the official cloudflare/cloudflared image via a TUNNEL_TOKEN environment variable and run the tunnel command — no config file or credentials JSON needs to be mounted.
Not as a public HTTP application. Cloudflare Tunnel's public hostnames are built for HTTP services, and a database should never be published that way. For database access use Cloudflare's private network routing with the WARP client, or keep the database on an internal network the tunnel reaches only for app containers, never as a public route.

Photo by Chi-Hung Lin via Wikimedia Commons (CC BY-SA 3.0)
Key Takeaway
Cloudflare Tunnel runs a small daemon called cloudflared that dials outbound to Cloudflare, so you expose homelab or VPS services with zero inbound ports open. You map hostnames to local services in an ingress config, then put Zero Trust Access policies in front to require identity-based login before anyone reaches the app.
For years the default answer to reaching a home server was port forwarding: punch a hole in your router, expose port 443, and hope your reverse proxy and TLS setup never has a bad day. On a residential connection you often cannot even do that, because the ISP sits behind carrier-grade NAT and never hands you a routable public IP. On a VPS you can open ports, but every open port is a login screen for the entire internet to hammer.
Cloudflare Tunnel flips the direction. Instead of the internet connecting in to you, a lightweight agent connects out to Cloudflare's edge and holds a persistent connection open. Traffic for your hostname arrives at Cloudflare, gets sent down that existing outbound tunnel, and only then reaches your service. Your firewall stays fully closed on the inbound side. I run this in front of a couple of internal dashboards and a service on my VPS, and it has quietly replaced every reverse-proxy-plus-open-port setup I used to babysit.
The daemon, cloudflared, establishes several outbound connections to the nearest Cloudflare data centers over QUIC (UDP 7844) or HTTPS. Because these are outbound connections your NAT and firewall already allow, nothing needs to be opened. When a request hits your hostname, Cloudflare routes it into the tunnel and cloudflared proxies it to a local address such as localhost on some port. The origin service never sees the public internet directly, and its port is never bound to a public interface.
The classic path uses the CLI to authenticate, create a named tunnel, and generate a credentials file. Authentication opens a browser so you can pick which zone the tunnel belongs to. Creating the tunnel writes a JSON credentials file whose filename is the tunnel UUID — treat that file like a password.
# Install on Debian/Ubuntu (official Cloudflare repo)
sudo mkdir -p /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared any main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
# Authenticate (opens a browser to pick your zone)
cloudflared tunnel login
# Create a named tunnel -> writes ~/.cloudflared/<UUID>.json
cloudflared tunnel create homelab
# Route a DNS hostname to this tunnel
cloudflared tunnel route dns homelab grafana.example.comPrefer the token-based method for containers and quick setups. In the dashboard under Zero Trust, create a tunnel and it hands you a single connector token — no credentials file to move around, and the ingress rules can be edited in the UI. The CLI config-file method below is better when you want your routing rules in version control.
When you manage the tunnel locally, a config file maps each public hostname to a local service. Cloudflare evaluates ingress rules top to bottom and stops at the first match, so order matters: put specific hostnames first and broad wildcards last. The rule that actually trips people up is the final one.
# ~/.cloudflared/config.yml
tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /home/matthews/.cloudflared/6ff42ae2-...json
ingress:
# Specific hostnames first
- hostname: grafana.example.com
service: http://localhost:3000
- hostname: uptime.example.com
service: http://localhost:3001
# A whole app behind one subdomain tree
- hostname: "*.lab.example.com"
service: http://localhost:8080
# REQUIRED catch-all — must be last, no hostname
- service: http_status:404The last ingress rule must be a catch-all with no hostname, such as returning HTTP status 404. Without it cloudflared refuses to start and prints a validation error. This trips up almost everyone on their first config — if the daemon will not boot, check that the final rule has no hostname key.
Once the config validates, run it as a service so it survives reboots. On a systemd host, cloudflared installs a service unit for you; on Docker, you point the container at the same routing but supply the connector token instead of a mounted config.
On my VPS everything is Docker Compose, so the tunnel is just one more service. The token method is cleanest here — the connector token carries the tunnel identity and its routing config, so the container needs nothing mounted. Keep the token in an environment file, never hard-coded in the compose file you commit.
# docker-compose.yml
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
# No ports published — that is the whole point.
networks:
- web
networks:
web:
external: true
# .env (git-ignored)
# TUNNEL_TOKEN=eyJhIjoiTOKEN...Put cloudflared on the same Docker network as the apps it proxies, then point ingress at the container name instead of localhost — for example http://grafana:3000. Now none of your app containers need published ports either; the tunnel reaches them over the internal Docker network and the host has nothing listening publicly.
A tunnel alone still publishes your service to anyone who knows the URL. For anything that is not meant to be fully public, layer Cloudflare Access on top. Access sits at the edge and forces every request through an identity check before it is ever routed into your tunnel. All Access applications are deny by default — a user must match an Allow policy first — so an unauthenticated stranger never reaches your origin at all.
Beyond a simple email allow-list, Access policies can gate on IdP group membership, country, device posture, and session duration. For a service that must accept programmatic calls, add a service token so a script can authenticate with a header pair instead of a browser login. The mental model is simple: the tunnel decides how traffic reaches your box, and Access decides who is allowed to send it.
| Service | Approach | Why |
|---|---|---|
| Internal dashboards (Grafana, uptime, Portainer) | Tunnel plus Access allow-list | Private by nature; identity gate keeps them yours alone. |
| Public blog or marketing site | Tunnel only, no Access | Meant for everyone; Access would block real visitors. |
| SSH to the host | Tunnel plus Access with short sessions | Reaches SSH without an open port 22 to the world. |
| Database ports (Postgres, Redis) | Private network route, not a public hostname | Never expose a DB as an HTTP app; use WARP or private routing. |
| Anything handling payments or PII | Access plus strict IdP and device posture | Edge auth is a control, not a substitute for app-level security. |
The trade-off worth naming: you are routing your traffic through Cloudflare and trusting their edge to terminate TLS. For a homelab and most side projects that is a fair deal for the convenience and the closed firewall. If you cannot accept a third party terminating TLS, a self-hosted alternative like a WireGuard VPN or a plain reverse SSH tunnel keeps everything in your hands at the cost of more setup and no edge filtering. For me, on a residential line behind CGNAT and a VPS I want locked down, Cloudflare Tunnel plus Access is the setup I actually keep running.