I run several services on a VM in my apartment. They work great on my local network. But when I wanted to access them from my phone at a coffee shop, or let a client see a demo, I hit the wall that every self-hoster hits: port forwarding.

Opening ports on your router means dealing with NAT, dynamic IPs, firewall rules, and the lingering fear that you’ve accidentally exposed something you shouldn’t. Cloudflare Tunnel solves all of this in about 10 minutes.

The problem with traditional port forwarding

To expose a local service publicly, you normally need to:

  1. Forward a port on your router to your VM’s IP
  2. Get a static IP or set up DDNS (dynamic DNS)
  3. Configure firewall rules to allow traffic
  4. Set up TLS certificates (Let’s Encrypt, certbot, etc.)
  5. Hope you didn’t open too many ports

Each step is a potential security hole. And when your ISP changes your IP (which happens more often than you’d think), everything breaks until DDNS catches up.

What Cloudflare Tunnel does

Cloudflare Tunnel creates an outbound connection from your server to Cloudflare’s edge network. Traffic flows like this:

User → Cloudflare Edge → Tunnel → Your Server

Your server initiates the connection. No inbound ports need to be opened. No firewall rules to configure. No dynamic IP to worry about. Cloudflare handles TLS, DDoS protection, and DNS resolution automatically.

The setup

1. Install cloudflared

# On your server
curl -fssl https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflared

2. Authenticate with Cloudflare

cloudflared tunnel login

This opens a browser window where you select your domain. Cloudflare adds a certificate to your server.

3. Create a tunnel

cloudflared tunnel create my-tunnel

This generates a tunnel ID and credentials file.

4. Configure the tunnel

# ~/.cloudflared/config.yml
tunnel: my-tunnel
credentials-file: /root/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: app.example.com
    service: http://localhost:3000
  - hostname: api.example.com
    service: http://localhost:8080
  - hostname: portainer.example.com
    service: http://localhost:9000
  - service: http_status:404

The last rule catches all unmatched requests and returns 404.

5. Route DNS

cloudflared tunnel route dns my-tunnel app.example.com
cloudflared tunnel route dns my-tunnel api.example.com

6. Run the tunnel

cloudflared tunnel run my-tunnel

Or better, run it as a system service:

sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared

What I actually run

My tunnel exposes these services:

ServiceURLPort
Portfolio sitefoxxxesky.com4321
Web appapps.foxxxesky.com3000
APIapi-apps.foxxxesky.com8080
n8nn8n.foxxxesky.com5678
Portainerportainer.foxxxesky.com9000

All running on a single VM. All accessible from anywhere. Zero ports opened on my router.

The security model

This is the part that sold me: your server initiates the connection. There’s no port to scan, no firewall hole to exploit. Cloudflare sees the traffic, applies their DDoS protection and WAF rules, and forwards it through the encrypted tunnel to your server.

You can also add Cloudflare Access policies to require authentication before reaching your services. Want to make sure only you can access Portainer? Add a Cloudflare Access policy that requires your Google/GitHub login.

The gotchas

Cold starts. If your tunnel service restarts, there’s a brief period (1-2 seconds) where traffic might fail. For production services, run the tunnel as a systemd service with automatic restart.

WebSocket support. Works, but you need to configure it explicitly in some cases.

Bandwidth limits. Cloudflare’s free tier has generous limits, but if you’re serving large files (video, large downloads), you might hit them.

Not for everything. If you need raw TCP/UDP (not HTTP), the tunnel supports it but it’s more complex to set up.

Docker Compose integration

For my Docker Compose stack, I run cloudflared as a service alongside my apps:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel run
    volumes:
      - ./cloudflared/credentials.json:/root/.cloudflared/credentials.json:ro
      - ./cloudflared/config.yml:/root/.cloudflared/config.yml:ro
    environment:
      - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}

With a tunnel token, you don’t even need the credentials file — Cloudflare manages everything from their dashboard.

The lesson

Cloudflare Tunnel eliminated the most annoying part of self-hosting: networking. No port forwarding, no dynamic DNS, no TLS certificates to renew. Just point your domain, run the tunnel, and your services are accessible from anywhere.

For solo developers and small teams running self-hosted services, it’s the single most valuable infrastructure tool I’ve added to my stack.