Infrastructure as code with Terraform
- Status: Accepted
- Date: 2026-06-06
- Deciders: Platform, Engineering, Security
Context
Section titled “Context”Our infrastructure runs on a single provider — Cloudflare (Workers + Static Assets, D1, R2, Images, WAF, Turnstile, DNS, Web Analytics — ADR 0006, ADR 0014). Click-ops in the dashboard is unreviewable, undocumented, irreproducible, and a security risk (drift, orphaned resources, ad-hoc secrets). We need infrastructure that is versioned, reviewed, and reproducible across environments.
Decision
Section titled “Decision”All infrastructure is defined as Terraform in the infra/ workspace, organized as reusable modules (infra/modules) instantiated per environment (infra/environments).
- Provider: the single Cloudflare provider. Terraform manages the stateful/config plane — zone, D1 database, R2 buckets, Turnstile widget, and the WAF + cache rulesets. The Worker itself (plus its static assets and D1 binding) is deployed by
wrangler deployin CI (ADR 0009), not by Terraform; DNS for the apex/wwwcustom domains is bound by the Worker’s custom-domain routes (wrangler), not a discrete DNS record. - State backend: Cloudflare R2 via its S3-compatible API (an
s3backend pointed at R2). State stays inside infrastructure we already own; no extra state SaaS. - Environments: at minimum
productionandpreview/staging, each a thin composition over shared modules so the topology is provably identical and only inputs differ. - Secrets: never committed. Provider credentials and app secrets come from CI/CD secrets and Cloudflare secret stores (Worker secrets); Terraform variables marked
sensitive. - Execution: Terraform runs in CI (ADR 0009) —
planon pull requests for review, a weekly scheduled drift check, andapplyonly by deliberate manualworkflow_dispatch. Application deploys (wrangler deploy) run independently on push tomain; infrastructure is reconciled deliberately rather than on every merge, so an unreviewed plan can never destroy stateful resources.
Consequences
Section titled “Consequences”Positive
- The entire config plane is reproducible, reviewable, and diffable in PRs.
- Environments are structurally identical, eliminating “works in staging” drift.
- State lives in R2, inside owned infrastructure, S3-compatible and versionable.
- Onboarding and disaster recovery become “run the code,” not “remember the clicks.”
Negative / costs
- Terraform is still a learning and maintenance surface, though a single Cloudflare provider keeps it small.
- Two deploy tools split the source of truth: Terraform owns the config plane (zone, D1, R2, Turnstile, WAF/cache) while
wrangler deployowns the Worker + static assets. The boundary must stay clear so neither fights the other over the same resource. - State in R2 requires careful locking discipline (and the operational care any remote state demands).
- Provider schema changes occasionally require module updates.
Alternatives considered
Section titled “Alternatives considered”- Click-ops in the dashboard. Unreviewable, drift-prone, irreproducible, a security liability. Rejected — it is the problem.
- Pulumi (IaC in TypeScript). Attractive given our TS stack, but Terraform’s Cloudflare provider maturity and the team’s familiarity win for infra we must trust. Reasonable future revisit.
- Wrangler-only (no Terraform for the config plane). Wrangler deploys the Worker well, but it is not a substitute for reviewable plan/apply over the zone, D1, R2, Turnstile, and WAF/cache rulesets. We keep wrangler for the Worker and Terraform for the stateful/config plane. Rejected as the sole tool.
- Terraform Cloud / separate state SaaS. Works, but adds a vendor when R2 (already in our stack) serves as an S3-compatible backend. Rejected for now.
Note: the prior two-cloud setup (Cloudflare + DigitalOcean) was simplified to a single Cloudflare provider when the data tier moved to D1 (ADR 0014).