Skip to main content
One IAM role per repo. Humans and CI both AssumeRole; nothing holds direct AWS resource access. State lives in a project-scoped S3 bucket; locks live in the same bucket via S3 conditional writes.
The pattern below is the single supported shape for any new AWS-backed Terraform / OpenTofu / Terragrunt repo. The bootstrap snippet provisions every resource named here in one terraform apply; the consuming-repo page shows what the new repo itself needs to drop in.

The isolation model

The per-project IAM role is the security boundary. Its trust policy lists exactly two principal types: GitHub OIDC for the matching repo, and named IAM users with MFA. Its permissions policy grants S3 access to exactly one bucket — its own. Humans authenticate as themselves with a base IAM user whose long-lived access key lives in the aws-vault macOS keychain. The base user holds one permission only: sts:AssumeRole on roles named tf-*. Every Terraform command runs through aws-vault exec tf-<project> -- ..., which mints a short-lived STS session for the per-project role and injects it into the subprocess. CI uses no static credentials at all. GitHub Actions exchanges its short-lived OIDC token for STS credentials directly via the role’s trust policy. There is no AWS_ACCESS_KEY_ID secret in any repo. The same role that a human assumes is the role CI assumes — one trust policy, one permissions policy, audited the same way.

Naming conventions

Every project uses the same naming shape so that an account-wide audit (aws s3 ls, aws iam list-roles --query "Roles[?starts_with(RoleName, \tf-`)]”`) is trivial.
ResourcePatternExample
S3 state buckettfstate-<project>-<account-id>tfstate-proxmox-111122223333
IAM roletf-<project>tf-proxmox
State object key<project>/terraform.tfstateproxmox/terraform.tfstate
Bootstrap state key_bootstrap/terraform.tfstate(same key in every project’s bucket)
<project> is a short kebab-case identifier matching the consuming repo’s last path segment (e.g. proxmox for terraform-proxmox, unifi for terraform-unifi). <account-id> is the 12-digit AWS account number — its inclusion in the bucket name makes the name globally unique across the S3 namespace without requiring a random suffix.

Encryption — why SSE-S3, not SSE-KMS

Every state bucket has bucket-default SSE-S3 (AES256) applied; the consuming repo’s backend block sets encrypt = true so each PutObject carries the SSE header explicitly. SSE-KMS uses the same AES-256 cipher under the hood. The difference is who owns the key material. SSE-KMS costs about $1 per month per project key plus a KMS API call on every state read and write — a real number in pipelines that re-plan on every PR. See AWS KMS pricing. Since access to the state bucket is already gated by the per-project IAM role’s trust policy (MFA-required for humans, OIDC-bound for CI), the KMS layer adds operational cost without changing who can read the state. Application-layer secrets that genuinely need MFA-gated or cross-account key control belong in Bitwarden for cold human secrets or Doppler for warm runtime injection — never inside the state file.

Where the long-lived AWS key actually lives

The base IAM user’s access key is stored in a dedicated aws-vault macOS keychain — a separate keychain from the login keychain, with its own password and access policy. No long-lived AWS credentials ever land in ~/.aws/credentials, in a .env file, or in shell history. Every Terraform invocation runs under a one-hour STS session minted by aws-vault exec tf-<project> -- <command>. aws-vault prompts for an MFA token on the first invocation per cached session window and silently re-uses the cached chained session for the remainder of session_ttl. See aws-vault for the profile-management mechanics.

Tagging

Every resource carries four tags, applied via the AWS provider’s default_tags block so individual resource declarations stay clean:
TagValue
Project<project> (same as in the naming table above)
ManagedByTerraform
Repo<github-org>/<github-repo>
Environmentbootstrap for the bootstrap module; per-environment (prod, staging) for the consuming repo
The Project tag should be activated as an AWS cost allocation tag (Billing → Cost allocation tags) so per-project spend appears in Cost Explorer.

Tool versions

ToolMinimum versionWhy
Terraform1.10S3 native locking (use_lockfile) released in Nov 2024
OpenTofu1.10S3 native locking released in 1.10 (conditional writes via If-None-Match)
aws-vault7.xStable keychain backend and chained session caching
AWS CLIv2aws sts assume-role behavior matches what the IAM trust policy expects
Terragrunt wraps Terraform and uses the same backend config — none of the isolation model changes. The Terragrunt-specific remote_state {} and generate {} blocks live in the consuming-repo page.

Where to go next

Bootstrap the AWS foundation

The admin-runnable Terraform that creates every per-project resource named on this page.

Set up the consuming repo

What goes inside the new repo so terraform plan runs immediately.

aws-vault profile mechanics

How the keychain backend, MFA, and session TTLs interact in practice.

Terraform check placement

Static checks in pre-commit, credentialed ops in CI. The placement rule every repo follows.