Skip to main content
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-identity returns 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:AssumeRole on arn: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):
Each per-project directory is independent: its own state object lives in its own bucket, so projects cannot affect each other even by accident.

The bootstrap module

The Terraform code lives in dryvist/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:
Full input / output reference and the list of underlying AWS resources live in the module repo’s README. The module pins to a tagged release (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

1

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).
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 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.
3

Migrate state into the bucket

Terraform prompts to copy the local state file into the bucket. Confirm. Then delete the local artefacts:
The bootstrap is now self-hosting in the bucket it created. Subsequent 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

If all three succeed, the consuming repo can immediately set up its backend.tf and run its first 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 (legacy) and the OpenBao broker

How operators and agents now reach the role this bootstrap created.