Cloudflare Tunnel Homelab Guide: Expose Self-Hosted Services Without Port Forwarding (2026)
If you run a homelab, you have almost certainly wrestled with the same question: how do I safely access my services from outside my home network? The traditional answer involves port forwarding on your router, dynamic DNS updates, and hoping your ISP hasn’t put you behind CGNAT. It is fragile, it exposes your home IP address to the world, and it is one misconfigured firewall rule away from disaster.
Cloudflare Tunnel (powered by cloudflared) eliminates all of those problems. It creates an encrypted outbound-only connection from your homelab to Cloudflare’s edge network. No open ports. No dynamic DNS. No exposed IP addresses. And you get Cloudflare’s DDoS protection, free SSL certificates, and global CDN for free.
In this guide, Dev walks through everything: what Cloudflare Tunnel actually is, how to deploy it with Docker, how to integrate it with Nginx Proxy Manager, and how to layer on Zero Trust access policies so only you (or your family) can reach sensitive dashboards.
What Is Cloudflare Tunnel and Why Should You Use It?
Cloudflare Tunnel is a secure, outbound-only tunnel that connects your local services to Cloudflare’s global network. The daemon — cloudflared — runs inside your homelab and establishes a persistent connection to Cloudflare’s edge. When a visitor hits jellyfin.yourdomain.com, Cloudflare proxies that request through the tunnel directly to your local service.
What this replaces:
| Old Way | Cloudflare Tunnel Way |
|---|---|
| Open port 443 on router | Zero open ports |
| Dynamic DNS (DuckDNS, afraid.org) | Not needed — Cloudflare handles DNS |
| Let’s Encrypt cert renewal cron jobs | Free Cloudflare edge certificates, auto-renewed |
| ISP CGNAT blocks everything | Works behind any NAT, even double-NAT |
| Your home IP exposed to attackers | Only Cloudflare’s IPs are visible |
What you need before starting:
- A domain name — any registrar works, but you must point its nameservers to Cloudflare (free plan included).
- A Cloudflare account — free tier is more than enough for homelab use.
- A machine running Docker — a Proxmox LXC, a Raspberry Pi, an old laptop, anything with Docker works.
- Services you want to expose — Jellyfin, Nextcloud, Home Assistant, Portainer, whatever you self-host.
Important caveat: Cloudflare Tunnel decrypts traffic at their edge and re-encrypts it over the tunnel. For the vast majority of homelab services, this is perfectly fine. If you are handling legally protected data (HIPAA, PCI-DSS, etc.), you probably should not be self-hosting it on a homelab connection anyway. For everything else — media servers, dashboards, family photo galleries — Cloudflare Tunnel is an excellent fit.
Step 1: Set Up Your Domain on Cloudflare
If you already have your domain on Cloudflare, skip to Step 2.
Log into the Cloudflare dashboard and click Add a Site. Enter your domain name and follow the onboarding wizard. Cloudflare will scan your existing DNS records and import them automatically.
Once your domain is added, go to your domain registrar (Namecheap, Porkbun, Google Domains, etc.) and replace the existing nameservers with the two Cloudflare-provided nameservers. Propagation typically takes a few minutes but can take up to 24 hours in rare cases.
In the Cloudflare dashboard, go to SSL/TLS → Overview and set your encryption mode to “Full.” This ensures traffic between Cloudflare’s edge and your tunnel is encrypted. Without this, you may see redirect loops or SSL errors when accessing your services.
Step 2: Create a Cloudflare Tunnel
Navigate to Zero Trust → Networks → Tunnels in the Cloudflare dashboard. (If this is your first time in the Zero Trust dashboard, you will be asked to pick a team name — choose anything, it is just an internal identifier and can be changed later.)
Click Create a tunnel, give it a name (e.g., homelab-tunnel), and click Save tunnel.
Cloudflare will now show you installation instructions for various platforms. Choose Docker and copy the token from the command. It looks like:
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run \
--token eyJhIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODkwIn0=
The --token value is your tunnel’s authentication credential — treat it like a password. Do not commit it to a public Git repository. We will store it properly in a Docker Compose file in the next step.
Before you close this screen, scroll down to the Public Hostnames section. This is where you map your domain names to local services. Add your first entry:
| Field | Value |
|---|---|
| Subdomain | jellyfin (or whatever you like) |
| Domain | yourdomain.com |
| Type | HTTP |
| URL | 192.168.1.100:8096 (your Jellyfin IP and port) |
Click Save hostname. Repeat for any other services you want to expose. You can always come back and add more later.
Step 3: Deploy cloudflared with Docker Compose
While you could add public hostnames one-by-one through the Cloudflare dashboard, a cleaner approach for homelab use is to configure cloudflared with a YAML config file. This keeps all your tunnel routing rules in one place — version-controlled, documented, and easy to recreate if your server dies.
Create a directory for your tunnel configuration:
mkdir -p ~/cloudflare-tunnel
Create a config.yml inside that directory:
# ~/cloudflare-tunnel/config.yml
tunnel: YOUR-TUNNEL-ID
credentials-file: /home/nonroot/.cloudflared/YOUR-TUNNEL-ID.json
ingress:
# Route by hostname to Nginx Proxy Manager
- hostname: jellyfin.yourdomain.com
service: http://nginx-proxy-manager:80
- hostname: nextcloud.yourdomain.com
service: http://nginx-proxy-manager:80
- hostname: homeassistant.yourdomain.com
service: http://nginx-proxy-manager:80
- hostname: portainer.yourdomain.com
service: http://nginx-proxy-manager:80
# Catch-all: return 404 for unmatched hostnames
- service: http_status:404
Why point everything at Nginx Proxy Manager? Instead of configuring each service’s internal IP and port in Cloudflare’s dashboard, you route all traffic through Nginx Proxy Manager (NPM). NPM handles SSL termination on the local side and lets you manage subdomains, proxy hosts, and access lists from one clean web UI. If you followed our Nginx Proxy Manager Docker Compose guide, you already have this set up.
Note: Replace
YOUR-TUNNEL-IDwith the actual tunnel ID from the Cloudflare dashboard (visible under Zero Trust → Networks → Tunnels, in the format of a UUID likeabcdef12-3456-7890-abcd-ef1234567890).
Now create your docker-compose.yml:
# ~/cloudflare-tunnel/docker-compose.yml
version: "3.8"
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
# Alternative: use config.yml with credentials file
# volumes:
# - ./config.yml:/home/nonroot/.cloudflared/config.yml:ro
# - ./credentials.json:/home/nonroot/.cloudflared/${TUNNEL_ID}.json:ro
# command: tunnel --config /home/nonroot/.cloudflared/config.yml run
networks:
- proxy_network
networks:
proxy_network:
external: true
name: nginx-proxy-manager_default # Replace with your NPM network name
Create a .env file in the same directory:
# ~/cloudflare-tunnel/.env
TUNNEL_TOKEN=eyJhIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODkwIn0=
Security tip: Never commit
.envto version control. Add it to.gitignoreimmediately.
Start the tunnel:
cd ~/cloudflare-tunnel
docker compose up -d
Check the logs to confirm the connection:
docker logs cloudflared
You should see a line like:
INF Registered tunnel connection connIndex=0
If you see ERR messages about failed connections, double-check your TUNNEL_TOKEN value and ensure your server can reach region1.v2.argotunnel.com on port 7844 (outbound).
Step 4: Configure Nginx Proxy Manager for Tunnel Traffic
With the tunnel running, traffic arriving at jellyfin.yourdomain.com is routed through Cloudflare’s edge, through the encrypted tunnel, and lands at Nginx Proxy Manager on port 80 inside your Docker network. Now you need NPM to know what to do with that request.
Log into your Nginx Proxy Manager web UI (typically http://192.168.1.100:81) and add a new proxy host:
| Field | Value |
|---|---|
| Domain Names | jellyfin.yourdomain.com |
| Scheme | http |
| Forward Hostname / IP | 192.168.1.100 (or the Docker service name) |
| Forward Port | 8096 |
| Block Common Exploits | ✅ Enabled |
| Websockets Support | ✅ Enabled (needed for Jellyfin, Home Assistant, etc.) |
Under the SSL tab:
| Field | Value |
|---|---|
| SSL Certificate | “None” — Cloudflare handles SSL at the edge |
| Force SSL | ❌ Disabled — otherwise you may create redirect loops |
| HTTP/2 Support | ✅ Enabled |
| HSTS Enabled | ✅ Enabled |
| HSTS Subdomains | ✅ Enabled |
Click Save. Repeat for each service you want to expose.
Why SSL=None locally? Cloudflare terminates SSL at their edge and forwards traffic to your tunnel over an encrypted QUIC connection. The hop from cloudflared to NPM inside your Docker network is local traffic and doesn’t need additional encryption. Adding SSL on that hop adds latency with no security benefit.
Step 5: Add Cloudflare Zero Trust Access (Optional but Recommended)
Exposing a service to the internet means anyone who discovers the URL can attempt to access it. For dashboards like Portainer, Proxmox, or Grafana — tools you definitely do not want random internet users interacting with — add an authentication layer with Cloudflare Zero Trust.
In the Cloudflare Zero Trust dashboard, go to Access → Applications and click Add an application. Choose Self-hosted.
Configure:
| Field | Value |
|---|---|
| Application name | Portainer |
| Subdomain | portainer |
| Domain | yourdomain.com |
| Identity providers | Select at least one (Google, GitHub, or One-Time PIN via email are all free) |
Under Policies, add a rule:
| Field | Value |
|---|---|
| Policy name | Allow family |
| Include | Emails ending in @yourdomain.com (or list specific email addresses) |
Save the policy and the application. Now when anyone visits portainer.yourdomain.com, they will first see a Cloudflare login page. Only authenticated users matching your policy can proceed to the actual Portainer UI. No VPN needed, no app installed — just a browser login.
For services you want fully public (like a blog), simply skip the Zero Trust Access step. Cloudflare Tunnel will serve them normally without any authentication gate.
Advanced: Using config.yml with Multiple Networks
If you run services across multiple Docker networks or on different physical hosts, the ingress rules in config.yml give you full flexibility:
ingress:
# Service on Docker network "media"
- hostname: jellyfin.yourdomain.com
service: http://jellyfin:8096
originRequest:
connectTimeout: 30s
noTLSVerify: true
# Service on a different physical host (Proxmox LXC at 192.168.1.50)
- hostname: proxmox.yourdomain.com
service: https://192.168.1.50:8006
originRequest:
noTLSVerify: true # Proxmox uses self-signed certs
# SSH access via browser (Cloudflare's browser-based SSH terminal)
- hostname: ssh.yourdomain.com
service: ssh://192.168.1.1:22
# Return 404 for any hostname not explicitly mapped
- service: http_status:404
To use this approach, switch your Docker Compose command to tunnel --config /home/nonroot/.cloudflared/config.yml run and mount both the config file and the credentials JSON file (downloaded from Cloudflare dashboard under the tunnel’s configuration tab).
Troubleshooting Common Issues
“Error 1033: Argo Tunnel error”
This means Cloudflare could not reach your tunnel. Check docker logs cloudflared. Common causes:
- The
TUNNEL_TOKENis incorrect or expired. - Your server cannot reach Cloudflare’s tunnel endpoints (firewall blocking outbound UDP/TCP on port 7844).
- The tunnel is stopped or crashed.
“Too Many Redirects” in the browser
This is almost always caused by SSL misconfiguration. Fixes:
- In Cloudflare SSL/TLS settings, ensure the mode is set to Full, not Flexible.
- In Nginx Proxy Manager, ensure Force SSL and SSL Certificate are both set to None on the proxy host for tunnel traffic.
- Clear your browser cache or test in an incognito window.
“502 Bad Gateway” from Cloudflare
Your tunnel is connected, but it cannot reach the backend service. Check:
- Is the service actually running?
docker ps | grep jellyfin - Are
cloudflaredand your service on the same Docker network? If using the simple token method,cloudflaredneeds to reach the service by its Docker host IP and port. If usingconfig.yml, ensure the service name in theservicefield resolves from thecloudflaredcontainer. - Is your NPM proxy host pointing to the correct internal IP and port?
Slow performance on media streaming
Cloudflare Tunnel is not designed for high-bandwidth video streaming through their free tier. While it works, Cloudflare’s Terms of Service (Section 2.8) prohibit serving disproportionate amounts of video or other non-HTML content through their CDN on the free plan. For streaming Jellyfin or Plex to remote users, consider:
- Tailscale (free for up to 100 devices) — direct peer-to-peer encrypted connection.
- WireGuard VPN — self-hosted on a cheap VPS as a relay.
- Direct port forwarding for streaming only (with proper firewall rules and fail2ban).
Use Cloudflare Tunnel for the web UI, metadata, and authentication; route the actual video stream through a separate path if you have more than a handful of remote users.
Security Checklist
Before you walk away from this setup, run through these security essentials:
- [ ] Cloudflare SSL/TLS mode set to Full (not Flexible — Flexible sends unencrypted traffic between Cloudflare and your tunnel, which defeats the purpose).
- [ ] Tunnel token stored in
.env, not committed to Git. - [ ] NPM “Block Common Exploits” enabled on every proxy host.
- [ ] Zero Trust Access policies applied on admin dashboards (Portainer, Proxmox, Grafana, Cockpit, etc.).
- [ ] Cloudflare WAF (Web Application Firewall) enabled — free tier includes basic DDoS protection and managed rulesets. Go to Security → WAF in the Cloudflare dashboard.
- [ ] Bot Fight Mode enabled (Security → Bots) — blocks basic automated scanners at the edge.
- [ ] Regularly review Cloudflare Access audit logs (Zero Trust → Logs → Access) to see who is authenticating and from where.
What We Covered
Cloudflare Tunnel gives you a production-grade way to expose self-hosted services without touching your router’s port forwarding rules. You get free SSL, DDoS protection, and optional authentication gates — all running on infrastructure that serves a significant portion of the internet.
In this guide, you set up:
- A Cloudflare Tunnel from your homelab to Cloudflare’s edge.
- Docker Compose deployment of
cloudflaredwith environment-based token management. - Nginx Proxy Manager integration so you manage all routing from one UI.
- Zero Trust Access policies to gate sensitive services behind email-based authentication.
- Troubleshooting steps for the three most common deployment issues.
From here, consider exploring Cloudflare’s WAF custom rules to block specific countries or IP ranges, setting up Cloudflare Pages for a static blog (perhaps using Pelican — our favorite), or adding Cloudflare R2 as an S3-compatible backup target for your homelab data. The free tier is remarkably generous for personal projects.
Your homelab services are now reachable, fast, and — most importantly — secure. All without opening a single port on your router.