|
| 1 | +# Cloudflare Multi-Domain DNS Design |
| 2 | + |
| 3 | +**Date:** 2026-04-27 |
| 4 | +**Status:** Approved |
| 5 | + |
| 6 | +## Overview |
| 7 | + |
| 8 | +Add Cloudflare DNS management to the core-infra monorepo, replacing Route53 as the DNS provider. All zones and account-level resources share a single Terraform state under `providers/cloudflare/`. A reusable `modules/zone/` module enforces consistent record structure across domains; a `for_each` loop in `main.tf` instantiates it for every zone defined in `zones.tf`. A scaffold script adds new zones with minimal friction. |
| 9 | + |
| 10 | +## Directory Structure |
| 11 | + |
| 12 | +``` |
| 13 | +providers/cloudflare/ |
| 14 | + modules/zone/ |
| 15 | + main.tf # cloudflare_zone + for_each over each record type |
| 16 | + variables.tf # zone_name, account_id, and per-type record lists |
| 17 | + outputs.tf # zone_id, zone_name |
| 18 | + zones.tf # locals { zones = { "domain.com" = { a_records = [...] } } } |
| 19 | + main.tf # module "zones" { for_each = local.zones } + account-level resources |
| 20 | + versions.tf # cloudflare provider ~> 5.0, GCS backend prefix = "cloudflare" |
| 21 | + variables.tf # api_token, account_id |
| 22 | + outputs.tf # map of zone_name → zone_id |
| 23 | + terraform.tfvars # gitignored — api_token + account_id values |
| 24 | + backend.hcl # gitignored — GCS bucket name |
| 25 | +``` |
| 26 | + |
| 27 | +## Module Interface (`modules/zone/`) |
| 28 | + |
| 29 | +### Inputs |
| 30 | + |
| 31 | +| Variable | Type | Required | Description | |
| 32 | +|---|---|---|---| |
| 33 | +| `zone_name` | `string` | yes | Domain name (e.g. `"mysite.com"`) | |
| 34 | +| `account_id` | `string` | yes | Cloudflare account ID | |
| 35 | +| `a_records` | `list(object({ name, content, proxied, ttl }))` | no | Defaults to `[]` | |
| 36 | +| `mx_records` | `list(object({ name, content, priority, ttl }))` | no | Defaults to `[]` | |
| 37 | +| `cname_records` | `list(object({ name, content, proxied, ttl }))` | no | Defaults to `[]` | |
| 38 | +| `txt_records` | `list(object({ name, content, ttl }))` | no | Defaults to `[]` | |
| 39 | +| `srv_records` | `list(object({ name, service, proto, priority, weight, port, target, ttl }))` | no | Defaults to `[]` | |
| 40 | + |
| 41 | +Each record type is iterated inside the module with `for_each`. Keys are `"${name}:${content}"` (or `"${name}:${port}"` for SRV) to ensure uniqueness when multiple records share a name (e.g., two `@` MX records). All optional types default to `[]` so zones omit unused types without nulls or conditionals. |
| 42 | + |
| 43 | +### Outputs |
| 44 | + |
| 45 | +| Output | Description | |
| 46 | +|---|---| |
| 47 | +| `zone_id` | Cloudflare zone ID (useful for cross-state references) | |
| 48 | +| `zone_name` | Domain name (pass-through) | |
| 49 | + |
| 50 | +## Root Module (`providers/cloudflare/`) |
| 51 | + |
| 52 | +### `zones.tf` |
| 53 | + |
| 54 | +Single source of truth for all managed zones. Each key is a domain name; each value is an object with optional record type lists. Zones omit record types they don't use. |
| 55 | + |
| 56 | +```hcl |
| 57 | +locals { |
| 58 | + zones = { |
| 59 | + "mysite.com" = { |
| 60 | + a_records = [ |
| 61 | + # NOTE: home.mysite.com is excluded — managed by favonia/cloudflare-ddns |
| 62 | + { name = "@", content = "1.2.3.4", proxied = true, ttl = 1 }, |
| 63 | + ] |
| 64 | + mx_records = [ |
| 65 | + { name = "@", content = "mail.mysite.com", priority = 10, ttl = 300 }, |
| 66 | + ] |
| 67 | + } |
| 68 | + "minecraft.example.com" = { |
| 69 | + a_records = [ |
| 70 | + { name = "@", content = "1.2.3.4", proxied = false, ttl = 300 }, |
| 71 | + ] |
| 72 | + srv_records = [ |
| 73 | + { name = "_minecraft", service = "_minecraft", proto = "_tcp", |
| 74 | + priority = 0, weight = 5, port = 25565, target = "mc.example.com", ttl = 300 }, |
| 75 | + ] |
| 76 | + } |
| 77 | + } |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +### `main.tf` |
| 82 | + |
| 83 | +```hcl |
| 84 | +module "zones" { |
| 85 | + for_each = local.zones |
| 86 | + source = "./modules/zone" |
| 87 | + zone_name = each.key |
| 88 | + account_id = var.account_id |
| 89 | + a_records = lookup(each.value, "a_records", []) |
| 90 | + mx_records = lookup(each.value, "mx_records", []) |
| 91 | + cname_records = lookup(each.value, "cname_records", []) |
| 92 | + txt_records = lookup(each.value, "txt_records", []) |
| 93 | + srv_records = lookup(each.value, "srv_records", []) |
| 94 | +} |
| 95 | +
|
| 96 | +# Account-level resources (budget alerts, etc.) go here |
| 97 | +``` |
| 98 | + |
| 99 | +## Scaffold Script (`scripts/create-cloudflare-zone.sh`) |
| 100 | + |
| 101 | +**Usage:** `./scripts/create-cloudflare-zone.sh <domain>` |
| 102 | + |
| 103 | +The script: |
| 104 | +1. Validates the domain argument (non-empty, basic format check) |
| 105 | +2. Checks `zones.tf` to confirm the domain is not already present |
| 106 | +3. Appends a minimal zone entry stub to `zones.tf` |
| 107 | +4. Prints the Terraform import command and next steps |
| 108 | + |
| 109 | +**Import command format** (printed by script): |
| 110 | +```bash |
| 111 | +terraform import 'module.zones["mysite.com"].cloudflare_zone.this' <ZONE_ID> |
| 112 | +``` |
| 113 | + |
| 114 | +State key uses the domain name as the map key, matching the `for_each` key in `main.tf`. |
| 115 | + |
| 116 | +## DDNS Integration |
| 117 | + |
| 118 | +The DDNS-managed subdomain (e.g. `home.mysite.com`) is **excluded from Terraform** — it is created and updated exclusively by `favonia/cloudflare-ddns`. Since `lifecycle { ignore_changes }` cannot be conditionally applied from a variable, keeping the record out of state entirely is the safest approach. A comment in `zones.tf` marks which subdomain is DDNS-managed. |
| 119 | + |
| 120 | +**Recommended container:** `favonia/cloudflare-ddns` |
| 121 | + |
| 122 | +```env |
| 123 | +CLOUDFLARE_API_TOKEN=<scoped token: Zone:DNS:Edit for the specific zone> |
| 124 | +DOMAINS=home.mysite.com |
| 125 | +PROXIED=false |
| 126 | +``` |
| 127 | + |
| 128 | +Use a scoped API token (Zone → DNS → Edit, limited to the specific zone) rather than a global token. |
| 129 | + |
| 130 | +## Migration Steps (first domain) |
| 131 | + |
| 132 | +1. Lower Route53 TTLs to 60–300s a few hours before cutover |
| 133 | +2. Add domain to Cloudflare via web UI (auto-imports records for review) |
| 134 | +3. `terraform init -backend-config="bucket=tapshalkar-com-tfstate"` |
| 135 | +4. `terraform import 'module.zones["mysite.com"].cloudflare_zone.this' <ZONE_ID>` |
| 136 | +5. `terraform plan` — verify no unexpected diff |
| 137 | +6. Update nameservers at Route53 Registered Domains to Cloudflare's NS values |
| 138 | +7. Verify propagation: `dig NS mysite.com @8.8.8.8` |
| 139 | +8. Decommission Route53 hosted zone |
| 140 | + |
| 141 | +## State Backend |
| 142 | + |
| 143 | +- **Backend:** GCS, `prefix = "cloudflare"` |
| 144 | +- **Bucket:** `tapshalkar-com-tfstate` (shared with all other providers) |
| 145 | +- Consistent with PagerDuty, Hetzner, and GitHub provider patterns in this repo |
| 146 | + |
| 147 | +## Future Extensions |
| 148 | + |
| 149 | +Account-level resources (Workers, R2 buckets, WAF rulesets, Zero Trust tunnels) are added directly to `providers/cloudflare/main.tf` alongside the `module "zones"` block. No structural changes needed. |
0 commit comments