From 479f64828e17f6adfae218d4491ed09e00b57499 Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Tue, 24 Mar 2026 14:46:07 +0000 Subject: [PATCH 1/4] Terraform DNS --- terraform/dns.tf | 147 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 terraform/dns.tf diff --git a/terraform/dns.tf b/terraform/dns.tf new file mode 100644 index 0000000..cb8f601 --- /dev/null +++ b/terraform/dns.tf @@ -0,0 +1,147 @@ +# DNS zone and records for the platform. +# +# Zone ownership: prod creates the zone, dev looks it up as a data source. +# Record ownership: each workspace manages records pointing to its own server. +# Consequence: terraform apply -- prod must run before terraform apply -- dev. + +locals { + is_prod = local.environment == "prod" + is_dev = local.environment == "dev" + zone = local.is_prod ? hcloud_zone.main[0].name : data.hcloud_zone.main[0].name + server_ipv6 = hcloud_server.platform.ipv6_address +} + +# ────────────────────────────────────────────── +# Zone +# ────────────────────────────────────────────── + +resource "hcloud_zone" "main" { + count = local.is_prod ? 1 : 0 + name = var.base_domain + mode = "primary" +} + +data "hcloud_zone" "main" { + count = local.is_dev ? 1 : 0 + name = var.base_domain +} + +# ────────────────────────────────────────────── +# Dev records (destroyed with dev server) +# ────────────────────────────────────────────── + +resource "hcloud_zone_rrset" "dev_a" { + count = local.is_dev ? 1 : 0 + zone = local.zone + name = "dev" + type = "A" + ttl = 300 + records = [{ value = hcloud_server.platform.ipv4_address }] +} + +resource "hcloud_zone_rrset" "dev_aaaa" { + count = local.is_dev ? 1 : 0 + zone = local.zone + name = "dev" + type = "AAAA" + ttl = 300 + records = [{ value = local.server_ipv6 }] +} + +# ────────────────────────────────────────────── +# Prod server records +# ────────────────────────────────────────────── + +resource "hcloud_zone_rrset" "prod_a" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "prod" + type = "A" + ttl = 60 + records = [{ value = hcloud_server.platform.ipv4_address }] +} + +resource "hcloud_zone_rrset" "prod_aaaa" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "prod" + type = "AAAA" + ttl = 60 + records = [{ value = local.server_ipv6 }] +} + +resource "hcloud_zone_rrset" "apex_a" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "@" + type = "A" + ttl = 60 + records = [{ value = hcloud_server.platform.ipv4_address }] +} + +resource "hcloud_zone_rrset" "apex_aaaa" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "@" + type = "AAAA" + ttl = 60 + records = [{ value = local.server_ipv6 }] +} + +resource "hcloud_zone_rrset" "registry_a" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "registry" + type = "A" + ttl = 60 + records = [{ value = hcloud_server.platform.ipv4_address }] +} + +resource "hcloud_zone_rrset" "registry_aaaa" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "registry" + type = "AAAA" + ttl = 60 + records = [{ value = local.server_ipv6 }] +} + +resource "hcloud_zone_rrset" "www" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "www" + type = "CNAME" + ttl = 60 + records = [{ value = "prod.${var.base_domain}." }] +} + +# ────────────────────────────────────────────── +# Email anti-spoofing (domain does not handle email) +# ────────────────────────────────────────────── + +resource "hcloud_zone_rrset" "null_mx" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "@" + type = "MX" + ttl = 86400 + records = [{ value = "0 ." }] +} + +resource "hcloud_zone_rrset" "apex_spf" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "@" + type = "TXT" + ttl = 86400 + records = [{ value = provider::hcloud::txt_record("v=spf1 -all") }] +} + +resource "hcloud_zone_rrset" "dmarc" { + count = local.is_prod ? 1 : 0 + zone = local.zone + name = "_dmarc" + type = "TXT" + ttl = 86400 + records = [{ value = provider::hcloud::txt_record("v=DMARC1; p=reject;") }] +} From f6c0927ffd178fec0c8fd5b01c35409e7214a43c Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Wed, 25 Mar 2026 11:59:55 +0000 Subject: [PATCH 2/4] automate DNS --- docs/README.md | 13 +++++---- docs/dns.md | 64 ++++++++++++++++++++++++++++++++++++++++++++ docs/technologies.md | 4 ++- terraform/dns.tf | 4 +-- 4 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 docs/dns.md diff --git a/docs/README.md b/docs/README.md index 6473569..a7767ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ This diagram shows how the various components work together:
- Click to expand + Click to expand diagram ```mermaid graph TB @@ -53,19 +53,18 @@ graph TB ## Reference -**Infrastructure** - +**Main components** - [Traefik](traefik.md) — Reverse proxy, TLS, operations, adding apps - [Registry](registry.md) — Auth, commands, operations - [Observe](monitoring.md) — OpenObserve, dashboards, logs - [Workflows](workflows.md) — Scheduled tasks and multi-step workflows with Prefect -**Application** - +**Application management** - [Application deployment](application-deployment.md) — Commands, app mount, records, implementation details -- [Secrets](secrets.md) — File locations, editing, SOPS, app secrets +- [Secrets management](secrets.md) — File locations, editing, SOPS, app secrets -**Operations** +**Operations and infrastructure** +- [DNS](dns.md) — Zone and records managed by Terraform (Hetzner DNS) - [Backups](backups.md) — Automated application aware backups using Restic - [Remote-SSH](remote-ssh.md) — Connect to the server via Remote-SSH (tunnel, dashboards) - [Private (local config)](private.md) — Local config files diff --git a/docs/dns.md b/docs/dns.md new file mode 100644 index 0000000..5a16762 --- /dev/null +++ b/docs/dns.md @@ -0,0 +1,64 @@ +[**<---**](README.md) + +# DNS + +DNS zones and records are managed by Terraform via the [hcloud provider](https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs), using [Hetzner DNS](https://docs.hetzner.com/networking/dns/) as the nameserver. Domain registration stays at the original registrar (e.g. TransIP); only the NS delegation points to Hetzner. + +**Configuration:** [`terraform/dns.tf`](../terraform/dns.tf) + +## How it works + +The prod workspace owns the DNS zone and all shared records. The dev workspace looks up the zone as a data source and manages only its own records. This means `terraform apply -- prod` must run before `terraform apply -- dev`. + +
+ Click to expand diagram + +```mermaid +flowchart LR + subgraph registrar["Registrar (TransIP)"] + NS["NS delegation →
Hetzner nameservers"] + end + + subgraph hetzner["Hetzner DNS"] + ZONE["Zone: example.com"] + PROD_REC["prod A/AAAA
@ A/AAAA
registry A/AAAA
www CNAME
email anti-spoofing"] + DEV_REC["dev A/AAAA"] + end + + subgraph terraform["Terraform"] + PROD_WS["platform-prod
(creates zone + records)"] + DEV_WS["platform-dev
(creates dev records)"] + end + + NS --> ZONE + PROD_WS --> ZONE + PROD_WS --> PROD_REC + DEV_WS --> DEV_REC +``` + +
+ +## Record ownership + +| Workspace | Records | Dynamic? | +|-----------|---------|----------| +| **prod** | `prod` A/AAAA, `@` A/AAAA, `registry` A/AAAA | Yes — derived from server IP | +| **prod** | `www` CNAME | Static — points to `prod.` | +| **prod** | Null MX, SPF, DMARC | Static — email anti-spoofing | +| **dev** | `dev` A/AAAA | Yes — derived from server IP; destroyed with dev server | + +## Adding a new domain + +For a new app on a different domain +1. No code changes needed in `dns.tf` — it keys off `var.base_domain` from `iac.yml` +2. Point the new domain's NS records at Hetzner's nameservers (`hydrogen.ns.hetzner.com`, `oxygen.ns.hetzner.com`, `helium.ns.hetzner.de`) +3. Run `terraform apply -- prod` to create the zone and records + +## Changing NS delegation + +This is a one-time step per domain at the registrar: + +1. `terraform apply -- prod` to create the zone and records in Hetzner DNS +2. Verify records resolve: `dig @hydrogen.ns.hetzner.com prod.` +3. At the registrar, change nameservers to Hetzner's three nameservers +4. Wait for propagation (minutes to hours): `dig NS ` diff --git a/docs/technologies.md b/docs/technologies.md index 7e41d95..23dd46a 100644 --- a/docs/technologies.md +++ b/docs/technologies.md @@ -14,7 +14,9 @@ Most versioned dependencies live in a package file; [Renovate](https://docs.reno | **Server provisioning** | | | | [Ansible](https://docs.ansible.com/) | Configuration management | [`requirements.txt`](../requirements.txt), [`ansible/requirements.yml`](../ansible/requirements.yml) | | [hcloud](https://github.com/hetznercloud/cli) | Hetzner Cloud CLI | [`mise.toml`](../mise.toml) | -| [Terraform](https://developer.hashicorp.com/terraform) | IaC for cloud resources | [`terraform/versions.tf`](../terraform/versions.tf) | +| [Terraform](https://developer.hashicorp.com/terraform) | IaC for cloud resources (servers, DNS) | [`terraform/versions.tf`](../terraform/versions.tf) | +| **DNS** | | | +| [Hetzner DNS](https://docs.hetzner.com/networking/dns/) | Zone and records managed by Terraform. | [`terraform/dns.tf`](../terraform/dns.tf) | | **Secrets** | | | | [Age](https://github.com/FiloSottile/age) | Encryption (age format) | [`mise.toml`](../mise.toml) | | [SOPS](https://getsops.io/) | Encrypt secrets in Git. See [Secrets](secrets.md) | [`mise.toml`](../mise.toml) | diff --git a/terraform/dns.tf b/terraform/dns.tf index cb8f601..7137bca 100644 --- a/terraform/dns.tf +++ b/terraform/dns.tf @@ -134,7 +134,7 @@ resource "hcloud_zone_rrset" "apex_spf" { name = "@" type = "TXT" ttl = 86400 - records = [{ value = provider::hcloud::txt_record("v=spf1 -all") }] + records = [{ value = "\"v=spf1 -all\"" }] } resource "hcloud_zone_rrset" "dmarc" { @@ -143,5 +143,5 @@ resource "hcloud_zone_rrset" "dmarc" { name = "_dmarc" type = "TXT" ttl = 86400 - records = [{ value = provider::hcloud::txt_record("v=DMARC1; p=reject;") }] + records = [{ value = "\"v=DMARC1; p=reject;\"" }] } From 12715b386a9a4d3bb375fa7e0f213ae86d74353f Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Wed, 25 Mar 2026 14:33:14 +0000 Subject: [PATCH 3/4] dnssec --- .../traefik-dynamic-redirects.yml.j2 | 6 + docs/dns.md | 38 ++--- docs/technologies.md | 2 +- tasks/Taskfile.terraform.yml | 12 +- terraform/.terraform.lock.hcl | 22 +++ terraform/dns.tf | 142 +++++++++--------- terraform/providers.tf | 5 + terraform/variables.tf | 11 ++ terraform/versions.tf | 4 + 9 files changed, 149 insertions(+), 93 deletions(-) diff --git a/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 b/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 index 8b13c39..4e9f12c 100644 --- a/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 +++ b/ansible/roles/server/templates/traefik-dynamic-redirects.yml.j2 @@ -9,6 +9,7 @@ http: tls: certResolver: letsencrypt middlewares: + - hsts-headers - redirect-to-app service: noop@internal redirect-apex: @@ -18,6 +19,7 @@ http: tls: certResolver: letsencrypt middlewares: + - hsts-headers - redirect-to-app service: noop@internal @@ -27,3 +29,7 @@ http: regex: "^https://(www\\.)?{{ base_domain | regex_escape() | replace('\\', '\\\\') }}(.*)" replacement: "https://{{ traefik_redirect_target }}$2" permanent: true + hsts-headers: + headers: + customResponseHeaders: + Strict-Transport-Security: "max-age=31536000; includeSubDomains" diff --git a/docs/dns.md b/docs/dns.md index 5a16762..82924f8 100644 --- a/docs/dns.md +++ b/docs/dns.md @@ -2,36 +2,30 @@ # DNS -DNS zones and records are managed by Terraform via the [hcloud provider](https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs), using [Hetzner DNS](https://docs.hetzner.com/networking/dns/) as the nameserver. Domain registration stays at the original registrar (e.g. TransIP); only the NS delegation points to Hetzner. +DNS zones and records are managed by Terraform via the [aequitas/transip](https://registry.terraform.io/providers/aequitas/transip/latest) provider. TransIP acts as both registrar and authoritative DNS provider. DNSSEC is managed via `transip_domain_dnssec`. **Configuration:** [`terraform/dns.tf`](../terraform/dns.tf) ## How it works -The prod workspace owns the DNS zone and all shared records. The dev workspace looks up the zone as a data source and manages only its own records. This means `terraform apply -- prod` must run before `terraform apply -- dev`. +TransIP manages the zone automatically as registrar — no zone creation step needed. Each workspace manages the DNS records that point to its own server. Both workspaces are independent (no ordering constraint).
Click to expand diagram ```mermaid flowchart LR - subgraph registrar["Registrar (TransIP)"] - NS["NS delegation →
Hetzner nameservers"] - end - - subgraph hetzner["Hetzner DNS"] + subgraph transip["TransIP (registrar + DNS)"] ZONE["Zone: example.com"] - PROD_REC["prod A/AAAA
@ A/AAAA
registry A/AAAA
www CNAME
email anti-spoofing"] + PROD_REC["prod A/AAAA
@ A/AAAA
registry A/AAAA
www CNAME
email anti-spoofing
DNSSEC"] DEV_REC["dev A/AAAA"] end subgraph terraform["Terraform"] - PROD_WS["platform-prod
(creates zone + records)"] - DEV_WS["platform-dev
(creates dev records)"] + PROD_WS["platform-prod"] + DEV_WS["platform-dev"] end - NS --> ZONE - PROD_WS --> ZONE PROD_WS --> PROD_REC DEV_WS --> DEV_REC ``` @@ -45,20 +39,20 @@ flowchart LR | **prod** | `prod` A/AAAA, `@` A/AAAA, `registry` A/AAAA | Yes — derived from server IP | | **prod** | `www` CNAME | Static — points to `prod.` | | **prod** | Null MX, SPF, DMARC | Static — email anti-spoofing | +| **prod** | DNSSEC (`transip_domain_dnssec`) | Static — key material from TransIP | | **dev** | `dev` A/AAAA | Yes — derived from server IP; destroyed with dev server | +## DNSSEC + +DNSSEC is declared as a `transip_domain_dnssec` resource in the prod workspace. The key material (key_tag, flags, algorithm, public_key) comes from TransIP's control panel once DNSSEC is enabled for the domain. Add the values to the `dns.tf` resource block and apply. + ## Adding a new domain -For a new app on a different domain -1. No code changes needed in `dns.tf` — it keys off `var.base_domain` from `iac.yml` -2. Point the new domain's NS records at Hetzner's nameservers (`hydrogen.ns.hetzner.com`, `oxygen.ns.hetzner.com`, `helium.ns.hetzner.de`) -3. Run `terraform apply -- prod` to create the zone and records +For a new app on a different domain: -## Changing NS delegation +- If the domain is registered at TransIP: no code changes — set `base_domain` in `iac.yml` and apply. +- If the domain is at another registrar: delegate NS records to TransIP's nameservers (`ns0.transip.net`, `ns1.transip.nl`, `ns2.transip.eu`), then apply. -This is a one-time step per domain at the registrar: +## Credentials -1. `terraform apply -- prod` to create the zone and records in Hetzner DNS -2. Verify records resolve: `dig @hydrogen.ns.hetzner.com prod.` -3. At the registrar, change nameservers to Hetzner's three nameservers -4. Wait for propagation (minutes to hours): `dig NS ` +`transip_account_name` and `transip_private_key` are stored in the sops-encrypted `app/.iac/iac.yml` and passed to Terraform as `TF_VAR_` environment variables by the Taskfile. Generate the key pair at https://www.transip.eu/cp/account/api/. diff --git a/docs/technologies.md b/docs/technologies.md index 23dd46a..e615071 100644 --- a/docs/technologies.md +++ b/docs/technologies.md @@ -16,7 +16,7 @@ Most versioned dependencies live in a package file; [Renovate](https://docs.reno | [hcloud](https://github.com/hetznercloud/cli) | Hetzner Cloud CLI | [`mise.toml`](../mise.toml) | | [Terraform](https://developer.hashicorp.com/terraform) | IaC for cloud resources (servers, DNS) | [`terraform/versions.tf`](../terraform/versions.tf) | | **DNS** | | | -| [Hetzner DNS](https://docs.hetzner.com/networking/dns/) | Zone and records managed by Terraform. | [`terraform/dns.tf`](../terraform/dns.tf) | +| [TransIP DNS](https://www.transip.eu/knowledgebase/155-dns-and-nameservers/) | DNS records and DNSSEC managed by Terraform. | [`terraform/dns.tf`](../terraform/dns.tf) | | **Secrets** | | | | [Age](https://github.com/FiloSottile/age) | Encryption (age format) | [`mise.toml`](../mise.toml) | | [SOPS](https://getsops.io/) | Encrypt secrets in Git. See [Secrets](secrets.md) | [`mise.toml`](../mise.toml) | diff --git a/tasks/Taskfile.terraform.yml b/tasks/Taskfile.terraform.yml index 72407a6..7bcd17b 100644 --- a/tasks/Taskfile.terraform.yml +++ b/tasks/Taskfile.terraform.yml @@ -35,7 +35,9 @@ tasks: TF_VAR_ssh_keys=$(echo "$SECRETS" | yq '.ssh_keys' -o=json) TF_VAR_allowed_ssh_ips=$(echo "$SECRETS" | yq '.allowed_ssh_ips' -o=json) TF_VAR_server_type=$(echo "$SECRETS" | yq -r '.server_type // "cx23"') - export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type + TF_VAR_transip_account_name=$(echo "$SECRETS" | yq -r '.transip_account_name') + TF_VAR_transip_private_key=$(echo "$SECRETS" | yq -r '.transip_private_key') + export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type TF_VAR_transip_account_name TF_VAR_transip_private_key terraform plan apply: @@ -57,7 +59,9 @@ tasks: TF_VAR_ssh_keys=$(echo "$SECRETS" | yq '.ssh_keys' -o=json) TF_VAR_allowed_ssh_ips=$(echo "$SECRETS" | yq '.allowed_ssh_ips' -o=json) TF_VAR_server_type=$(echo "$SECRETS" | yq -r '.server_type // "cx23"') - export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type + TF_VAR_transip_account_name=$(echo "$SECRETS" | yq -r '.transip_account_name') + TF_VAR_transip_private_key=$(echo "$SECRETS" | yq -r '.transip_private_key') + export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type TF_VAR_transip_account_name TF_VAR_transip_private_key echo "🔑 Removing old host key for workspace from known_hosts..." task hostkeys:prepare -- "{{.WORKSPACE}}" terraform apply @@ -86,7 +90,9 @@ tasks: TF_VAR_ssh_keys=$(echo "$SECRETS" | yq '.ssh_keys' -o=json) TF_VAR_allowed_ssh_ips=$(echo "$SECRETS" | yq '.allowed_ssh_ips' -o=json) TF_VAR_server_type=$(echo "$SECRETS" | yq -r '.server_type // "cx23"') - export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type + TF_VAR_transip_account_name=$(echo "$SECRETS" | yq -r '.transip_account_name') + TF_VAR_transip_private_key=$(echo "$SECRETS" | yq -r '.transip_private_key') + export TF_VAR_hcloud_token TF_VAR_base_domain TF_VAR_ssh_keys TF_VAR_allowed_ssh_ips TF_VAR_server_type TF_VAR_transip_account_name TF_VAR_transip_private_key terraform destroy echo "🧹 Cleaning server host key from known_hosts..." task hostkeys:prepare -- "{{.WORKSPACE}}" diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index 24f5b08..9793749 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -1,6 +1,28 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/aequitas/transip" { + version = "0.1.27" + constraints = "~> 0.1" + hashes = [ + "h1:K5D+p14w7w0UgglNbUG5sASg9a9a/V2NjiwHRWh8Ktc=", + "zh:19d1cd6ef49ad8b3d28b5ce47ae990150286d7446fc02009248c0439c3da7be7", + "zh:36e1d80d54cdc1cd54ca72032ef5a2377e6453c5cc1ba6394ebf96e22f84cb13", + "zh:4542356b09ceb7f5d8788ae6e3712119eff811549c0891e94a00b2f5ad523811", + "zh:48440fe4ed018b211d07b29396ae94758a71a4d54f2c0be6886a652c9f687cf9", + "zh:4fa38256cbb48beae1b7887889cc7ee486f27578136d0865caa2d54902ec45dd", + "zh:5d13bd2e7c15066b42487daaed56fa14d99e3c7741adde487c1c98f3f5bdb68b", + "zh:715f3e2b545df138d616fb7fc16217f059eb37a113579ee3e9edeecb686fddee", + "zh:784f7f388d501d6351697a97a80dc945fcb9221053b4ba985d535f5f8416532c", + "zh:7a7b697efab46bacf1c61a87da301851882d41fa5a819437028373aa0cdb07c5", + "zh:959e990486eb8faa786e31548091d3552a90a4aed9865a2941ca5f812c62ce0f", + "zh:bb6ebc885f651ab0a0d093adbe7cbeebf47d70846a71b9ea1ff40cad329eb82f", + "zh:c782ac53112b21145f44b08efe31b8a38de83f7a0885919f8084c27be543ac5e", + "zh:e6c23a687cf6ff8fed3cf80a995b6f01597b48e8b36a1351d5924875a4bbedae", + "zh:f8caaa05b012d4fe992e915f97ef2a0440303bf21ae67b5867165bd3cbcde666", + ] +} + provider "registry.terraform.io/hetznercloud/hcloud" { version = "1.60.1" constraints = "~> 1.45" diff --git a/terraform/dns.tf b/terraform/dns.tf index 7137bca..829b546 100644 --- a/terraform/dns.tf +++ b/terraform/dns.tf @@ -1,147 +1,155 @@ -# DNS zone and records for the platform. +# DNS records and DNSSEC for the platform. # -# Zone ownership: prod creates the zone, dev looks it up as a data source. +# TransIP is both registrar and DNS provider — no zone creation needed. # Record ownership: each workspace manages records pointing to its own server. -# Consequence: terraform apply -- prod must run before terraform apply -- dev. +# DNSSEC is managed via transip_domain_dnssec (prod only). locals { is_prod = local.environment == "prod" is_dev = local.environment == "dev" - zone = local.is_prod ? hcloud_zone.main[0].name : data.hcloud_zone.main[0].name server_ipv6 = hcloud_server.platform.ipv6_address } -# ────────────────────────────────────────────── -# Zone -# ────────────────────────────────────────────── - -resource "hcloud_zone" "main" { - count = local.is_prod ? 1 : 0 - name = var.base_domain - mode = "primary" -} - -data "hcloud_zone" "main" { - count = local.is_dev ? 1 : 0 - name = var.base_domain -} - # ────────────────────────────────────────────── # Dev records (destroyed with dev server) # ────────────────────────────────────────────── -resource "hcloud_zone_rrset" "dev_a" { +resource "transip_dns_record" "dev_a" { count = local.is_dev ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "dev" type = "A" - ttl = 300 - records = [{ value = hcloud_server.platform.ipv4_address }] + expire = 300 + content = [hcloud_server.platform.ipv4_address] } -resource "hcloud_zone_rrset" "dev_aaaa" { +resource "transip_dns_record" "dev_aaaa" { count = local.is_dev ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "dev" type = "AAAA" - ttl = 300 - records = [{ value = local.server_ipv6 }] + expire = 300 + content = [local.server_ipv6] } # ────────────────────────────────────────────── # Prod server records # ────────────────────────────────────────────── -resource "hcloud_zone_rrset" "prod_a" { +resource "transip_dns_record" "prod_a" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "prod" type = "A" - ttl = 60 - records = [{ value = hcloud_server.platform.ipv4_address }] + expire = 60 + content = [hcloud_server.platform.ipv4_address] } -resource "hcloud_zone_rrset" "prod_aaaa" { +resource "transip_dns_record" "prod_aaaa" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "prod" type = "AAAA" - ttl = 60 - records = [{ value = local.server_ipv6 }] + expire = 60 + content = [local.server_ipv6] } -resource "hcloud_zone_rrset" "apex_a" { +resource "transip_dns_record" "apex_a" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "@" type = "A" - ttl = 60 - records = [{ value = hcloud_server.platform.ipv4_address }] + expire = 60 + content = [hcloud_server.platform.ipv4_address] } -resource "hcloud_zone_rrset" "apex_aaaa" { +resource "transip_dns_record" "apex_aaaa" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "@" type = "AAAA" - ttl = 60 - records = [{ value = local.server_ipv6 }] + expire = 60 + content = [local.server_ipv6] } -resource "hcloud_zone_rrset" "registry_a" { +resource "transip_dns_record" "registry_a" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "registry" type = "A" - ttl = 60 - records = [{ value = hcloud_server.platform.ipv4_address }] + expire = 60 + content = [hcloud_server.platform.ipv4_address] } -resource "hcloud_zone_rrset" "registry_aaaa" { +resource "transip_dns_record" "registry_aaaa" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "registry" type = "AAAA" - ttl = 60 - records = [{ value = local.server_ipv6 }] + expire = 60 + content = [local.server_ipv6] } -resource "hcloud_zone_rrset" "www" { +resource "transip_dns_record" "www" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "www" type = "CNAME" - ttl = 60 - records = [{ value = "prod.${var.base_domain}." }] + expire = 60 + content = ["prod.${var.base_domain}."] } # ────────────────────────────────────────────── # Email anti-spoofing (domain does not handle email) # ────────────────────────────────────────────── -resource "hcloud_zone_rrset" "null_mx" { +resource "transip_dns_record" "null_mx" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "@" type = "MX" - ttl = 86400 - records = [{ value = "0 ." }] + expire = 86400 + content = ["0 ."] } -resource "hcloud_zone_rrset" "apex_spf" { +resource "transip_dns_record" "apex_spf" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "@" type = "TXT" - ttl = 86400 - records = [{ value = "\"v=spf1 -all\"" }] + expire = 86400 + content = ["v=spf1 -all"] } -resource "hcloud_zone_rrset" "dmarc" { +resource "transip_dns_record" "dmarc" { count = local.is_prod ? 1 : 0 - zone = local.zone + domain = var.base_domain name = "_dmarc" type = "TXT" - ttl = 86400 - records = [{ value = "\"v=DMARC1; p=reject;\"" }] + expire = 86400 + content = ["v=DMARC1; p=reject;"] } + +# ────────────────────────────────────────────── +# DNSSEC (prod only — applies to the whole domain) +# ────────────────────────────────────────────── +# +# transip_domain_dnssec requires at least 1 dnssec {} block with real key material +# (key_tag, flags, algorithm, public_key) from TransIP's authoritative servers. +# +# To add DNSSEC management: +# 1. Enable DNSSEC at https://www.transip.eu/cp/domein/ (or confirm it is already active) +# 2. Retrieve the key material from the TransIP API or control panel +# 3. Add a transip_domain_dnssec resource block with the actual values and apply +# +# resource "transip_domain_dnssec" "main" { +# count = local.is_prod ? 1 : 0 +# domain = var.base_domain +# +# dnssec { +# key_tag = # 5-digit value from TransIP +# flags = 257 # 256 = ZSK, 257 = KSK +# algorithm = 13 # ECDSA-P256-SHA256 +# public_key = "" +# } +# } diff --git a/terraform/providers.tf b/terraform/providers.tf index 4bb051b..69eb8e6 100644 --- a/terraform/providers.tf +++ b/terraform/providers.tf @@ -4,3 +4,8 @@ provider "hcloud" { token = local.hcloud_token } +provider "transip" { + account_name = var.transip_account_name + private_key = var.transip_private_key +} + diff --git a/terraform/variables.tf b/terraform/variables.tf index b06f536..1398353 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -4,6 +4,17 @@ variable "hcloud_token" { sensitive = true } +variable "transip_account_name" { + description = "TransIP API account name" + type = string +} + +variable "transip_private_key" { + description = "TransIP API private key (PEM)" + type = string + sensitive = true +} + variable "ssh_keys" { description = "List of Hetzner Cloud SSH key IDs to authorize on the server" type = list(string) diff --git a/terraform/versions.tf b/terraform/versions.tf index 49c78cc..6876279 100644 --- a/terraform/versions.tf +++ b/terraform/versions.tf @@ -15,6 +15,10 @@ terraform { source = "hetznercloud/hcloud" version = "~> 1.45" } + transip = { + source = "aequitas/transip" + version = "~> 0.1" + } } } From 2dfe5c961ccfc7612defc959e0528f6e94d229be Mon Sep 17 00:00:00 2001 From: Wander Grevink Date: Wed, 25 Mar 2026 14:48:27 +0000 Subject: [PATCH 4/4] simpler --- docs/dns.md | 6 +++--- terraform/dns.tf | 24 +----------------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/docs/dns.md b/docs/dns.md index 82924f8..b7e2a72 100644 --- a/docs/dns.md +++ b/docs/dns.md @@ -2,7 +2,7 @@ # DNS -DNS zones and records are managed by Terraform via the [aequitas/transip](https://registry.terraform.io/providers/aequitas/transip/latest) provider. TransIP acts as both registrar and authoritative DNS provider. DNSSEC is managed via `transip_domain_dnssec`. +DNS zones and records are managed by Terraform via the [aequitas/transip](https://registry.terraform.io/providers/aequitas/transip/latest) provider. TransIP acts as both registrar and authoritative DNS provider. DNSSEC is managed by TransIP defaults/policy. **Configuration:** [`terraform/dns.tf`](../terraform/dns.tf) @@ -39,12 +39,12 @@ flowchart LR | **prod** | `prod` A/AAAA, `@` A/AAAA, `registry` A/AAAA | Yes — derived from server IP | | **prod** | `www` CNAME | Static — points to `prod.` | | **prod** | Null MX, SPF, DMARC | Static — email anti-spoofing | -| **prod** | DNSSEC (`transip_domain_dnssec`) | Static — key material from TransIP | +| **prod** | DNSSEC | Managed by TransIP defaults/policy | | **dev** | `dev` A/AAAA | Yes — derived from server IP; destroyed with dev server | ## DNSSEC -DNSSEC is declared as a `transip_domain_dnssec` resource in the prod workspace. The key material (key_tag, flags, algorithm, public_key) comes from TransIP's control panel once DNSSEC is enabled for the domain. Add the values to the `dns.tf` resource block and apply. +DNSSEC is enabled and managed by TransIP defaults/policy outside Terraform. ## Adding a new domain diff --git a/terraform/dns.tf b/terraform/dns.tf index 829b546..52f1aec 100644 --- a/terraform/dns.tf +++ b/terraform/dns.tf @@ -130,26 +130,4 @@ resource "transip_dns_record" "dmarc" { content = ["v=DMARC1; p=reject;"] } -# ────────────────────────────────────────────── -# DNSSEC (prod only — applies to the whole domain) -# ────────────────────────────────────────────── -# -# transip_domain_dnssec requires at least 1 dnssec {} block with real key material -# (key_tag, flags, algorithm, public_key) from TransIP's authoritative servers. -# -# To add DNSSEC management: -# 1. Enable DNSSEC at https://www.transip.eu/cp/domein/ (or confirm it is already active) -# 2. Retrieve the key material from the TransIP API or control panel -# 3. Add a transip_domain_dnssec resource block with the actual values and apply -# -# resource "transip_domain_dnssec" "main" { -# count = local.is_prod ? 1 : 0 -# domain = var.base_domain -# -# dnssec { -# key_tag = # 5-digit value from TransIP -# flags = 257 # 256 = ZSK, 257 = KSK -# algorithm = 13 # ECDSA-P256-SHA256 -# public_key = "" -# } -# } +# DNSSEC is managed by TransIP defaults/policy outside Terraform.