Feat/open tofu #8

Merged
luke-else merged 24 commits from feat/open-tofu into main 2026-07-22 00:10:01 +00:00
9 changed files with 109 additions and 5 deletions
Showing only changes of commit ae34a78a1d - Show all commits
+27
View File
@@ -17,6 +17,26 @@ locals {
# IDs for every named key in var.ssh_key_names - installed on every server.
ssh_key_ids = [for k in data.hcloud_ssh_key.main : k.id]
# Every A record currently referenced by a Traefik Host() rule anywhere under
# services/ - kept in sync with the actual compose files, not aspirational.
# "dev.luke-else.co.uk" is a *website* served from prod (a dev-tagged build of
# luke-else.co.uk), not the dev server - don't confuse the two.
dns_records = [
{ zone = "luke-else.co.uk", name = "@", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "dev", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "metarius", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "traefik", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "status", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "bitwarden", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "rd", value = module.prod.ipv4 },
{ zone = "luke-else.co.uk", name = "git", value = module.dev.ipv4 },
{ zone = "luke-else.co.uk", name = "traefik.cicd", value = module.dev.ipv4 },
{ zone = "luke-else.co.uk", name = "vpn", value = module.vpn.ipv4 },
{ zone = "luke-else.co.uk", name = "traefik.vpn", value = module.vpn.ipv4 },
{ zone = "divine-couture.co.uk", name = "www", value = module.prod.ipv4 },
{ zone = "snexo.co.uk", name = "@", value = module.prod.ipv4 },
]
}
module "network" {
@@ -77,3 +97,10 @@ module "vpn" {
bootstrap_script = local.bootstrap_script
ssh_private_key_path = var.ssh_private_key_path
}
module "dns" {
source = "./modules/dns"
zone_names = var.dns_zones
records = local.dns_records
}
+28
View File
@@ -0,0 +1,28 @@
# One Hetzner DNS zone per domain in var.zone_names. Hetzner becomes
# authoritative for these once the domain's registrar NS records are pointed at
# the nameservers in the `nameservers` output - that step happens at whichever
# registrar the domain was bought through, and can't be done from here.
resource "hcloud_zone" "this" {
for_each = toset(var.zone_names)
name = each.value
mode = "primary"
delete_protection = true # API-level guard, in addition to prevent_destroy below
lifecycle {
prevent_destroy = true
}
}
resource "hcloud_zone_rrset" "a" {
for_each = { for r in var.records : "${r.zone}/${r.name}" => r }
zone = hcloud_zone.this[each.value.zone].id
name = each.value.name
type = "A"
ttl = each.value.ttl
records = [
{ value = each.value.value }
]
}
+9
View File
@@ -0,0 +1,9 @@
output "zone_ids" {
description = "Map of domain name to Hetzner zone ID."
value = { for name, z in hcloud_zone.this : name => z.id }
}
output "nameservers" {
description = "Map of domain name to the nameservers Hetzner assigned it - point your registrar's NS records at these to delegate DNS."
value = { for name, z in hcloud_zone.this : name => z.authoritative_nameservers.assigned }
}
+15
View File
@@ -0,0 +1,15 @@
variable "zone_names" {
description = "Domains to create as Hetzner DNS zones."
type = list(string)
}
variable "records" {
description = "A records to create across the zones in var.zone_names. Use name = \"@\" for the zone apex."
type = list(object({
zone = string
name = string
value = string
ttl = optional(number, 300)
}))
default = []
}
+5
View File
@@ -17,3 +17,8 @@ output "prod_private_ipv4" {
output "vpn_ipv4" {
value = module.vpn.ipv4
}
output "dns_nameservers" {
description = "Nameservers Hetzner assigned each zone - point each domain's registrar NS records at these to delegate DNS to Hetzner."
value = module.dns.nameservers
}
+1
View File
@@ -24,3 +24,4 @@ ssh_private_key_path = "~/.ssh/id_ed25519"
# allowed_ssh_source_ips = ["203.0.113.4/32"]
# deploy_user = "deploy"
# dev_runner_count = 3
# dns_zones = ["luke-else.co.uk", "divine-couture.co.uk", "snexo.co.uk"]
+6
View File
@@ -97,3 +97,9 @@ variable "dev_runner_count" {
type = number
default = 1
}
variable "dns_zones" {
description = "Domains to manage as Hetzner DNS zones. Point each domain's registrar NS records at tofu output dns_nameservers for Hetzner to actually become authoritative."
type = list(string)
default = ["luke-else.co.uk", "divine-couture.co.uk", "snexo.co.uk"]
}
+1 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45"
version = "~> 1.54" # hcloud_zone / hcloud_zone_rrset (DNS) require >= 1.54.0
}
local = {
source = "hashicorp/local"
+17 -4
View File
@@ -81,7 +81,8 @@ architecture-beta
│ │ └── templates/
│ │ └── runners-docker-compose.yml.tftpl # shape of each generated runner service
│ ├── prod/ # prod server + firewall + volume
── vpn/ # vpn server + firewall (no private network, no volume)
── vpn/ # vpn server + firewall (no private network, no volume)
│ └── dns/ # Hetzner DNS zones + A records for every domain in var.dns_zones
├── services/ # Docker Compose stacks, grouped by which server they run on
│ ├── dev/ # Gitea + CI runner + Traefik (git.luke-else.co.uk, cicd.luke-else.co.uk)
│ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik
@@ -100,7 +101,7 @@ Each of `services/dev`, `services/prod`, `services/vpn` follows the same convent
- A [Hetzner Cloud](https://console.hetzner.cloud/) project and API token
- One or more SSH keys uploaded to that project (Console → Security → SSH Keys) - all are installed on every server - plus the private key matching one of them available locally (OpenTofu uses it once per server to run the post-install bootstrap — see below)
- [OpenTofu](https://opentofu.org/docs/intro/install/) `>= 1.6.0`
- DNS records for the domains in [Service inventory](#service-inventory) pointed at the relevant server's public IP
- Ownership of the domains in `var.dns_zones` at whatever registrar they're bought through, so you can point their NS records at Hetzner (see [Managing DNS](#managing-dns) — the zones and records themselves are created for you)
Docker + the Compose plugin no longer need installing by hand — the bootstrap script below handles that.
@@ -117,12 +118,13 @@ tofu plan
tofu apply
```
This creates, via `module.network` / `module.dev` / `module.prod` / `module.vpn` in `infra/main.tf`:
This creates, via `module.network` / `module.dev` / `module.prod` / `module.vpn` / `module.dns` in `infra/main.tf`:
- `hcloud_network` + subnet, shared by `dev` and `prod` (`modules/network`)
- one `hcloud_server` + `hcloud_firewall` per host, scoped to the ports each host actually uses (`modules/dev`, `modules/prod`, `modules/vpn`)
- one `hcloud_volume` each for `dev` and `prod` (`modules/dev`, `modules/prod`; `vpn` has none)
- one Hetzner DNS zone per domain in `var.dns_zones`, plus every A record in [Service inventory](#service-inventory) (`modules/dns` — see [Managing DNS](#managing-dns))
Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vpn_ipv4`.
Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vpn_ipv4`, `tofu output dns_nameservers`.
`terraform.tfvars` and any `*.tfvars` file are gitignored — never commit real values there. Defaults for server sizes, locations, and IP ranges live in `infra/variables.tf` and are passed down into the modules from `infra/main.tf`; override them per-environment via `terraform.tfvars`.
@@ -155,6 +157,16 @@ Registration tokens are handled automatically, not baked into the generated file
If step 3 is run before you're connected to the VPN, the `dev`/`prod` provisioners will fail and Terraform will mark those servers tainted — re-running `tofu apply` once connected will recreate and re-bootstrap them.
### Managing DNS
`module.dns` (in `infra/modules/dns`) creates one Hetzner DNS zone per domain in `var.dns_zones` (default: `luke-else.co.uk`, `divine-couture.co.uk`, `snexo.co.uk`) and an `hcloud_zone_rrset` A record for every hostname currently referenced by a Traefik `Host()` rule anywhere under `services/` — kept in sync with `local.dns_records` in `infra/main.tf`, pointed at whichever of `dev`/`prod`/`vpn` actually serves it. Zones have `prevent_destroy = true`, matching the volumes — losing one deletes every record in it.
Creating the zone doesn't make Hetzner authoritative for the domain by itself: you still need to point that domain's NS records at Hetzner's nameservers at whichever registrar it's registered through. Run `tofu output dns_nameservers` after applying to get the exact nameservers per domain, and set those as the domain's NS records at the registrar. Propagation can take a while depending on the registrar and the domain's previous NS TTL.
Adding a new subdomain: add an entry to `local.dns_records` in `infra/main.tf` (zone, name, and the target `module.<host>.ipv4`) and re-run `tofu apply` — don't hand-create records in the Hetzner console, they'll drift from state. Note `name = "@"` is Hetzner's convention for a zone's apex record (e.g. bare `snexo.co.uk`), not an empty string.
Only A records for IPv4 are managed here — none of the modules currently track servers' IPv6 addresses, so AAAA records aren't generated even though the firewalls already allow IPv6 traffic.
## Deploying the services (`services/`)
Once a server exists, copy the relevant `services/<host>/` directory to it (e.g. `scp -r services/prod user@<prod_ipv4>:~/services`) and run the matching script from inside that directory:
@@ -239,3 +251,4 @@ Then reopen the repo in VS Code with the Dev Containers extension.
- `vpn` is intentionally excluded from the private network so that a compromised VPN endpoint cannot reach `dev` or `prod` directly over it — SSH access still works because the VPN's egress traffic is NATed through its own public IP.
- Firewalls are allowlists scoped per host in `infra/modules/<host>/main.tf` — only ports actually used by that host's compose stacks (plus the cross-host private network range) are open.
- Every host's [post-install bootstrap](#post-install-bootstrap) disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (`var.deploy_user`) for day-to-day access.
- DNS zones (`modules/dns`) carry both Hetzner's own `delete_protection` and Terraform's `prevent_destroy` — losing a zone takes every record in it with it, including for `divine-couture.co.uk` and `snexo.co.uk`, not just `luke-else.co.uk`.