Run this once per new project, with admin AWS credentials. Output is everything a new repo’s backend.tf needs.
The bootstrap is plain Terraform. The first apply runs with local state; once the bucket exists, you uncomment the backend "s3" block at the top of the file and terraform init -migrate-state lifts the state into the bucket the bootstrap itself just created. The bucket then hosts both its own bootstrap state (_bootstrap/terraform.tfstate) and the consuming repo’s state (<project>/terraform.tfstate).
Prerequisites
-
Admin AWS credentials in the shell —
aws sts get-caller-identityreturns an admin ARN. - Terraform ≥ 1.10 or OpenTofu ≥ 1.10 on PATH.
-
The new GitHub repo (
<github-org>/<github-repo>) already exists. -
The GitHub Actions OIDC provider exists in the AWS account. Check with:
If the result is empty, create it once per account (one-time, account-wide):AWS verifies the GitHub Actions issuer’s certificate chain automatically — no manual thumbprint is needed.
-
Each human operator has an IAM user with MFA enabled, and a policy granting only
sts:AssumeRoleonarn:aws:iam::<account-id>:role/tf-*(no direct resource permissions). Operator IAM user creation is a per-operator one-time step, separate from per-project bootstrap.
Where this lives
The recommended layout is one directory per project inside a single admin-owned repo (suggested name:terraform-aws-foundation):
The bootstrap module
The Terraform code lives indryvist/terraform-aws-template (Apache-2.0, public). Each per-project bootstrap directory is a small root module that wires the published module to the project’s values:
v0.1.0 above) — breaking changes ship as new majors so existing bootstraps stay valid until you re-pin.
The S3-native lock object (<project>/terraform.tfstate.tflock) is just another S3 object under the same prefix as state — no separate IAM permission required, no DynamoDB table.
Bootstrap the chicken-and-egg
Apply locally
With the module block above pointing at your project’s values:Confirm the apply.
terraform output backend_config emits the ready-to-paste backend "s3" {} block for the consuming repo (terraform output -raw backend_config > /tmp/backend.tf to ship it straight to a file).Uncomment the backend block
In
main.tf, uncomment the backend "s3" block at the top of the terraform {} block and substitute the outputs the apply just produced:encrypt = true instructs the client to send the SSE header on every PutObject. The bucket’s default SSE-S3 encryption is already configured by the module above — no kms_key_id is needed because there is no KMS key.Migrate state into the bucket
terraform plan / terraform apply runs against the bootstrap (for example to widen branch_pattern or add another operator) work like any other Terraform module.Verify
terraform plan.
Where to go next
OpenTofu on AWS overview
The isolation model and naming conventions this bootstrap implements.
Set up the consuming repo
What the new repo drops in next to use the outputs above.
OpenTofu check placement
Where every Terraform / OpenTofu command runs — pre-commit vs CI.
aws-vault profile mechanics
Operator-side credential management for the role this bootstrap created.