Run this once per new project, with administrator 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,
uncomment the backend "s3" block at the top of the file. Then 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
-
Administrator AWS credentials in the shell.
aws sts get-caller-identityreturns an administrator 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. It needs no manual thumbprint.
-
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 administrator-owned repo (suggested name:terraform-aws-foundation):
The bootstrap module
The Terraform code lives indryvist/tofu-aws-templates (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, shown in the preceding block). 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. It needs no separate IAM permission and no DynamoDB table.
Bootstrap the chicken-and-egg
1
Apply locally
With the preceding module block 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).2
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 preceding
module already configures the bucket’s default SSE-S3 encryption. kms_key_id is not needed,
because there is no KMS key.3
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
backend.tf and run its first terraform plan.
Where to go next
OpenTofu check placement
Where every Terraform / OpenTofu command runs — pre-commit vs CI.