Architecture: Edge Proxy (Hetzner VPS)
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-proxywithtag:edge. Starts Caddy with the default config. - Salt highstate -- Renders
Caddyfile.j2from 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_proxyblock targeting{proxy_target}:443with TLS transport (SNI set to the upstream hostname) - The
Hostheader is forwarded as-is viaheader_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_recordresource interraform/dns.tf - Add a site entry in
salt/pillar/caddy.sls - Run
tofu applythensalt '*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
Related
- Architecture: Kubernetes Deployment -- downstream services that the edge proxy forwards to