Why Port Forwarding Is a Terrible Idea in 2026
If you're still punching holes in your firewall to access self-hosted services from outside your home network, stop. Every open port is an attack surface. Your ISP may block inbound connections on port 443. You probably don't have a static IP. And if you're running Proxmox with a dozen services β Jellyfin, Immich, Gitea, Grafana, your own LLM endpoint β managing NAT rules per-service quickly becomes a maintenance nightmare.
Cloudflare Tunnels solve all of this. The cloudflared daemon on your server establishes an outbound connection to Cloudflare's edge. Traffic flows in through that tunnel β no inbound ports required, no static IP, full TLS from client to origin. Cloudflare becomes your reverse proxy, your DDoS absorber, and your certificate authority, all without you touching your router's port forwarding table.
I've been running this setup on my Proxmox cluster for several months. Here's exactly how it works, the real configuration you need, and the gotchas that will trip you up.
---
How Cloudflare Tunnels Actually Work
The architecture is simple but worth understanding before you write a single config file:
1. You install cloudflared on a host inside your network (a Proxmox LXC, a VM, a container).
2. cloudflared establishes persistent outbound WebSocket connections to Cloudflare's edge β typically 4 connections for redundancy.
3. You configure DNS records (CNAME) pointing your subdomain to Cloudflare's tunnel endpoint.
3. Incoming HTTPS requests to jellyfin.yourdomain.com hit Cloudflare, traverse the tunnel, and land at http://192.168.0.x:8096 on your internal network.
Your router sees zero inbound connections. Your origin is never directly reachable from the internet. Cloudflare terminates TLS at the edge, so you get valid certificates without running Certbot or Let's Encrypt internally (though you can add internal TLS too for defense in depth).
The free tier covers everything a home lab needs. You get unlimited tunnels, unlimited requests, and Cloudflare Access for zero-trust auth. The only requirement: your domain must be on Cloudflare DNS.
---
Prerequisites
cloudflared β I use a Debian 12 LXC with 512MB RAM and 1 CPU; it handles a dozen services without breaking a sweatHardware-wise, you don't need anything special. My entire home lab runs on a Beelink EQR6 Mini PC running Proxmox 8, and the cloudflared LXC is one of about 15 containers on it. A Beelink EQ12 or similar N100/N150 mini PC handles this load with CPU utilization under 5% at idle.
For networking, I run my LXCs on a dedicated VLAN using a TP-Link TL-SG108E managed switch, which lets me isolate traffic between the tunnel LXC and my service LXCs. Not strictly required, but good practice.
---
Step 1: Create the Tunnel in Cloudflare Dashboard
1. Log into dash.cloudflare.com, go to Zero Trust β Networks β Tunnels.
2. Click Create a tunnel, choose Cloudflared as the connector type.
3. Name it something like homelab-proxmox and save.
4. Cloudflare will generate a tunnel token. Copy it β you'll need it in the next step.
You can also do this via the Cloudflare API or Terraform if you want to version-control your tunnel config, but the dashboard is fine for a home lab.
---
Step 2: Install cloudflared in a Proxmox LXC
Create a Debian 12 LXC in Proxmox (unprivileged, 512MB RAM, 4GB disk is plenty). Then inside it:
# Add Cloudflare's apt repo
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | 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' | tee /etc/apt/sources.list.d/cloudflared.listapt update && apt install -y cloudflared
Authenticate and install the tunnel using the token from the dashboard
cloudflared service install <YOUR_TUNNEL_TOKEN>Start and enable the service
systemctl enable --now cloudflared
That's it. Check the Cloudflare dashboard β your connector should show as Healthy within 30 seconds.
---
Step 3: Configure Public Hostnames (Routes)
This is where you map subdomains to internal services. In the Cloudflare dashboard, go to your tunnel β Public Hostname tab β Add a public hostname.
Example mappings:
jellyfin.yourdomain.com β http://192.168.0.x:8096immich.yourdomain.com β http://192.168.0.y:2283grafana.yourdomain.com β http://192.168.0.z:3000proxmox.yourdomain.com β https://192.168.0.x:8006 (see note below)Cloudflare automatically creates the CNAME DNS record for each hostname. You don't touch DNS manually.
The Proxmox Web UI Gotcha
If you're exposing the Proxmox web interface, the service URL needs to be https:// (since Proxmox only serves HTTPS) and you need to enable No TLS Verify under Additional application settings β TLS in the hostname config. Proxmox uses a self-signed cert by default. Alternatively, set up a proper cert on Proxmox with Let's Encrypt β Proxmox 8 has a built-in ACME client under Datacenter β ACME.
Also add this HTTP header override to fix WebSocket issues with the Proxmox console:
Origin-Header: https://proxmox.yourdomain.com
Without this, the noVNC console will refuse to connect.
---
Step 4: Add Zero-Trust Auth with Cloudflare Access
Cloudflare Tunnels expose your services to the internet β which means you need auth in front of anything you don't want public. Cloudflare Access handles this as a free zero-trust layer, and it's excellent.
Go to Zero Trust β Access β Applications β Add an application β Self-hosted.
grafana.yourdomain.comNow anyone hitting grafana.yourdomain.com will be challenged by Cloudflare's identity flow before the request even reaches your tunnel. You can use GitHub OAuth, Google, a one-time PIN to your email, or Cloudflare's own magic link. I use GitHub auth for anything admin-adjacent and email OTP for shared access.
For services with their own auth (Jellyfin, Immich, Gitea), you can skip Access or layer it as an extra factor. For services with no auth at all β Grafana in anonymous mode, raw APIs β Access is non-negotiable.
# Confirm Access is working β should redirect to Cloudflare auth, not your service curl -I https://grafana.yourdomain.com
HTTP/2 302 β Location: https://cloudflareaccess.com/...
---
Step 5: Local Config File (Optional but Recommended)
If you prefer managing routes as code rather than through the dashboard, cloudflared supports a local YAML config:
# /etc/cloudflared/config.yml tunnel: <your-tunnel-id> credentials-file: /root/.cloudflared/<tunnel-id>.json
ingress: - hostname: jellyfin.yourdomain.com service: http://192.168.0.x:8096 - hostname: immich.yourdomain.com service: http://192.168.0.y:2283 originRequest: connectTimeout: 30s - hostname: proxmox.yourdomain.com service: https://192.168.0.x:8006 originRequest: noTLSVerify: true - hostname: grafana.yourdomain.com service: http://192.168.0.z:3000 - service: http_status:404
The final catch-all http_status:404 is required β cloudflared will error on startup without it.
Apply changes:
cloudflared tunnel ingress validate
systemctl restart cloudflared
---
Performance and Real-World Latency
The honest answer: Cloudflare Tunnels add latency. Connections route through Cloudflare's edge rather than directly to your IP. For LAN use, you're adding 10β40ms depending on your nearest Cloudflare PoP. For most services β Jellyfin, Immich, web dashboards β this is imperceptible. For SSH or latency-sensitive admin tasks, I still use Tailscale for direct LAN access.
The two tools are complementary, not competing: Cloudflare Tunnels for public-facing services, Tailscale (or Headscale self-hosted) for private administrative access. I've got both running simultaneously with zero conflicts.
On hardware, if you're running everything on a single mini PC, 8-port managed switches let you handle VLAN separation cheaply. I also recommend having a UPS β a cloudflared tunnel dropping because of a power blip is annoying, especially if it's in front of something important.
---
Bandwidth Considerations
Cloudflare's free tier has no bandwidth caps, but their Terms of Service prohibit using Tunnels for "large file serving" in ways that abuse the free tier. In practice, streaming Jellyfin externally is a gray area β many people do it, and Cloudflare doesn't actively enforce this. For a family-scale Jellyfin setup, it's fine. For a public Plex alternative with hundreds of users, you'd want Cloudflare's paid tiers or a different approach (direct IP with Caddy + Let's Encrypt, served from a VPS).
Immich is more explicitly fine since it's typically personal use. Same for Gitea, Grafana, and similar services.
---
The Hardware Running This Setup
My current tunnel setup sits on:
If you're building from scratch, the Beelink EQ14 (Intel N150, ~$200) or the Minisforum UM790 Pro (Ryzen 9 7940HS, if you want serious compute) are my current recommendations. Both run Proxmox 8 flawlessly.
For networking, pair with a TP-Link Archer AX55 router (Wi-Fi 6, solid OpenWRT support) and the managed switch above.
---
Troubleshooting Common Issues
Tunnel shows Degraded instead of Healthy:
Check that cloudflared can reach region1.cloudflaretunnels.com and region2.cloudflaretunnels.com on port 443 and 7844. Some ISPs block QUIC (7844); cloudflared will fall back to HTTP/2 over 443 automatically, but it logs warnings.
cloudflared tunnel info <tunnel-name>
journalctl -u cloudflared -f
WebSocket services not working (Proxmox console, n8n, Jupyter):
Enable --no-chunked-encoding in the origin config, or add noChunkedEncoding: true in the YAML. Also ensure the service hostname config has HTTP2Origin disabled if the upstream is HTTP/1.1.
Immich mobile app can't connect: Immich's mobile app needs the server URL to match exactly what's in Access (if enabled). Disable Access for Immich if you're using Immich's own auth β the app handles login itself.
Slow first-byte times: This is usually DNS propagation. After adding a new hostname, wait 1β2 minutes. Also ensure Cloudflare proxy (orange cloud) is enabled on the CNAME records β gray cloud bypasses the tunnel entirely.
---
Final Thoughts
Cloudflare Tunnels are one of the best zero-cost infrastructure upgrades you can make to a home lab. You gain:
The tradeoff is that your traffic routes through Cloudflare, so you're trusting them with your HTTP traffic (they terminate TLS at the edge). For sensitive services, use Cloudflare Access + your own application auth, and consider end-to-end encryption at the origin too. For most self-hosted services in a personal lab, this is an entirely acceptable trust boundary.
It doesn't replace a private mesh like Tailscale for administrative access, but it's the cleanest way to make your self-hosted services available to the outside world without the exposure risks of traditional port forwarding.
---
Disclosure: This post contains affiliate links. If you purchase through these links, I may earn a small commission at no extra cost to you.
