Skip to content

Commit e8fe1d5

Browse files
adityatapsclaude
andcommitted
docs(spec): add Cloudflare multi-domain DNS design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 77f325d commit e8fe1d5

1 file changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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

Comments
 (0)