Codifying scotthavird.com in Terraform
Codifying scotthavird.com in Terraform
For years this site lived in two places at once: the Docusaurus repo and a scattering of Cloudflare dashboard clicks. Pages project settings, custom domains, zone security toggles, and an email signup API that only existed in AWS — none of it was reviewable in a PR.
That drift is fine until it isn't. I hit the www redirect trap on my own domain. Preview builds failed because Node 18 was still configured in the dashboard while Docusaurus 3.9 requires Node 20. Each fix was another manual change with no audit trail.
So I codified the whole stack in iac/ and retired the GitHub Actions deploy workflow in favor of native Cloudflare Pages builds.
Moved scotthavird.com infrastructure into Terraform: Cloudflare Pages (build + env vars), zone security (SSL strict, HSTS preload), DNS for SES, and an AWS Lambda subscribe API wired into the Docusaurus build via SUBSCRIBE_API_URL. Dashboard-only config is now PR-reviewable.
What Moved Into Terraform
The iac/ directory now owns four concerns that used to be split across dashboards:
| Concern | Resource | Why codify it |
|---|---|---|
| Site hosting | cloudflare_pages_project | Build command, Node version, preview branch rules |
| Zone security | null_resource + Cloudflare API | SSL strict, HSTS preload, TLS 1.3 |
| Email signup | API Gateway + Lambda + DynamoDB + SES | Homepage newsletter form |
| DNS for email | cloudflare_record (SES verification, DKIM) | Deliverability without manual TXT records |
The Docusaurus site itself stays in the same repo. Cloudflare Pages builds main on every push — no separate deploy workflow.
Cloudflare Pages: The Important Details
The Pages project resource looks straightforward, but a few fields matter in practice:
build_config {
build_command = "yarn build"
destination_dir = "build"
}
deployment_configs {
production {
environment_variables = {
NODE_VERSION = "20"
SUBSCRIBE_API_URL = aws_apigatewayv2_api.main.api_endpoint
}
}
}
Node 20 — Docusaurus 3.9.2 requires it. The dashboard was still on 18, which meant every PR preview failed with yarn: command not found or engine warnings. Setting it in Terraform means preview and production stay aligned.
SUBSCRIBE_API_URL — Docusaurus reads this at build time via process.env.SUBSCRIBE_API_URL and exposes it as siteConfig.customFields.apiUrl. The homepage EmailSignup component POSTs to ${apiUrl}/subscribe. Wiring the API Gateway endpoint into the Pages env var means the frontend always targets the right backend without hardcoding URLs.
Preview branch excludes — gh-pages is excluded from preview builds. That branch is the old peaceiris deploy output; it has no package.json and will fail every build attempt.
One gap: build_image_major_version (v1 → v3) isn't exposed in the Cloudflare provider v4. I set it via a one-time API PATCH and documented the workaround in iac/pages.tf. Provider v5 adds native support — revisit on upgrade.
Zone Security Without the Provider Trap
The cloudflare_zone_settings_override resource in provider v4 reads all zone settings during refresh, including Enterprise-only fields that return HTTP 999 on Free plans. That makes terraform apply fail even when your target settings are correct.
The workaround: a null_resource with local-exec that PATCHes only the settings we care about:
- SSL mode: strict
- Always Use HTTPS: on
- Minimum TLS: 1.2, TLS 1.3: on
- HSTS: 2-year max-age, include subdomains, preload-ready
The triggers block hashes the target values so any change re-applies. It's not elegant, but it's reliable on a Free-plan zone.
Email Subscribe API
The homepage newsletter form needs a backend. The stack:
- API Gateway HTTP API —
POST /subscribe,GET /confirm?token=... - Lambda — validates email, stores pending token in DynamoDB, sends confirmation via SES
- DynamoDB — subscribers table + tokens table with TTL for expired confirmations
- SES — domain-verified sending from
scotthavird.com
All tagged with Project = scotthavird-com and environment-scoped naming (scotthavird-prod-*).
The double opt-in flow matters: we don't add addresses to the list until the user clicks the confirmation link. Tokens expire via DynamoDB TTL.
What I Deliberately Didn't Codify
www → apex redirect — Cloudflare Pages owns the http_request_dynamic_redirect ruleset for custom domains. Zone-level Rulesets API tokens can't override it. The fix is documented in Your Cloudflare Pages Redirect Is Probably Backwards: set the apex as the primary custom domain in Pages, then add a Page Rule for belt-and-suspenders.
GitHub Actions deploy — Retired. Cloudflare Pages builds directly from the repo. One less moving part, preview URLs on every PR, and no GITHUB_TOKEN deploy secret to rotate.
Operating It
cd iac
terraform plan -var-file=prod.tfvars
terraform apply -var-file=prod.tfvars
Prod and dev workspaces share the pattern; DNS records for SES are prod-only (local.is_prod) to avoid conflicts.
Site-side guardrails run in CI on every PR:
yarn ci # lint:posts → update:llms → build → validate:schema
Infrastructure changes and content changes can ship in the same PR when needed — but they're reviewed separately because they touch different directories.
Lessons
- Codify the stuff that breaks silently. Redirect direction and Node version don't fail loudly until SEO or previews are already damaged.
- Build-time env vars for frontend API URLs. Hardcoding API Gateway endpoints in React is a deploy-order nightmare; Pages env vars solve it cleanly.
- Provider limitations are real. When the Terraform resource can't express what you need, document the out-of-band step and add a trigger or comment so the next person doesn't "fix" it back to broken.
- Keep the site and the infra in one repo. Docusaurus content and Cloudflare/AWS resources share a lifecycle. One PR can add a feature and the API it needs.
Related Posts
- Your Cloudflare Pages Redirect Is Probably Backwards — The www/apex trap that motivated part of this work
- Ways of Working at the Speed of Thought — Why reducing deploy friction matters when engineers ship in hours