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_firewallopens 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.ymlbinds 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_cidrsmust be an explicit allowlist; thehetzner-firewallmodule's own variable validation rejects0.0.0.0/0/::/0outright. - No managed MySQL/Redis product exists on Hetzner -
provision_data_vm = falseplusexternal_mysql_host/external_redis_hostis the escape hatch to point at a real managed provider (PlanetScale/Aiven/RDS/etc.) instead, without changing anything else in this stack.
Related pages¶
- Environments
- Execution - how to safely inspect these resources without applying anything