Skip to content

Hetzner

Purpose

Document the Hetzner Cloud compute/network/firewall resources this project's Terraform actually provisions.

Provider

Hetzner Cloud - selected for compute; Cloudflare handles DNS/CDN/security/object storage (already the app's chosen provider for R2 media storage).

Resources, by root

environments/platform/ (applies once, shared by staging + production)

Resource Count Purpose
hcloud_network + hcloud_network_subnet 1 The one shared private network every VM below attaches to
hcloud_ssh_key 1 Access to the shared K3s node specifically
hcloud_firewall (k3s) 1 22 (allowlisted), 80/443 (Cloudflare edge IPs only), 6443 (allowlisted)
hcloud_server (k3s node) 1 Runs K3s via cloud-init on boot - hosts both environments, namespace-separated

environments/staging/ and environments/production/ (each independent)

Resource Count Purpose
hcloud_ssh_key 1 Access to this environment's own data VM only
hcloud_firewall (data) 0 or 1 Only if provision_data_vm = true - 22 (allowlisted), 3306/6379 reachable only from the shared K3s node's private IP (a /32), never the whole subnet
hcloud_server (data VM) 0 or 1 MySQL 8.2 + Redis 7.2 via Docker Compose (cloud-init), private-network-bound

environments/shared/ (independent of everything else)

Resource Count Purpose
hcloud_ssh_key, hcloud_firewall, hcloud_server 0 or 1 each Optional Jenkins controller VM (provision_jenkins_vm = true)

Private IP addressing

.10  platform's shared K3s node
.20  staging's data VM
.30  production's data VM
.40  dev's data VM (scaffold, unapplied)

The hetzner-server module is reused, not copy-pasted

One module (terraform/modules/hetzner-server) provisions the shared K3s node, every environment's data VM, and the optional Jenkins VM - parameterized by size, network attachment, and cloud-init template, rather than near-identical resource blocks repeated per caller. hetzner-firewall the same way, with a different rule set per caller.

Security design

  • Default-deny everywhere. Every hcloud_firewall opens only SSH (allowlisted), the Kubernetes API (allowlisted), and 80/443 (Cloudflare edge IPs only, K3s node alone). The data VM opens nothing to the public internet at all.
  • MySQL/Redis are never publicly reachable, on two independent layers: the data VM's firewall has no rule exposing 3306/6379 beyond the private subnet, and the cloud-init-rendered docker-compose.yml binds those ports to the private IP specifically ("${private_ipv4}:3306:3306", never "3306:3306") - so even a future firewall mistake couldn't expose a port nothing is listening on publicly to begin with.
  • SSH access has no wide-open default - allowed_ssh_cidrs must be an explicit allowlist; the hetzner-firewall module's own variable validation rejects 0.0.0.0/0/::/0 outright.
  • No managed MySQL/Redis product exists on Hetzner - provision_data_vm = false plus external_mysql_host/external_redis_host is the escape hatch to point at a real managed provider (PlanetScale/Aiven/RDS/etc.) instead, without changing anything else in this stack.