Securely Expose Homelab Services with Cloudflare Tunnels Securely Expose Homelab Services with Cloudflare Tunnels

Securely Expose Homelab Services with Cloudflare Tunnels

If you run a homelab, you’ve probably faced this dilemma: you want to access your services from outside your home network, but opening ports on your router feels risky. Port forwarding exposes your public IP, requires constant certificate management, and creates an attack surface you’d rather avoid.

Cloudflare Tunnels solves this elegantly — no open ports, automatic HTTPS, built-in DDoS protection, and optional Zero Trust authentication. And it’s completely free.

In this guide, I’ll walk you through setting up a fully Dockerized Cloudflare Tunnel from scratch and securing it with Cloudflare Access.


What is a Cloudflare Tunnel?

A Cloudflare Tunnel (formerly known as Argo Tunnel) creates an outbound-only connection between a lightweight daemon called cloudflared running on your machine and Cloudflare’s global edge network. Instead of opening ports on your router, cloudflared initiates the connection outward — just like a regular client accessing the internet. Cloudflare then routes traffic from your domain through that tunnel to your internal service.

The connection is post-quantum encrypted, and each tunnel maintains four long-lived connections to two Cloudflare data centers for built-in redundancy. You can run multiple cloudflared replicas for additional high availability.

Source: Cloudflare Tunnel Documentation

Main advantages

  • Zero open router ports — no port forwarding or direct public IP exposure
  • Automatic HTTPS — Cloudflare handles TLS certificates; no Let’s Encrypt needed
  • DDoS protection — traffic passes through Cloudflare’s edge before reaching your service
  • Cloudflare Access integration — require authentication (Google, GitHub, Azure AD, etc.) before any request reaches your service
  • Free — everything works on Cloudflare’s free plan (up to 50 users in Access)

Why not just open ports?

The traditional approach to making an internal service internet-accessible involves:

  1. Opening a port on the router (port forwarding)
  2. Setting up DDNS for a stable hostname
  3. Obtaining and renewing TLS certificates (Let’s Encrypt)
  4. Hoping nobody discovers that open port

Each step adds attack surface. Even with best practices, any service directly exposed to the internet can be scanned and exploited. Cloudflare Tunnels eliminates all four problems at once.


Prerequisites

Before we start, make sure you have:

  • Docker and Docker Compose installed on your homelab machine
  • A domain configured in Cloudflare (nameservers pointing to Cloudflare)
  • A Cloudflare account with access to the Zero Trust Dashboard

Step 1 — Create a tunnel in the Zero Trust Dashboard

  1. Go to one.dash.cloudflare.com
  2. Navigate to Networks → Tunnels → Create a tunnel
  3. Select the Cloudflared tunnel type
  4. Give your tunnel a name (e.g., homelab-tunnel)
  5. Save the Tunnel Token displayed on screen — you’ll need it in the next step

You can also create tunnels via the CLI with cloudflared tunnel create, but the dashboard approach is simpler and recommended for getting started.


Step 2 — Configure your services with Docker Compose

Instead of installing cloudflared natively, we’ll run everything in containers. This keeps your homelab clean, reproducible, and easy to manage.

Create a project directory and a docker-compose.yml:

version: "3.8"
services:
# Cloudflare Tunnel — connects your
# Docker network to Cloudflare's edge
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=$TUNNEL_TOKEN
networks:
- homelab-net
depends_on:
- pihole
- homeassistant
# Pi-hole — DNS ad-blocker + dashboard
pihole:
image: pihole/pihole:latest
container_name: pihole
restart: unless-stopped
environment:
- TZ=Europe/Lisbon
- WEBPASSWORD=$PIHOLE_PASSWORD
volumes:
- ./pihole/etc-pihole:/etc/pihole
- ./pihole/etc-dnsmasq.d:/etc/dnsmasq.d
networks:
- homelab-net
# Home Assistant — home automation hub
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
restart: unless-stopped
environment:
- TZ=Europe/Lisbon
volumes:
- ./homeassistant/config:/config
network_mode: host
# Nextcloud — self-hosted file sync
nextcloud:
image: nextcloud:latest
container_name: nextcloud
restart: unless-stopped
environment:
- MYSQL_HOST=db
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=$NC_DB_USER
- MYSQL_PASSWORD=$NC_DB_PASS
volumes:
- ./nextcloud/html:/var/www/html
networks:
- homelab-net
depends_on:
- db
# MariaDB — database for Nextcloud
db:
image: mariadb:latest
container_name: mariadb
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=$NC_DB_USER
- MYSQL_PASSWORD=$NC_DB_PASS
volumes:
- ./mariadb/data:/var/lib/mysql
networks:
- homelab-net
networks:
homelab-net:
driver: bridge

Now create a .env file in the same directory:

Terminal window
TUNNEL_TOKEN=eyJhIjoi...paste-your-token-here... PIHOLE_PASSWORD=your-secure-password NC_DB_USER=nextcloud NC_DB_PASS=your-db-password MYSQL_ROOT_PASS=your-root-password

Never commit the .env file to git. Add it to your .gitignore.

Note on network_mode: host: Some services like Home Assistant need host networking for device discovery (mDNS, Bluetooth, etc.). If you prefer everything on the Docker bridge network, use networks: [homelab-net] and set up an mDNS reflector like Avahi.


Step 3 — Configure tunnel routes (Dashboard UI approach)

With the dashboard-managed tunnel (recommended), you configure public hostnames directly in the Zero Trust UI — no YAML file needed.

Go to Networks → Tunnels → [your tunnel] → Public Hostname and add:

SubdomainDomainServiceURL
piholeyourdomain.comHTTPhttp://pihole:80
hassyourdomain.comHTTPhttp://homeassistant:8123
cloudyourdomain.comHTTPhttp://nextcloud:80

Important: Because cloudflared and your services are on the same Docker network (homelab-net), you can reference containers by service name (e.g., pihole, nextcloud) instead of IP addresses. Docker’s internal DNS resolves these automatically.

For Proxmox or services running outside Docker (bare metal / VMs), use the internal IP instead:

SubdomainDomainServiceURL
proxmoxyourdomain.comHTTPShttps://192.168.1.10:8006

When proxying to an HTTPS origin with a self-signed cert (like Proxmox), enable No TLS Verify under Additional application settings → TLS → No TLS Verify.


Step 4 — (Alternative) Configure routes with config.yml

If you prefer managing routes declaratively via a config file (good for version control), mount a config.yml into the cloudflared container.

Update the cloudflared service in your docker-compose.yml:

cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --config /etc/cloudflared/config.yml run
volumes:
- ./cloudflared/config.yml:/etc/cloudflared/config.yml
- ./cloudflared/credentials.json:/etc/cloudflared/credentials.json
networks:
- homelab-net

And create ./cloudflared/config.yml:

./cloudflared/config.yml
tunnel: YOUR_TUNNEL_ID
credentials-file: /etc/cloudflared/credentials.json
ingress:
- hostname: pihole.yourdomain.com
service: http://pihole:80
- hostname: hass.yourdomain.com
service: http://homeassistant:8123
- hostname: cloud.yourdomain.com
service: http://nextcloud:80
- hostname: proxmox.yourdomain.com
service: https://192.168.1.10:8006
originRequest:
insecureSkipVerify: true
# Catch-all (required)
- service: http_status:404

Then create the DNS CNAME records: bash cloudflared tunnel route dns homelab-tunnel pihole.yourdomain.com cloudflared tunnel route dns homelab-tunnel hass.yourdomain.com cloudflared tunnel route dns homelab-tunnel cloud.yourdomain.com cloudflared tunnel route dns homelab-tunnel proxmox.yourdomain.com

See the official configuration file docs for all available options.


Step 5 — Bring everything up

Terminal window
docker compose up -d

Check that all containers are running:

Terminal window
docker compose ps

Check cloudflared logs to confirm the tunnel is connected:

Terminal window
docker logs cloudflared

You should see output like: INF Connection … registered connIndex=0 … INF Connection … registered connIndex=1 … INF Connection … registered connIndex=2 … INF Connection … registered connIndex=3 …

Four connections = full redundancy (two per Cloudflare data center).

Verify the tunnel status in the Zero Trust Dashboard under Networks → Tunnels. A green HEALTHY badge means everything is working.


Step 6 — Secure with Cloudflare Access (Zero Trust)

Right now, anyone who knows your subdomains can access your services. Let’s lock that down.

  1. In the Zero Trust Dashboard, go to Access → Applications → Add an application
  2. Select Self-hosted
  3. Enter the service hostname (e.g., pihole.yourdomain.com)
  4. Configure an Identity Provider:
ProviderSetup difficultyNotes
GoogleEasyBest for personal use; requires a Google OAuth client
GitHubEasyGreat for developers
Azure AD / Entra IDMediumGood if you already use Microsoft services
One-time PINEasiestCloudflare emails a 6-digit code; no IdP setup needed
  1. Create an access policy (Allow) with rules such as:
    • Include → emails ending in @yourdomain.com
    • Or Include → specific emails ([email protected])
  2. Save the application

Repeat this process for each service you want to protect.

From now on, when someone accesses pihole.yourdomain.com, they’ll be redirected to the identity provider’s login page. Only after successful authentication is the traffic forwarded to your Pi-hole container. Without a valid session token, the request never reaches your service.

Cloudflare Access is free for up to 50 users on the Zero Trust free plan. See pricing details.


Step 7 — Additional security layers

Beyond Cloudflare Access, you can add further protection:

LayerWhat it doesPlan availability
Cloudflare AccessRequires authentication before traffic reaches the serviceFree (50 users)
WAF RulesBlocks SQL injection, XSS, known malicious botsFree (managed rules)
Rate LimitingLimits requests per IP to mitigate brute-forceFree (basic)
Country BlocksRestricts access by country (e.g., only allow Portugal)Free
Bot ManagementChallenges automated traffic with CAPTCHAPro plan
Session DurationControls how long an Access session lasts before re-authFree

Learn more in the Cloudflare Zero Trust documentation.


Comparison with alternatives

FeatureCloudflare TunnelTailscalePort Forwarding + DDNS
Open router portsNoNoYes
Public IP exposedNoNoYes
Requires VPN clientNoYesNo
Automatic HTTPSYesNoNo (needs Let’s Encrypt)
Built-in auth (IdP)Yes (Access)Not nativelyNo
CostFree (50 users)Free (100 devices)Free
Best forExposing services with a custom domainPrivate personal access between devicesNot recommended for homelab

Considerations and limitations

  • Traffic passes through Cloudflare — Cloudflare terminates TLS at the edge (between the user and Cloudflare). Traffic between Cloudflare and your origin is also encrypted, but Cloudflare is in the path. For most homelab use cases, this is perfectly acceptable.
  • Latency — Traffic routing through Cloudflare’s edge adds a few milliseconds. Generally imperceptible.
  • Terms of Service — Cloudflare’s free plan prohibits serving non-HTML content (videos, large images) through the proxy. For admin panels and APIs, there’s no issue.
  • Cloudflare dependency — If Cloudflare has an outage, you lose remote access to your services. Always keep local network access as a fallback.
  • Docker networking — Services using network_mode: host (like Home Assistant) won’t be on the homelab-net bridge. Reference them by host.docker.internal or the host’s LAN IP in your tunnel config.

Conclusion

Cloudflare Tunnels combined with Access offer, for free, a Zero Trust architecture that would be impractical to build from scratch for most homelabbers. No open ports, automatic HTTPS, federated authentication, and everything running in Docker containers — clean, reproducible, and easy to manage.

If you don’t already have a domain on Cloudflare, this is the perfect excuse to get one.


References


← Back to blog