Infrastructure as code: declared for what lasts, scripted for each release

The site's Google Cloud infrastructure is declared as code, planned before it changes and tested in CI on a mocked provider; scripts keep each release's image and files.

Status
Accepted 7 November 2026
The record
0022-infrastructure-as-code.md

Context

ADR 0002 chose the shape: a bucket and Cloud Run behind one HTTPS load balancer. It was built as five gcloud scripts, never run. A second run of the setup script would have failed on what the first made; nothing showed what a change would do before it did it; the service's settings lived in the flags of one command, which the scripts' readers had to trust were the deployed ones; and the API ran as the Compute Engine default account, an Editor of the whole project.

Decision

  • Declared in Terraform. infra/gcp/terraform (Terraform 1.16 or later, hashicorp/google 8.x) declares the APIs, Artifact Registry with a cleanup policy, what Cloud Build needs (its account's role, the bucket of its sources), the API's own service account, the secrets and who may read them, the Cloud Run service with every setting and its migration job, the bucket, Cloud CDN, the serverless NEG, the URL map, the managed certificate, the proxies, the address and the HTTP redirect. Its state is kept in a versioned bucket of the project's own, which bootstrap.sh makes. terraform plan says what a change will do before it does it.
  • The release, left to scripts. What changes with every release is not Terraform's: deploy-api.sh builds the image, migrates with it and rolls it out, changing nothing else of the service; deploy-web.sh syncs the built files. Terraform makes the service and the job with a sample image and leaves the image to the deploys. The scripts read every name from Terraform's outputs.
  • No secret through Terraform. It makes the secrets and their access; their values are added with gcloud, so that none passes through the state or terraform.tfvars. preflight.sh checks that each secret the service reads has a value, which is the one thing Terraform cannot see.
  • An account of the API's own, which may read its secrets and nothing else.
  • One source for the headers. The headers the load balancer adds come from headers.json, which the guide's browser flow serves dist/ with too; the CSP's hash of the theme script is computed from web/index.html, and deploy-web.sh refuses a build whose script hashes otherwise.
  • Checked before anything is made. The variables are validated: the database URL's form and its socket timeout (lab audit), Neon's pooler refused, the public features' need of a key and a database. The URL map carries tests of its routes, which Google runs at every change of it. terraform test applies the configuration to a mocked provider and holds the settings the site depends on to this record; CI runs fmt, validate and test without a cloud account.

Consequences

  • First applied to a real project on 2026-11-09. The tests had not seen what an organization's policies take away, so the first deploy added the role a build needs and the bucket of its sources to what is declared.
  • The cost does not change: the forwarding rules are the fixed cost, about $18–20 a month.
  • A change to the theme script needs an apply before the deploy; pages already cached run with the default theme between the two.
  • The Cloud Run service is protected from deletion: taking the site down is a deliberate change first.
  • Two things the lab audit left for the deploy are settled here: the database URL cannot be set without a socket timeout, and a deploy keeps the earlier builds' hashed files for pages still open.
  • This record's title and summary, which the site's catalogue carries, name no tool: the fit check and the guide read the catalogue as evidence of the owner's experience, and the site's own Terraform, applied once for its own deploy, is not that. Whether it becomes evidence is the owner's to say; the fit check's platform-terraform example holds the gap until then.