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:
    aws iam list-open-id-connect-providers \
      --query 'OpenIDConnectProviderList[?contains(Arn, `token.actions.githubusercontent.com`)]'
    
    If the result is empty, create it once per account (one-time, account-wide):
    aws iam create-open-id-connect-provider \
      --url https://token.actions.githubusercontent.com \
      --client-id-list sts.amazonaws.com
    
    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):
terraform-aws-foundation/
├── bootstrap/
│   ├── proxmox/
│   │   ├── main.tf          # this file
│   │   └── terraform.tfvars # per-project values
│   ├── unifi/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   └── ...
└── README.md
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/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:
terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  # First `terraform apply` runs with local state. Once the bucket exists,
  # uncomment this block (substitute the bucket name the apply emits) and
  # run `terraform init -migrate-state` to lift state into the bucket.
  #
  # backend "s3" {
  #   bucket       = "tfstate-<project>-<account-id>"
  #   key          = "_bootstrap/terraform.tfstate"
  #   region       = "us-east-1"
  #   use_lockfile = true
  #   encrypt      = true
  # }
}

provider "aws" {
  region = "us-east-1"
}

module "state_backend" {
  source = "git::https://github.com/dryvist/terraform-aws-template.git?ref=v0.1.0"

  project        = "proxmox"
  github_org     = "<github-org>"
  github_repo    = "terraform-proxmox"
  branch_pattern = "main"

  operator_user_arns = [
    "arn:aws:iam::<account-id>:user/<operator>",
  ]
}

output "backend_config"   { value = module.state_backend.backend_config }
output "tf_role_arn"      { value = module.state_backend.tf_role_arn }
output "state_bucket"     { value = module.state_backend.state_bucket }
output "state_key_prefix" { value = module.state_backend.state_key_prefix }
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:
terraform init      # local state — no backend block yet
terraform apply
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:
backend "s3" {
  bucket       = "tfstate-proxmox-<account-id>"
  key          = "_bootstrap/terraform.tfstate"
  region       = "us-east-1"
  use_lockfile = true
  encrypt      = true
}
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 init -migrate-state
Terraform prompts to copy the local state file into the bucket. Confirm. Then delete the local artefacts:
rm terraform.tfstate terraform.tfstate.backup
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

# State bucket exists and contains the bootstrap state.
aws s3 ls s3://tfstate-<project>-<account-id>/_bootstrap/

# Role exists with the expected name.
aws iam get-role --role-name tf-<project>

# Outputs — feed these into the consuming repo's backend.tf.
terraform output -json
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 profile mechanics

Operator-side credential management for the role this bootstrap created.