For years, my homelab setup looked like this: port forwarding on the router, dynamic DNS to handle IP changes, Certbot for SSL certificates that needed renewing, and nginx reverse proxy configs that I’d forget the syntax of every time I needed to change something.

Then I discovered Cloudflare Tunnel. Now I expose any service on my home network to the internet with zero open ports, automatic SSL, and no public IP required. Here’s how.

What Is Cloudflare Tunnel?

Cloudflare Tunnel (formerly Argo Tunnel) creates an outbound-only encrypted connection from your server to Cloudflare’s network. Traffic flows like this:

User → Cloudflare Edge → Tunnel → Your Server

The key insight: your server connects out to Cloudflare, not the other way around. This means:

  • No open ports on your firewall/router
  • No port forwarding configuration
  • No exposing your home IP address
  • No DDNS needed
  • Automatic SSL certificates
  • Built-in DDoS protection from Cloudflare

It’s free for basic usage. You only pay if you add features like private network routing for teams.

My Homelab Setup

I run a mix of services on a couple of machines:

ServicePurposeMachine
Proxmox VEHypervisorDell Optiplex
Home AssistantSmart homeRaspberry Pi 4
JellyfinMedia streamingNAS
GiteaSelf-hosted GitDocker on Proxmox
Uptime KumaMonitoringDocker on Proxmox
Paperless-ngxDocument managementDocker on Proxmox
VaultwardenPassword managerDocker on Proxmox

Before Cloudflare Tunnel, exposing each of these required its own nginx config, SSL cert, and port forwarding rule. Now? One tunnel handles everything.

Setting Up Cloudflare Tunnel

Prerequisites

  • A domain on Cloudflare (even just the free DNS plan)
  • A Linux machine on your home network (the tunnel runs here)
  • Docker (optional but recommended)

Step 1: Install cloudflared

On your server:

# Debian/Ubuntu
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 $(lsb_release -cs) main" \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflared

# Or with Docker
docker pull cloudflare/cloudflared

Step 2: Create a Tunnel via Dashboard

The easiest way is through the Cloudflare dashboard:

  1. Go to Zero TrustNetworksTunnels
  2. Click Create a tunnel
  3. Name it (e.g., “homelab”)
  4. You’ll get a token — copy it

Step 3: Run the Tunnel

# Direct install
cloudflared tunnel run --token YOUR_TOKEN_HERE

# Or with Docker
docker run -d --restart always --name cloudflared \
  cloudflare/cloudflared tunnel run --token YOUR_TOKEN_HERE

Step 4: Configure Routes

Back in the dashboard, add Public Hostnames for each service:

SubdomainServiceURL
ha.yourdomain.comHome Assistanthttp://192.168.1.50:8123
git.yourdomain.comGiteahttp://localhost:3000
media.yourdomain.comJellyfinhttp://192.168.1.100:8096
status.yourdomain.comUptime Kumahttp://localhost:3001
docs.yourdomain.comPaperless-ngxhttp://localhost:8000

Each one gets automatic SSL, HTTPS, and Cloudflare’s CDN/DDoS protection. No nginx configs, no certbot, no port forwarding.

Docker Compose Setup

Here’s how I run cloudflared alongside my services:

# docker-compose.yml
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: always
    command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN}
    environment:
      - CLOUDFLARE_TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
    networks:
      - homelab

  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: always
    ports:
      - "3000:3000"
    volumes:
      - ./gitea/data:/data
    networks:
      - homelab

  uptime-kuma:
    image: louislam/uptime-kuma:latest
    container_name: uptime-kuma
    restart: always
    ports:
      - "3001:3001"
    volumes:
      - ./uptime-kuma/data:/app/data
    networks:
      - homelab

networks:
  homelab:
    driver: bridge

Create a .env file:

CLOUDFLARE_TUNNEL_TOKEN=your-token-here

Advanced: Configuration File Approach

For more control, you can use a configuration file instead of the dashboard:

# ~/.cloudflared/config.yml
tunnel: your-tunnel-id
credentials-file: /home/user/.cloudflared/your-tunnel-id.json

ingress:
  - hostname: ha.yourdomain.com
    service: http://192.168.1.50:8123
    originRequest:
      noTLSVerify: true  # If the service uses self-signed certs

  - hostname: git.yourdomain.com
    service: http://localhost:3000

  - hostname: media.yourdomain.com
    service: http://192.168.1.100:8096

  - hostname: status.yourdomain.com
    service: http://localhost:3001

  # Catch-all (required)
  - service: http_status:404

Then run with:

cloudflared tunnel run homelab

Access Control with Cloudflare Access

Exposing services to the internet means anyone can access them. For sensitive services, add Cloudflare Access policies:

  1. Go to Zero TrustAccessApplications
  2. Add a new application
  3. Set the domain (e.g., git.yourdomain.com)
  4. Add an access policy:
Policy: Allow
Include: Emails ending in @yourdomain.com

Authentication options (all free):

  • One-time PIN — Cloudflare sends a code to your email
  • GitHub OAuth — Log in with your GitHub account
  • Google OAuth — Log in with Google

Now your self-hosted Gitea requires GitHub login before anyone can reach it. The authentication happens at Cloudflare’s edge — before traffic even reaches your home network.

Which Services Need Access Policies?

ServicePublic?Access Policy
Blog/PortfolioYesNone needed
Uptime Kuma (status page)YesNone (read-only)
JellyfinNoEmail OTP or SSO
GiteaNoGitHub OAuth
Home AssistantNoEmail OTP
VaultwardenNoEmail OTP + 2FA
Paperless-ngxNoEmail OTP
ProxmoxNever exposeVPN or local only

Running as a systemd Service

For reliability, run cloudflared as a system service:

sudo cloudflared service install YOUR_TOKEN

# This creates /etc/systemd/system/cloudflared.service
# The tunnel auto-starts on boot and restarts on failure

Verify it’s running:

sudo systemctl status cloudflared

# Output:
# ● cloudflared.service - cloudflared
#    Active: active (running) since ...

Monitoring Your Tunnels

In the Cloudflare dashboard, go to Zero TrustNetworksTunnels. You’ll see:

  • Tunnel status (Healthy/Degraded/Down)
  • Connected routes
  • Recent connections
  • Connector status (which machine is running the tunnel)

I also use Uptime Kuma to monitor my services from outside the network — it makes requests through Cloudflare Tunnel and alerts me on Telegram if anything goes down.

Troubleshooting Common Issues

Tunnel connects but service returns 502

The cloudflared connector can reach Cloudflare, but can’t reach the local service. Check:

# Can you reach the service locally?
curl http://localhost:3000

# Is the service running?
docker ps

# Is the port correct in your tunnel config?

WebSocket connections fail

Some services (Home Assistant, for example) need WebSocket support. Add to your ingress rule:

- hostname: ha.yourdomain.com
  service: http://192.168.1.50:8123
  originRequest:
    noTLSVerify: true
    connectTimeout: 10s

Cloudflare Tunnel supports WebSockets natively — you usually just need to make sure the Host header is correct.

Slow performance for media streaming

For Jellyfin or Plex, large video files passing through Cloudflare Tunnel can be slow. Options:

  1. Enable Cloudflare WARP on your devices and use the private network feature
  2. Use Tailscale alongside Tunnel for internal access
  3. Stream locally on LAN, use Tunnel only for remote access

Tunnel vs. Alternatives

FeatureCloudflare TunnelTailscaleWireGuardngrok
No open portsYesYesNoYes
Custom domainYes (free)MagicDNSManualPaid
Free tierGenerousGenerousFreeLimited
Public accessYesNo (private)No (private)Yes
DDoS protectionYesNoNoNo
Setup complexityLowLowMediumLow

My recommendation: Use Cloudflare Tunnel for services you want to access publicly (or with Cloudflare Access). Use Tailscale for purely private access (like Proxmox management).

What This Replaced

Before Cloudflare Tunnel, my setup needed:

  • Router port forwarding (5 rules)
  • DuckDNS for dynamic DNS
  • nginx reverse proxy (5 server blocks)
  • Certbot with auto-renewal cron job
  • Fail2ban for brute force protection
  • UFW firewall rules

Now it’s one cloudflared container and a few clicks in the dashboard. The reduction in complexity alone was worth the switch — and I sleep better knowing no ports are open on my home network.

Export for reading

Comments