Skip to main content
BlogSecurity

Expose Self-Hosted Services Safely with Cloudflare Tunnel

How I reach my homelab and VPS from anywhere with zero inbound ports open — cloudflared config, ingress rules, and Zero Trust access policies.

Matthews Wong

Software Engineer

July 20269 min read
Expose Self-Hosted Services Safely with Cloudflare Tunnel

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.

How the tunnel works with no open ports

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.

  • No inbound firewall rules — the tunnel is outbound-only, so it works behind CGNAT and locked-down VPS firewalls alike.
  • TLS terminates at Cloudflare's edge, so you do not manage certificates on the origin for public hostnames.
  • DDoS and bot filtering happen at the edge before traffic ever enters your tunnel.
  • Your origin IP address is never exposed in DNS — the record points at Cloudflare, not at you.

Installing cloudflared and creating the tunnel

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

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

The config.yml and ingress rules

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:404

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

Running cloudflared in Docker

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.

Adding Zero Trust Access in front

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.

  1. In the dashboard, go to Zero Trust, then Access, then Applications, and create a new Self-hosted application.
  2. Set the application domain to the exact hostname your tunnel serves, for example grafana.example.com.
  3. Add an Allow policy — the simplest is a rule matching your own email address, or emails ending in your domain.
  4. Connect an identity provider such as Google or GitHub, or use the built-in one-time PIN sent to allowed emails.
  5. Save. Visiting the hostname now redirects to a login page, and only matching identities pass through to the app.

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.

What to expose this way — and what not to

ServiceApproachWhy
Internal dashboards (Grafana, uptime, Portainer)Tunnel plus Access allow-listPrivate by nature; identity gate keeps them yours alone.
Public blog or marketing siteTunnel only, no AccessMeant for everyone; Access would block real visitors.
SSH to the hostTunnel plus Access with short sessionsReaches SSH without an open port 22 to the world.
Database ports (Postgres, Redis)Private network route, not a public hostnameNever expose a DB as an HTTP app; use WARP or private routing.
Anything handling payments or PIIAccess plus strict IdP and device postureEdge 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Do I need to open any ports to use Cloudflare Tunnel?

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.

What is the difference between Cloudflare Tunnel and Zero Trust Access?

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.

Why does cloudflared refuse to start with an ingress config error?

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.

Can I run Cloudflare Tunnel in Docker without a credentials file?

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.

Is it safe to expose a database like PostgreSQL through a tunnel?

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.