Architecture: Edge Proxy (Hetzner VPS)

arch-edge-proxy Architecture

architecture active hetzner edge-proxy caddy

Edge Proxy: Hetzner VPS

Public-facing reverse proxy that terminates TLS for all custom domains and forwards traffic into the private Tailscale mesh. A Hetzner CPX11 VPS running Debian 12 in Ashburn (ash), managed by Terraform for provisioning and Salt for ongoing configuration.

Diagram




          
  

Components

Component Purpose Notes
Hetzner CPX11 VPS hosting the edge proxy Debian 12, Ashburn (ash) datacenter, public IPv4 178.156.129.142. Provisioned via <code>terraform/modules/hetzner-edge/</code>
Caddy Reverse proxy + TLS termination Automatic Let's Encrypt ACME. Installed via cloud-init, configured via Salt. Listens on ports 80 and 443
Tailscale Encrypted mesh connectivity to upstream services Hostname <code>edge-proxy</code>, IP 100.72.199.14, tag <code>tag:edge</code>. Ephemeral pre-authorized key from Terraform
Hetzner Firewall Network-level access control <code>edge-web</code> firewall: allows TCP 22, 80, 443 inbound from all sources. All other inbound dropped
GoDaddy DNS Public DNS A records Managed via <code>godaddy-tofu</code> provider in <code>terraform/dns.tf</code>. All domains point to <code>module.hetzner_edge.server_ipv4</code>, TTL 600s
Salt state (caddy) Configuration management for Caddyfile <code>salt/states/caddy/init.sls</code> renders Jinja2 template from pillar data, reloads Caddy on change
Salt pillar (caddy.sls) Data-driven domain routing table <code>salt/pillar/caddy.sls</code> defines domain, proxy_target, and www_redirect per site
Cloud-init First-boot provisioning <code>terraform/modules/hetzner-edge/cloud-init.yaml</code> installs Tailscale + Caddy, joins tailnet

Domains Routed

Domain Upstream (Tailscale) www redirect
palinks.app palinks.tail5b443a.ts.net:443 yes
landscaping-assistant.app landscaping-assistant.tail5b443a.ts.net:443 yes
prediction-assistant.com prediction-assistant.tail5b443a.ts.net:443 yes
westsidekingsandqueens.com westsidekingsandqueens.tail5b443a.ts.net:443 yes
paldocs.app paldocs.tail5b443a.ts.net:443 yes

Provisioning Flow

  • Terraform apply -- Creates the CPX11 server with cloud-init user data. A pre-authorized ephemeral Tailscale key is injected via templatefile().
  • Cloud-init -- Installs Tailscale + Caddy on first boot. Joins the tailnet as edge-proxy with tag:edge. Starts Caddy with the default config.
  • Salt highstate -- Renders Caddyfile.j2 from pillar data, writes to /etc/caddy/Caddyfile, and reloads Caddy. This is the ongoing config management path.

Caddy Configuration

The Caddyfile is rendered from a Jinja2 template (salt://caddy/Caddyfile.j2) driven by pillar data. Each site entry produces:
  • A reverse_proxy block targeting {proxy_target}:443 with TLS transport (SNI set to the upstream hostname)
  • The Host header is forwarded as-is via header_up Host {http.request.host}
  • An optional www.{domain} block that issues a 301 permanent redirect to the apex domain
Caddy handles TLS automatically via Let's Encrypt ACME -- no certificate management is needed.

Adding a New Domain

  • Add a godaddy_dns_record resource in terraform/dns.tf
  • Add a site entry in salt/pillar/caddy.sls
  • Run tofu apply then salt '*edge*' state.highstate

Known Gap: Salt-Minion Not Bootstrapped

The cloud-init template does not install or configure the salt-minion. Salt highstate cannot be pushed to the edge proxy until the minion is manually or automatically bootstrapped. This is a known gap blocking automated Caddy configuration updates.

Key Decisions

  • Caddy over Nginx/Traefik: Automatic ACME TLS with zero config. Single binary, no sidecar cert-manager needed.
  • Hetzner over home-hosted edge: Stable public IP, low latency (Ashburn), cheap CPX11 (~$4/mo). Keeps the k3s homelab off the public internet.
  • Tailscale mesh for upstream: No port forwarding or VPN tunnels. Caddy connects to upstream services over the encrypted Tailscale network using their stable DNS names.
  • Pillar-driven Caddyfile: Adding a domain is a data change (pillar entry + DNS record), not a template change. Keeps the Salt state generic and reusable.
  • Ephemeral Tailscale key: The auth key is single-use and ephemeral (node auto-expires if it goes offline). Prevents stale pre-auth keys from lingering.
  • lifecycle ignore_changes on user_data: Prevents Terraform from destroying/recreating the server when cloud-init content changes -- in-place updates are handled by Salt instead.

File Map