Skip to content

Infrastructure as code with Terraform

  • Status: Accepted
  • Date: 2026-06-06
  • Deciders: Platform, Engineering, Security

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.

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 deploy in CI (ADR 0009), not by Terraform; DNS for the apex/www custom 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 s3 backend pointed at R2). State stays inside infrastructure we already own; no extra state SaaS.
  • Environments: at minimum production and preview/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) — plan on pull requests for review, a weekly scheduled drift check, and apply only by deliberate manual workflow_dispatch. Application deploys (wrangler deploy) run independently on push to main; infrastructure is reconciled deliberately rather than on every merge, so an unreviewed plan can never destroy stateful resources.

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 deploy owns 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.
  • 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).