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.
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/google8.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, whichbootstrap.shmakes.terraform plansays 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.shbuilds the image, migrates with it and rolls it out, changing nothing else of the service;deploy-web.shsyncs 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 orterraform.tfvars.preflight.shchecks 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 servesdist/with too; the CSP's hash of the theme script is computed fromweb/index.html, anddeploy-web.shrefuses 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 testapplies the configuration to a mocked provider and holds the settings the site depends on to this record; CI runsfmt,validateandtestwithout 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-terraformexample holds the gap until then.