From ae34a78a1da3ae7fafb6d78439712f6fdfd55afe Mon Sep 17 00:00:00 2001 From: Luke Else Date: Fri, 10 Jul 2026 22:19:08 +0100 Subject: [PATCH] feat: Added DNS control to tofu --- infra/main.tf | 27 +++++++++++++++++++++++++++ infra/modules/dns/main.tf | 28 ++++++++++++++++++++++++++++ infra/modules/dns/outputs.tf | 9 +++++++++ infra/modules/dns/variables.tf | 15 +++++++++++++++ infra/outputs.tf | 5 +++++ infra/terraform.tfvars.example | 1 + infra/variables.tf | 6 ++++++ infra/versions.tf | 2 +- readme.md | 21 +++++++++++++++++---- 9 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 infra/modules/dns/main.tf create mode 100644 infra/modules/dns/outputs.tf create mode 100644 infra/modules/dns/variables.tf diff --git a/infra/main.tf b/infra/main.tf index 760950f..da2e66c 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -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 +} diff --git a/infra/modules/dns/main.tf b/infra/modules/dns/main.tf new file mode 100644 index 0000000..08952ee --- /dev/null +++ b/infra/modules/dns/main.tf @@ -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 } + ] +} diff --git a/infra/modules/dns/outputs.tf b/infra/modules/dns/outputs.tf new file mode 100644 index 0000000..2ef778d --- /dev/null +++ b/infra/modules/dns/outputs.tf @@ -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 } +} diff --git a/infra/modules/dns/variables.tf b/infra/modules/dns/variables.tf new file mode 100644 index 0000000..7f8265e --- /dev/null +++ b/infra/modules/dns/variables.tf @@ -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 = [] +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 989bf4c..f8e427c 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -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 +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index 8be0e88..7ab14ec 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -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"] diff --git a/infra/variables.tf b/infra/variables.tf index 7c041c8..10f6f43 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -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"] +} diff --git a/infra/versions.tf b/infra/versions.tf index d991f1e..9552a41 100644 --- a/infra/versions.tf +++ b/infra/versions.tf @@ -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" diff --git a/readme.md b/readme.md index eab589f..72f2796 100644 --- a/readme.md +++ b/readme.md @@ -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..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//` directory to it (e.g. `scp -r services/prod user@:~/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//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`.