As you may know from my Homelab page, I run two personal servers:

  1. A Hetzner VPS for services that demand 100% uptime.

  2. 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!