Exposing Homelab Services with Cloudflare Tunnels
I recently purchased a new MacMini to serve as my primary workstation. While setting up a homelab on this was a no-brainer, I wanted to ensure that I am able to access the services outside my home network. Unlike my previous attempts at homelabbing, this time, I was equipped with past experiences and a beefier machine to keep me engaged. Key objectives included enforcing HTTPS for all traffic (both local and external), and avoiding direct exposure of router ports. Since my domain is managed by Cloudflare DNS, I decided to leverage Cloudflare Tunnels for external access. This log documents the steps taken and solutions to common hiccups for future reference.
Core Design
The setup relies on two main traffic flows:
-
External Access:
- User navigates to
service.mydomain.com
service.mydomain.com
. - DNS resolves via Cloudflare, directing the request through a Cloudflare Tunnel.
- The
cloudflared
cloudflared
client (Docker container) on my workstation receives this traffic. cloudflared
cloudflared
forwards the request to Traefik (another Docker container) over the internal Docker network (HTTPS).- Traefik, acting as a reverse proxy, handles SSL termination using Let's Encrypt certificates and routes the request to the appropriate backend application container.
- User navigates to
-
Internal (LAN) Access:
- User navigates to
service.mydomain.com
service.mydomain.com
. - My local router's custom DNS entry resolves the domain directly to my workstation's local IP.
- The browser connects directly to Traefik (listening on port 443) on the workstation.
- Traefik handles SSL and routes to the backend application.
- User navigates to
Cloudflare Configuration - Tokens and Tunnels
Cloudflare API Token for Traefik (DNS-01 Challenge)
Traefik needs a Cloudflare API token to automatically manage Let's Encrypt certificates using Cloudflare's DNS. Here's how to create one:
Steps:
- Go to Cloudflare Dashboard -> My Profile -> API Tokens.
- Click "Create Token."
- Choose the "Edit zone DNS" template.
- Set the following Permissions:
Zone - DNS - Edit
Zone - DNS - Edit
- Set Zone Resources:
Include - All zones from an account
Include - All zones from an account
- Generate the token and store it securely. This will be needed in the
docker-compose.yml
docker-compose.yml
file.
Cloudflare Tunnel Creation
To establish a secure connection from Cloudflare to my local network, I created a Cloudflare Tunnel. This tunnel acts as a secure conduit for traffic between Cloudflare's edge and my local cloudflared
cloudflared
client.
Since I wanted to run cloudflared
cloudflared
inside Docker on my main server, I used a separate machine to create the tunnel. This avoids needing to install cloudflared
cloudflared
directly on my workstation.
On the separate machine:
cloudflared login
cloudflared login
- Authenticated with Cloudflare and selectedmydomain.com
mydomain.com
.cloudflared tunnel create homelab-main-tunnel
cloudflared tunnel create homelab-main-tunnel
- Created a new tunnel.- Noted the Tunnel ID (UUID format) provided.
cloudflared tunnel token homelab-main-tunnel
cloudflared tunnel token homelab-main-tunnel
- Generated the specific token for this tunnel.
Keep this token handy; it will be used in the docker-compose.yml
docker-compose.yml
file for the cloudflared
cloudflared
service.
Local Project Structure and Traefik Configuration
I set up the following directory structure on my workstation:
~/homelab/
├── docker-compose.yml
├── traefik/
│ ├── traefik.yml # Traefik static configuration
│ ├── acme.json # For Let's Encrypt certificate storage
│ └── logs/ # Persistent logs for Traefik
└── cloudflared/
└── config.yml # Ingress rules for the cloudflared client
~/homelab/
├── docker-compose.yml
├── traefik/
│ ├── traefik.yml # Traefik static configuration
│ ├── acme.json # For Let's Encrypt certificate storage
│ └── logs/ # Persistent logs for Traefik
└── cloudflared/
└── config.yml # Ingress rules for the cloudflared client
Traefik Static Configuration
This file defines Traefik's fundamental settings, entrypoints, and certificate resolvers.
global:
checkNewVersion: true
sendAnonymousUsage: false # Disabled for privacy
api:
dashboard: true # Enable the Traefik web UI
log:
level: INFO
accessLog:
filePath: "/var/log/traefik/access.log"
entryPoints:
web: # HTTP entrypoint (port 80)
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
# docker network inspect proxy_network
# IPAM -> Config -> Subnet
forwardedHeaders: # Trust headers from known internal proxies
trustedIPs:
- "172.16.0.0/12"
- "192.168.0.0/16"
- "10.0.0.0/8"
websecure: # HTTPS entrypoint (port 443)
address: ":443"
http:
tls:
certResolver: cloudflareResolver
domains:
- main: "mydomain.com"
sans:
- "*.mydomain.com" # Wildcard certificate for subdomains
providers:
docker:
exposedByDefault: false # Only manage containers with explicit Traefik labels
network: proxy_network # The Docker network Traefik will monitor
certificatesResolvers:
cloudflareResolver:
acme:
email: "[email protected]"
storage: "/etc/traefik/acme.json"
dnsChallenge:
provider: cloudflare
global:
checkNewVersion: true
sendAnonymousUsage: false # Disabled for privacy
api:
dashboard: true # Enable the Traefik web UI
log:
level: INFO
accessLog:
filePath: "/var/log/traefik/access.log"
entryPoints:
web: # HTTP entrypoint (port 80)
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
# docker network inspect proxy_network
# IPAM -> Config -> Subnet
forwardedHeaders: # Trust headers from known internal proxies
trustedIPs:
- "172.16.0.0/12"
- "192.168.0.0/16"
- "10.0.0.0/8"
websecure: # HTTPS entrypoint (port 443)
address: ":443"
http:
tls:
certResolver: cloudflareResolver
domains:
- main: "mydomain.com"
sans:
- "*.mydomain.com" # Wildcard certificate for subdomains
providers:
docker:
exposedByDefault: false # Only manage containers with explicit Traefik labels
network: proxy_network # The Docker network Traefik will monitor
certificatesResolvers:
cloudflareResolver:
acme:
email: "[email protected]"
storage: "/etc/traefik/acme.json"
dnsChallenge:
provider: cloudflare
Let's Encrypt Storage
Created an empty file and set permissions for Traefik to store its certificates:
touch ~/homelab/traefik/acme.json
chmod 600 ~/homelab/traefik/acme.json
touch ~/homelab/traefik/acme.json
chmod 600 ~/homelab/traefik/acme.json
Cloudflared Client Configuration
This configures the cloudflared
cloudflared
Docker container to forward incoming tunnel traffic to Traefik's HTTPS endpoint.
# ~/homelab/cloudflared/config.yml
ingress:
# For traffic matching my domain, forward to Traefik's HTTPS port.
# Traefik will then handle routing based on hostname.
- hostname: "*.mydomain.com"
service: https://traefik:443 # 'traefik' is the Docker service name for Traefik
originRequest:
# Traefik's cert is for *.mydomain.com, not the internal hostname 'traefik'.
# noTLSVerify is safe for this internal hop on a trusted Docker network.
noTLSVerify: true
# Default rule for any other traffic hitting the tunnel directly.
- service: http_status:404
# ~/homelab/cloudflared/config.yml
ingress:
# For traffic matching my domain, forward to Traefik's HTTPS port.
# Traefik will then handle routing based on hostname.
- hostname: "*.mydomain.com"
service: https://traefik:443 # 'traefik' is the Docker service name for Traefik
originRequest:
# Traefik's cert is for *.mydomain.com, not the internal hostname 'traefik'.
# noTLSVerify is safe for this internal hop on a trusted Docker network.
noTLSVerify: true
# Default rule for any other traffic hitting the tunnel directly.
- service: http_status:404
Rationale for https://traefik:443
https://traefik:443
: I faced issues (HTTP status 418) when I tried to route to the Traefik http port. The mentioned approach routes tunnel traffic directly to Traefik's secure entrypoint. I don't know if this is the best practice, but it worked for me. The noTLSVerify
noTLSVerify
option is necessary because the hostname traefik
traefik
does not match the certificate's SAN (Subject Alternative Name). This is acceptable since the traffic is internal and on a trusted Docker network.
Docker Compose Orchestration
The docker-compose.yml
docker-compose.yml
file defines and links the Traefik, cloudflared
cloudflared
, and application services.
version: "3.8"
services:
traefik:
image: "traefik:v3"
container_name: "traefik_reverse_proxy"
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- .env
networks:
- proxy_network
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" # Allows Traefik to discover services
- "./traefik/traefik.yml:/etc/traefik/traefik.yml:ro"
- "./traefik/acme.json:/etc/traefik/acme.json"
- "./traefik/logs:/var/log/traefik"
labels:
# Configuration for accessing the Traefik dashboard itself
- "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`dashboard.mydomain.com`)"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard.tls.certresolver=cloudflareResolver"
- "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"
cloudflared:
image: "cloudflare/cloudflared:latest"
container_name: "cloudflared_tunnel_client"
restart: unless-stopped
networks:
- proxy_network
env_file: .env
dns:
- 1.1.1.1
- 1.0.0.1
volumes:
- "./cloudflared/config.yml:/etc/cloudflared/config.yml:ro"
# The command uses the mounted config.yml and the tunnel token.
command: tunnel --no-autoupdate --edge-ip-version auto --config /etc/cloudflared/config.yml run
depends_on:
- traefik
whoami_app:
image: "traefik/whoami"
container_name: "whoami_service_instance"
restart: unless-stopped
networks:
- proxy_network
labels:
- "traefik.enable=true"
# Router definition for this application
- "traefik.http.routers.whoami-app-router.rule=Host(`whoami.mydomain.com`)"
- "traefik.http.routers.whoami-app-router.entrypoints=websecure"
- "traefik.http.routers.whoami-app-router.tls.certresolver=cloudflareResolver"
- "traefik.http.routers.whoami-app-router.service=whoami-app-service"
# Service definition pointing to the container's port
- "traefik.http.services.whoami-app-service.loadbalancer.server.port=80" # whoami listens on port 80
networks:
proxy_network:
name: proxy_network
driver: bridge
version: "3.8"
services:
traefik:
image: "traefik:v3"
container_name: "traefik_reverse_proxy"
restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file:
- .env
networks:
- proxy_network
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" # Allows Traefik to discover services
- "./traefik/traefik.yml:/etc/traefik/traefik.yml:ro"
- "./traefik/acme.json:/etc/traefik/acme.json"
- "./traefik/logs:/var/log/traefik"
labels:
# Configuration for accessing the Traefik dashboard itself
- "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`dashboard.mydomain.com`)"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard.tls.certresolver=cloudflareResolver"
- "traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080"
cloudflared:
image: "cloudflare/cloudflared:latest"
container_name: "cloudflared_tunnel_client"
restart: unless-stopped
networks:
- proxy_network
env_file: .env
dns:
- 1.1.1.1
- 1.0.0.1
volumes:
- "./cloudflared/config.yml:/etc/cloudflared/config.yml:ro"
# The command uses the mounted config.yml and the tunnel token.
command: tunnel --no-autoupdate --edge-ip-version auto --config /etc/cloudflared/config.yml run
depends_on:
- traefik
whoami_app:
image: "traefik/whoami"
container_name: "whoami_service_instance"
restart: unless-stopped
networks:
- proxy_network
labels:
- "traefik.enable=true"
# Router definition for this application
- "traefik.http.routers.whoami-app-router.rule=Host(`whoami.mydomain.com`)"
- "traefik.http.routers.whoami-app-router.entrypoints=websecure"
- "traefik.http.routers.whoami-app-router.tls.certresolver=cloudflareResolver"
- "traefik.http.routers.whoami-app-router.service=whoami-app-service"
# Service definition pointing to the container's port
- "traefik.http.services.whoami-app-service.loadbalancer.server.port=80" # whoami listens on port 80
networks:
proxy_network:
name: proxy_network
driver: bridge
DNS and Network Adjustments
At this point, the local setup is complete. However, we need to tell Cloudflare how to route traffic to our tunnel. When a request comes to the subdomain whoami.mydomain.com
whoami.mydomain.com
, it should be directed to the tunnel.
Cloudflare Public DNS Records
To add the DNS records for the tunnel, we need to create a CNAME record in Cloudflare's DNS settings. Following command works great -
cloudflared tunnel route dns homelab-main-tunnel whoami.mydomain.com
cloudflared tunnel route dns homelab-main-tunnel whoami.mydomain.com
This command associates the tunnel with the subdomain whoami.mydomain.com
whoami.mydomain.com
. Cloudflare will now route traffic for this domain through the tunnel to my local cloudflared
cloudflared
client.
Local Router DNS for Split-Brain DNS
To ensure that requests from my local network to whoami.mydomain.com
whoami.mydomain.com
do not go out to the internet and back in, I set up a split-brain DNS configuration. This allows local devices to resolve the domain directly to my workstation's local IP address. My router provides this functionality, allowing me to create custom DNS entries for specific domains.
dashboard.mydomain.com -> 192.168.X.Y (my workstation's LAN IP)
whoami.mydomain.com -> 192.168.X.Y
dashboard.mydomain.com -> 192.168.X.Y (my workstation's LAN IP)
whoami.mydomain.com -> 192.168.X.Y
This ensures local traffic stays within the LAN.
Cloudflare SSL/TLS Encryption Mode
Cloudflare provides several SSL/TLS encryption modes. For my setup, I chose the "Full (Strict)" mode. This means that Cloudflare will only connect to my origin server (the cloudflared
cloudflared
client) if it can establish a secure connection using a valid SSL certificate. This is crucial for ensuring that all traffic between Cloudflare and my local network is encrypted, even if the traffic is not going out to the public internet. I have also noticed that this mode is needed to avoid the TOO_MANY_REDIRECTS
TOO_MANY_REDIRECTS
error.
In Cloudflare Dashboard -> mydomain.com -> SSL/TLS -> Overview, ensure that the SSL/TLS encryption mode is set to Full (Strict).
Hiccups and Solutions
Too many redirects
In past, I observed the error ERR_TOO_MANY_REDIRECTS
ERR_TOO_MANY_REDIRECTS
almost always when Cloudflare's SSL/TLS mode was not set to "Full (Strict)". This time, apart from configuring this setting, I noticed that my docker subnet was not the standard IP range, so Traefik was not trusting X-Forwarded-Proto from cloudflared. I fixed it by setting forwardedHeaders.trustedIPs
forwardedHeaders.trustedIPs
on the HTTP entrypoint to allow my docker subnet.
418 "I'm a teapot"
This was an interesting one. I am not really sure why this happened, but if I didn't set the traffic forwarding to traefik:443
traefik:443
in the cloudflared
cloudflared
config, I got this error. I suspect it was because the request was not being routed correctly, and Traefik was returning a default response for unrecognized traffic.
404 Not Found
This error occurred when I tried to access the services without the correct router configuration. I had to ensure that the router for the dashboard was correctly set up in the docker-compose.yml
docker-compose.yml
file. Almost always, this was due to a missing or incorrect Traefik label in the service definition.
MacOS going to sleep
I noticed that my Mac mini would go to sleep after a period of inactivity, causing the tunnel to drop. To prevent this, I set the energy saver settings to "Wake for network access" This ensures that the MacMini is always listening for network connections and the tunnel stays open.
Though not applicable to my case, another important thing to note is that if you close the lid of your MacBook, it will go to sleep and the tunnel will drop. To prevent this, you can use a tool like Amphetamine or InsomniaX to keep your Mac awake while the lid is closed. Else, you can also keep the MBP plugged in to power and connected to an external display (or a virtual display adapter), which will prevent it from sleeping.
Conclusion
This setup allows me to securely access my homelab services from anywhere while keeping my local network safe. The combination of Cloudflare Tunnels, Traefik, and Docker provides a robust solution for self-hosting applications with HTTPS support. I hope this log helps others in setting up a similar configuration and serves as a reference for my future endeavors.