As you may know from my Homelab page, I run two personal servers:
A Hetzner VPS for services that demand 100% uptime.
My home rig for services that can afford a little downtime (you know, when the cat decides the fiber cable is a chew toy 😼).
My challenge was to keep the services on my home server completely isolated from the public internet. I didn't want its public IP address visible in any DNS records, nor did I want ports 80 or 443 open directly to the outside world.
The Solution: Tailscale and Caddy as a Private Reverse Proxy
I recently set up a Tailscale network (Tailnet) between the Caddy instances on both my home server and my Hetzner VPS. This clever configuration allows my Hetzner VPS's Caddy instance to act as a secure, public-facing reverse proxy for all my resources, including those running at home!
The Security Benefits
This setup provides two major security wins:
IP Anonymity: Only my VPS's public IP address is visible. My home server is completely hidden and isolated from the internet.
Flexible Security: I run an Authentik instance on my VPS, which can now securely communicate with my home Caddy instance via the Tailnet. This means I can use my central identity provider to secure access to my home resources, too!
How It Works: The Flow
This diagram simplifies the new request path:
Web Request → Caddy (Hetzner VPS) → *.domain-a.com → Container on VPS → *.domain-b.com → Caddy (Home) via Tailnet → Container on Home Server
In essence, requests for domain-b are tunneled privately and securely from the public VPS to my hidden home server, all thanks to Tailscale's zero-config VPN magic!
⚙️ Docker Compose Configuration (Hetzner VPS)
This is the configuration that ties the public-facing Caddy instance to the private Tailnet.
The key is running Caddy in the same network namespace as the Tailscale container (network_mode: service:caddy-tailscale). This allows Caddy to see other devices on the Tailnet by their internal hostname (caddy-home).
services:
caddy:
# we build our own image based on lucaslorentz/caddy-docker-proxy:ci-alpine
# we add the requirements for Cloudflare Wildcard certificates management
build:
context: .
dockerfile: Dockerfile
container_name: caddy
depends_on:
- caddy-tailscale
network_mode: service:caddy-tailscale # caddy has access to other devices on the tailnet
labels:
caddy.acme_dns: cloudflare ${CF_API_TOKEN}
caddy.email: ${EMAIL}
caddy_0: "*.${DOMAIN}"
caddy_0.reverse_proxy: caddy-home:80 # forward all of these requests via the tailnet to my home
env_file:
- .env
environment:
- CADDY_INGRESS_NETWORKS=proxy-network
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data:/data
- ./certs:/caddy-certs
restart: always
caddy-tailscale:
image: tailscale/tailscale
container_name: caddy-tailscale
hostname: caddy-tailscale
ports:
- 80:80
- 443:443 # expose Caddy's ports
volumes:
- tailscale_state:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
restart: unless-stopped
networks:
- proxy-network # needed to access other containers
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_EXTRA_ARGS=--advertise-tags=tag:container --stateful-filtering=false
- TS_STATE_DIR=/var/lib/tailscale
- TS_ACCEPT_DNS=true
- TS_USERSPACE=false
volumes:
tailscale_state: null
networks:
proxy-network:
name: proxy-network
external: true
Note: The Caddy configuration on my home server is nearly identical. It connects to the Tailnet and is reachable by the hostname caddy-home.
While setting this up required a good amount of troubleshooting, the final result is a surprisingly simple, elegant, and robust way to enhance both security and privacy for a distributed homelab!