OpenTofu for the results API — the api/ tree, which is the server that ships.
This deploys only the serving plane. The batch pipeline is a separate
deployment under deploy/cron/ and shares nothing here but the account. See
openspec/changes/provision-aws/ for the proposal, the decision record
(A1–A16), and the task list.
- OpenTofu >= 1.10. Not optional: state locking uses
use_lockfile, native S3 conditional writes, which does not exist before 1.10 — v1.9 documents onlydynamodb_tableand disables locking silently without it. Each root module declares the constraint;../.opentofu-versionpins the exact version for both planes, and version managers of thetenv/tofuenvfamily find it by searching upward from wherever you are standing. - AWS credentials for the target account.
- Run off any TLS-intercepting network. A VPN that re-signs certificates
makes the AWS SDK fail with
SSL validation failed ... self-signed certificate in certificate chain, which reads like a permissions error and is not one.
bootstrap/ one-time; creates the state bucket (see below)
modules/ building blocks, internal to this deployment
environments/
staging/ root module, own state key
production/ root module, own state key
Each root module is independent — bootstrap/, environments/staging/, and
environments/production/ each carry their own terraform block and provider
configuration. Terraform and OpenTofu do not inherit those from a parent
directory, so there is no tree-wide versions.tf and the duplication is
required rather than sloppy.
Environments are separate root modules with separate state keys, not workspaces. Workspaces share one backend configuration, which makes it possible to apply to production while believing you are in staging. Here the target is the directory you are standing in.
The bucket that holds state has to exist before there is a backend to record its creation. That circle is broken once, by hand:
cd bootstrap
# 1. Apply with LOCAL state. The backend block is commented out on purpose.
tofu init
tofu apply -var region=<region> -var state_bucket_name=<bucket>
# 2. Uncomment the backend block in main.tf, filling in the bucket and region
# from the outputs above.
# 3. Move this root module's own state into the bucket it just created.
tofu init -migrate-state
# 4. The local state file is now a copy, not the source of truth.
rm -f terraform.tfstate terraform.tfstate.backupAfter step 4, a terraform.tfstate in any directory here is a mistake. It
means someone ran a root module whose backend block was missing or commented
out, and their changes are recorded only on their laptop.
The region is deliberately a required variable with no default. It is an observation — wherever the result buckets already are — not a choice; a service in a different region from its buckets loses the S3 gateway endpoint and pays NAT egress on every object read (A5).
bootstrap/ first, then staging, then production. The order is not
cosmetic:
bootstrap/creates the state bucket every other root writes into.stagingcreates the GitHub OIDC provider;productionconsumes it. AWS permits one provider per issuer per account, so production setscreate_oidc_provider = falseand takesoidc_provider_arnfrom staging's output. Applying production first fails, or creates the provider in the wrong root.
cd environments/staging
# The bucket is chosen at bootstrap, so it is supplied at init rather than
# committed.
tofu init -backend-config=bucket=<state bucket>
# First run only: the certificate has to exist before its validation records
# can be read, and they are created by hand in Netlify.
tofu apply -target=module.edge.aws_acm_certificate.this
tofu output -json certificate_validation_records # create these in Netlify
tofu plan
tofu applyimage and origin_hostname have no defaults and must be supplied.
After an apply, confirm the build that is serving. Fastly caches /version
under the scorecard-version surrogate key (TTL in
api/app/server/version.go), so purge that key or wait out the TTL first.
curl -s https://api.scorecard.dev/versionBoth environments point at the same corpus buckets, because conformance against an empty bucket proves nothing — every request would 404 identically whether or not the service worked.
Only production may write. The API's publish path writes into whichever
bucket it reads as primary, so a staging task with PutObject could overwrite
production results with output from an unproven build — and versioning is not
enabled on those buckets, so there would be nothing to restore from. The write
is gated behind enable_publish_writes, default false, set true only in
environments/production.
Staging therefore fails the POST path by design. That is not a gap in the
rehearsal: the conformance harness is GET requests, and the publish path is
gated separately by watching a real Action upload land.
modules/service/main.tf sets every environment variable and secret the API
reads. This is the mapping from that Terraform to the code that consumes it:
| Variable | Source here | Consumed at |
|---|---|---|
API_BASE_URL |
var.api_base_url, plain env |
post_results.go's getPurger — the hostname a post-publish CDN purge targets; purging is disabled if unset |
SCORECARD_RESULTS_BUCKET_URL |
s3://${var.results_bucket}?region=..., plain env |
api/app/server/config.go's resultsBucketURL — the primary bucket, read and written |
SCORECARD_CRON_RESULTS_BUCKET_URL |
s3://${var.cron_results_bucket}?region=..., plain env |
api/app/server/config.go's cronResultsBucketURL — the weekly-scan bucket, read-only fallback |
FASTLY_PURGE_TOKEN |
Secrets Manager, via var.fastly_secret_arn |
post_results.go's getPurger — the only credential the API reads; purging is disabled if unset |
The ?region= on both bucket URLs is required: gocloud.dev/blob's s3blob
resolves the region from the URL, not from the task's own AWS region.
- The corpus buckets. They already exist and hold data, so they are
referenced as
datasources and never declared as resources (A13). Declaring them would put the corpus onetofu destroy, one-targetmistake, or one deleted block away from deletion — and OpenTofu cannot tell "this should not exist" from "someone removed the block that declared it." Their versioning and lifecycle settings are out of scope here for the same reason. - Secret values. The secret resources and their access policies are
managed; the values are loaded out-of-band and
ignore_changescovers them. A value passed to OpenTofu is a value written to state, which would make the state bucket a credential store with weaker controls than the service built for it (A11). - DNS. Both zones are delegated to Netlify DNS, confirmed by the account
holding zero Route 53 zones. Two record sets have to be created there by hand,
and both are emitted as outputs:
- the ACM validation records —
aws_acm_certificate_validationblocks until they exist, so this shows up as a stalled apply rather than a silent prerequisite; - the origin hostname, pointing at the ALB.
- the ACM validation records —
The CDN is pinned to the ALB hostname, the production cutover is a single backend field in Fastly, and rollback is restoring that field. So the load balancer and its certificate are first-class resources here with a lifecycle independent of any workload — nothing should be able to change or destroy the origin as a side effect of a deployment (A9, A10).