Feat/open tofu #8

Merged
luke-else merged 24 commits from feat/open-tofu into main 2026-07-22 00:10:01 +00:00
15 changed files with 144 additions and 30 deletions
Showing only changes of commit 7ebd02f6b8 - Show all commits
+8 -5
View File
@@ -14,16 +14,19 @@ to S3, not on a Hetzner volume - see `../readme.md`.
- `inventory/hcloud.yml` - dynamic inventory, queries the Hetzner Cloud API
directly (not Terraform state) and groups servers by their `role` label
(`role_dev`, `role_prod`, `role_vpn` - set in `infra/modules/<host>/main.tf`).
- `group_vars/` - `all.yml` (shared: `deploy_user`, SSH key path,
`backup_s3_*` S3 backup credentials), `role_dev.yml` / `role_prod.yml` /
`role_vpn.yml` (per-role vars, e.g. `service_group`).
- `roles/bootstrap/` - Docker install, `deploy` user creation, SSH hardening,
unattended-upgrades. Replaces `infra/scripts/bootstrap.sh.tftpl`.
- `roles/deploy/` - copies `services/<host>/` to the server and renders `.env`
(S3 backup credentials, see `../readme.md`) and, for `dev`,
`Runners/docker-compose.yml`.
- `playbooks/` - `bootstrap.yml`, `deploy.yml`, `spinup.yml`, `spindown.yml`,
and `site.yml` (all three in order).
`site.yml` (all three in order), and `group_vars/` (`all.yml` - shared:
`deploy_user`, SSH key path, `backup_s3_*` S3 backup credentials;
`role_dev.yml` / `role_prod.yml` / `role_vpn.yml` - per-role vars, e.g.
`service_group`). `group_vars/` lives here rather than at the `ansible/`
root because Ansible only auto-loads `group_vars/`/`host_vars/` from
directories adjacent to the playbook file or the inventory source - not
from an arbitrary sibling directory.
## Prerequisites
@@ -41,7 +44,7 @@ export BACKUP_S3_ENDPOINT=your-s3-endpoint # omit for real AWS S3
persists your answers to `../.control.env` (gitignored) so you don't have to
re-export them every session.
`deploy_user` (see `group_vars/all.yml`) defaults to `deploy` - override with
`deploy_user` (see `playbooks/group_vars/all.yml`) defaults to `deploy` - override with
the `DEPLOY_USER` env var if you want a different non-root user created on each
host. `ansible_ssh_private_key_file` (same file) defaults to
`~/.ssh/id_ed25519` - override with `ANSIBLE_SSH_PRIVATE_KEY_FILE` if you use a
+12 -1
View File
@@ -31,6 +31,17 @@ locals {
{ zone = "luke-else.co.uk", name = "vpn", value = module.vpn.ipv4 },
{ zone = "luke-else.co.uk", name = "traefik.vpn", value = module.vpn.ipv4 },
]
# Microsoft 365 mail records for luke-else.co.uk - carried over from the
# domain's old DNS provider (pre-Hetzner), not previously applied here, so
# these are genuinely new to the Hetzner zone rather than something to
# import.
mail_records = [
{ zone = "luke-else.co.uk", name = "@", type = "MX", value = "0 lukeelse-co-uk01b.mail.protection.outlook.com." },
{ zone = "luke-else.co.uk", name = "@", type = "TXT", value = "\"v=spf1 include:spf.protection.outlook.com -all\"" },
{ zone = "luke-else.co.uk", name = "@", type = "TXT", value = "\"google-site-verification=aKLk5BATowKUwNbFcU5zyfu-AIyCW9W9PBStePRjH3U\"" },
{ zone = "luke-else.co.uk", name = "autodiscover", type = "CNAME", value = "autodiscover.outlook.com." },
]
}
module "network" {
@@ -87,5 +98,5 @@ module "dns" {
source = "./modules/dns"
zone_names = var.dns_zones
records = local.dns_records
records = concat(local.dns_records, local.mail_records)
}
+20 -6
View File
@@ -14,15 +14,29 @@ resource "hcloud_zone" "this" {
}
}
resource "hcloud_zone_rrset" "a" {
for_each = { for r in var.records : "${r.zone}/${r.name}" => r }
locals {
# Group flat var.records entries by zone/name/type into one set of values
# each, since a Hetzner RRSet is keyed on (zone, name, type) and can hold
# several values (e.g. an SPF and a google-site-verification TXT both live
# under the same "@" TXT RRSet).
rrsets = {
for key, items in { for r in var.records : "${r.zone}/${r.name}/${r.type}" => r... } : key => {
zone = items[0].zone
name = items[0].name
type = items[0].type
ttl = items[0].ttl
values = [for i in items : i.value]
}
}
}
resource "hcloud_zone_rrset" "this" {
for_each = local.rrsets
zone = hcloud_zone.this[each.value.zone].id
name = each.value.name
type = "A"
type = each.value.type
ttl = each.value.ttl
records = [
{ value = each.value.value }
]
records = [for v in each.value.values : { value = v }]
}
+60
View File
@@ -0,0 +1,60 @@
# Generalizing hcloud_zone_rrset.a (for_each keyed "zone/name", A records
# only) into hcloud_zone_rrset.this (keyed "zone/name/type", any record type)
# changed every existing instance's resource address. Without these, `tofu
# apply` would destroy and recreate every A record already in state instead of
# just adding the new mail records - these moved blocks make it a no-op rename
# for anything that already existed. Safe to delete once applied and merged.
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/@"]
to = hcloud_zone_rrset.this["luke-else.co.uk/@/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/dev"]
to = hcloud_zone_rrset.this["luke-else.co.uk/dev/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/metarius"]
to = hcloud_zone_rrset.this["luke-else.co.uk/metarius/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/traefik"]
to = hcloud_zone_rrset.this["luke-else.co.uk/traefik/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/status"]
to = hcloud_zone_rrset.this["luke-else.co.uk/status/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/bitwarden"]
to = hcloud_zone_rrset.this["luke-else.co.uk/bitwarden/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/rd"]
to = hcloud_zone_rrset.this["luke-else.co.uk/rd/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/git"]
to = hcloud_zone_rrset.this["luke-else.co.uk/git/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/traefik.cicd"]
to = hcloud_zone_rrset.this["luke-else.co.uk/traefik.cicd/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/vpn"]
to = hcloud_zone_rrset.this["luke-else.co.uk/vpn/A"]
}
moved {
from = hcloud_zone_rrset.a["luke-else.co.uk/traefik.vpn"]
to = hcloud_zone_rrset.this["luke-else.co.uk/traefik.vpn/A"]
}
+10 -1
View File
@@ -4,11 +4,20 @@ variable "zone_names" {
}
variable "records" {
description = "A records to create across the zones in var.zone_names. Use name = \"@\" for the zone apex."
description = <<-EOT
Records to create across the zones in var.zone_names. Use name = "@" for
the zone apex. Entries sharing the same zone/name/type become a single
Hetzner RRSet with multiple values (e.g. two TXT strings under the same
name) - Hetzner has no concept of separate same-type records otherwise.
For MX, embed the priority in value using standard zonefile syntax (e.g.
"10 mail.example.com."); Hetzner's API has no separate priority field.
For TXT, wrap value in escaped double quotes (e.g. "\"v=spf1 ...\"").
EOT
type = list(object({
zone = string
name = string
value = string
type = optional(string, "A")
ttl = optional(number, 300)
}))
default = []
+4 -4
View File
@@ -13,25 +13,25 @@ variable "network_zone" {
variable "server_image" {
description = "OS image used for all servers."
type = string
default = "ubuntu-24.04"
default = "ubuntu-26.04"
}
variable "dev_server_type" {
description = "Server type for dev"
type = string
default = "cx22"
default = "cx23"
}
variable "prod_server_type" {
description = "Server type for prod"
type = string
default = "cx22"
default = "cx23"
}
variable "vpn_server_type" {
description = "Server type for vpn"
type = string
default = "cx22"
default = "cx23"
}
variable "network_ip_range" {
+16 -5
View File
@@ -85,11 +85,12 @@ Each host's stateful data lives in local named Docker volumes, backed up nightly
│ └── dns/ # Hetzner DNS zones + A records for every domain in var.dns_zones
├── ansible/ # Ansible: bootstraps each server and deploys/starts services/<host>/ onto it
│ ├── inventory/hcloud.yml # dynamic inventory — queries the Hetzner API, groups by the role label above
│ ├── group_vars/ # deploy_user, service_group, backup_s3_*, etc.
│ ├── roles/
│ │ ├── bootstrap/ # Docker, deploy user, sshd hardening, unattended-upgrades
│ │ └── deploy/ # copies services/<host>/, renders .env (S3 backup credentials) + Runners/docker-compose.yml
│ └── playbooks/ # bootstrap.yml, deploy.yml, spinup.yml, spindown.yml, site.yml
│ └── playbooks/ # bootstrap.yml, deploy.yml, spinup.yml, spindown.yml, site.yml,
│ └── group_vars/ # deploy_user, service_group, backup_s3_*, etc. - must sit next to
│ # the playbooks for Ansible to auto-load it (see ansible/README.md)
├── services/ # Docker Compose stacks, grouped by which server they run on
│ ├── dev/ # Gitea + CI runner + Traefik + backup/restore (git.luke-else.co.uk, cicd.luke-else.co.uk)
│ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik + backup/restore
@@ -159,13 +160,23 @@ OpenTofu never connects to the servers over SSH — no provisioners, no `bootstr
### 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.
`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` per record in `var.records` — kept in sync with `local.dns_records` (A records, one per hostname currently referenced by a Traefik `Host()` rule anywhere under `services/`, pointed at whichever of `dev`/`prod`/`vpn` actually serves it) and `local.mail_records` (see below) in `infra/main.tf`. 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.
Only A records for IPv4 are managed for hosts — none of the modules currently track servers' IPv6 addresses, so AAAA records aren't generated even though the firewalls already allow IPv6 traffic.
`var.records` isn't limited to A records: each entry takes an optional `type` (`A` by default, or `MX`/`TXT`/`CNAME`/etc.), and entries sharing the same `zone`/`name`/`type` are merged into a single Hetzner RRSet with multiple values (needed for e.g. two TXT strings under the same name) - see `infra/modules/dns/variables.tf` for the exact value format each type expects (MX embeds the priority in the value string, TXT values need escaped double quotes).
#### Mail records (Microsoft 365)
`local.mail_records` in `infra/main.tf` tracks the DNS records Microsoft 365 needs for `luke-else.co.uk` - the MX record, the SPF and Google-site-verification TXT records (merged into one `@` TXT RRSet), and the `autodiscover` CNAME. These carried over from the domain's DNS provider prior to Hetzner and were never applied to the Hetzner zone, so they're genuinely new records here (not something to `tofu import` - nothing to collide with).
**Not yet included:** a DMARC policy (`_dmarc` TXT) and DKIM signing (two `selector1._domainkey`/`selector2._domainkey` CNAMEs). DKIM's targets are generated per-tenant by Microsoft 365 (Defender portal → Email & collaboration → Policies & rules → DKIM) and can't be guessed - enable DKIM there first, then add the two CNAMEs it gives you to `local.mail_records`.
`infra/modules/dns/moved.tf` re-points every pre-existing A record at its new resource address, since generalizing the module to support non-A record types changed how they're keyed internally - without it, `tofu apply` would destroy and recreate every A record for no functional reason. It's a one-time migration aid: run `tofu plan` first to confirm the only real changes are the new mail records being created, then the file can be deleted after a successful apply.
## Bootstrapping and deploying with Ansible (`ansible/`)
@@ -310,5 +321,5 @@ Then reopen the repo in VS Code with the Dev Containers extension.
- SSH to `dev` and `prod` is restricted to the `vpn` server's own public IP — you must be tunneled into the VPN to reach them over SSH. SSH to `vpn` itself is gated by `var.allowed_ssh_source_ips`; narrow this from the default `0.0.0.0/0` once you know your own egress IP(s).
- `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 [Ansible bootstrap role](ansible/roles/bootstrap/tasks/main.yml) disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (`deploy_user`, see `ansible/group_vars/all.yml`) for day-to-day access.
- Every host's [Ansible bootstrap role](ansible/roles/bootstrap/tasks/main.yml) disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (`deploy_user`, see `ansible/playbooks/group_vars/all.yml`) 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`.
+1 -1
View File
@@ -26,7 +26,7 @@ services:
# Run explicitly by spinup.sh before the services that own these volumes
# start - not started by `docker compose up`.
restore:
image: amazon/aws-cli:2
image: amazon/aws-cli:latest
container_name: backup-restore
entrypoint: ["/bin/bash", "/restore.sh"]
environment:
+1 -1
View File
@@ -31,7 +31,7 @@ services:
# Run explicitly by spinup.sh before the services that own these volumes
# start - not started by `docker compose up`.
restore:
image: amazon/aws-cli:2
image: amazon/aws-cli:latest
container_name: backup-restore
entrypoint: [ "/bin/bash", "/restore.sh" ]
environment:
+1 -1
View File
@@ -26,7 +26,7 @@ services:
# Run explicitly by spinup.sh before the services that own these volumes
# start - not started by `docker compose up`.
restore:
image: amazon/aws-cli:2
image: amazon/aws-cli:latest
container_name: backup-restore
entrypoint: ["/bin/bash", "/restore.sh"]
environment:
+10 -4
View File
@@ -2,13 +2,15 @@ services:
dockovpn:
image: alekslitvinenk/openvpn
cap_add:
- NET_ADMIN
- NET_ADMIN
ports:
- 1194:1194/udp # Expose tcp if you defined HOST_TUN_PROTOCOL=tcp
- 1194:1194/udp # Expose tcp if you defined HOST_TUN_PROTOCOL=tcp
networks:
- proxy
environment:
HOST_ADDR: vpn.luke-else.co.uk # Your VPN server address
HOST_ADDR: vpn.luke-else.co.uk # Your VPN server address
volumes:
- openvpn_data:/opt/Dockovpn_data
- openvpn_data:/opt/Dockovpn_data
labels:
## Expose vpn Through Trefik ##
- "traefik.enable=true" # <== Enable traefik to proxy this container
@@ -26,6 +28,10 @@ services:
- "traefik.http.routers.vpn.tls.certresolver=myresolver"
restart: always
networks:
proxy:
external: true
volumes:
openvpn_data:
name: openvpn_data