Home Lab Part 1: Proxmox, Cloudflare Tunnel, and Traefik
This is the first in a series of posts documenting my home lab setup. Before running any services, you need three things: a hypervisor, a way to reach the server from the internet, and a reverse proxy to route traffic. This post covers all three.
The Starting Point
My server sits behind a residential internet connection with CGNAT — meaning no public IPv4 address. Port forwarding isn’t an option. But with a Cloudflare Tunnel, that doesn’t matter.
Proxmox
Proxmox VE runs on the bare metal. It’s a Debian-based hypervisor that manages VMs and containers.
I initially tried running services in a privileged LXC container, but certain CPU instructions weren’t available — Docker and some applications failed. Switching to a full VM with CPU type host (passthrough of host CPU instructions) solved it.
The services VM (Ubuntu minimal) gets a static IP at 192.168.178.10 and runs all Docker workloads.
Storage Layout
The storage is split by speed requirements:
- NVMe (238 GB) — Proxmox OS and LVM thin pool
- ZFS SSD pool (928 GB) — VM root filesystem and databases (anything that needs fast I/O)
- ZFS HDD mirror (2 × 6 TB SAS) — photos, files, and backups (bulk storage)
ZFS was chosen for its built-in mirroring, snapshots, and data integrity checks. A ZFS snapshot of the HDD pool gives you a complete, atomic backup of all file-based services.
Cloudflare Tunnel
Since there’s no public IP, a Cloudflare Tunnel (cloudflared) creates an outbound connection from the server to Cloudflare’s edge network. Incoming requests to *.johannes-kling.de are routed through this tunnel to the server — no open ports, no VPS, no dynamic DNS.
cloudflared runs on the Proxmox host (not inside the VM) and forwards traffic to Traefik inside the VM:
# /etc/cloudflared/config.yml
ingress:
- hostname: "*.johannes-kling.de"
service: http://192.168.178.10:8080
- service: http_status:404
A wildcard DNS record (* → tunnel CNAME) in Cloudflare handles all subdomains.
Lessons Learned
- Always use CLI-created tunnels, not dashboard-created ones. Dashboard tunnels fetch their config remotely and ignore your local ingress rules.
- Cloudflare’s free SSL wildcard covers one level (
*.domain.com) but not two (*.sub.domain.com). - If you also use Cloudflare Workers or Pages on the same domain, use Workers Custom Domains — they take priority over the wildcard tunnel. Regular DNS records or Workers Routes will conflict.
Traefik
Traefik is the reverse proxy running inside the services VM. It listens on port 8080 (the tunnel target) and routes requests to the right Docker container based on labels.
# traefik.yml (static config)
entryPoints:
web:
address: ":8080"
providers:
docker:
exposedByDefault: false
api:
dashboard: true
Services register themselves via Docker Compose labels — no manual config per service:
labels:
- "traefik.enable=true"
- "traefik.http.routers.myservice.rule=Host(`myservice.johannes-kling.de`)"
No Local TLS
Cloudflare handles HTTPS termination (SSL mode: Flexible). Traefik serves everything over plain HTTP internally. But this means every service that checks the protocol header needs a middleware to set X-Forwarded-Proto:
- "traefik.http.middlewares.myservice-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
Without this, apps will see HTTP and redirect-loop trying to force HTTPS.
Dashboard Access
The Traefik dashboard at traefik.johannes-kling.de is protected by Cloudflare Access (Zero Trust) with Google OAuth — no credentials stored on the server.
The Result
With this foundation in place, adding a new service means:
- Write a
docker-compose.ymlwith Traefik labels - Run
docker compose up -d - It’s live at
servicename.johannes-kling.de
No DNS changes, no certificate management, no port mapping. The next posts will cover the actual services running on this stack — starting with Immich for photo management.
Remote Access
For SSH and admin access, Tailscale runs on the Proxmox host. This gives me a stable IP (100.x.x.x) regardless of where I am — no VPN server to maintain, works through any NAT.