https://developer.hashicorp.com/terraform/language/backend/s3
https://developer.hashicorp.com/terraform/cli/commands/init
https://registry.terraform.io/providers/hashicorp/aws/latest/docs
https://github.com/hashicorp/terraform-provider-aws/blob/main/website/docs/index.html.markdown
https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html

1. Important Points#

Terraform AWS S3 backend:
    S3 bucket:
        stores terraform.tfstate
        versioning should be enabled for recovery from accidental state overwrite/delete

    state key:
        path inside the bucket, for example order/prod/default/ap-east-1/network/terraform.tfstate

    use_lockfile:
        enables S3-native state locking
        creates a .tflock object while Terraform is running
        deletes the .tflock object when the run finishes
        correct option is use_lockfile, not user_lockfile
scope:
    this page covers AWS provider guardrails and S3 backend config
    includes manual S3 backend bucket bootstrap
    AWS resource tag design is covered in Terraform AWS Tags
    AWS resource naming pattern is covered in Terraform AWS Naming
    S3 bucket naming and bucket policy are covered in Terraform AWS S3 Bucket
    IAM role design and AssumeRole usage are covered in Terraform AWS IAM Role
    IAM user manual Terraform migration is covered in Terraform AWS IAM User Manual Terraform
    GCP and Azure should have their own backend pages

AWS resource tags:

AWS resource naming:

IAM role design and creation usage:

S3 state bucket naming and policy:

2. AWS Provider Account And Region Guardrails#

AWS provider 的账号防呆用 allowed_account_ids。Terraform 初始化 provider 时会检查当前 credentials 对应的 AWS account ID;如果不在允许列表里,provider 会失败,避免在错误账号里 plan/apply。

Region 防呆通常不靠 provider 内置 allow list,而是靠:

region guardrails:
    provider region must be explicit
    aws_region variable must have validation
    provider alias must bind to a specific region/account
    backend key must include workload region
    CI should run aws sts get-caller-identity and aws configure get region / AWS_REGION check
provider "aws" {
  region = var.aws_region

  allowed_account_ids = [
    var.aws_account_id
  ]

  default_tags {
    tags = local.common_tags
  }
}
variable "aws_region" {
  type        = string
  description = "AWS region for this root module"

  validation {
    condition = contains([
      "ap-east-1",
      "ap-southeast-1"
    ], var.aws_region)

    error_message = "aws_region must be one of: ap-east-1, ap-southeast-1."
  }
}

variable "aws_account_id" {
  type        = string
  description = "Expected AWS account ID for this environment"
}
aws_region     = "ap-east-1"
aws_account_id = "123456789012"
why use allowed_account_ids:
    prevents using wrong AWS_PROFILE / credentials
    catches accidental dev credentials while applying prod
    catches CI role misconfiguration early
    makes account boundary explicit in code review

why validate region:
    prevents typo or unapproved region such as ap-east-2
    prevents accidental deploy to a region not approved for this system
    makes multi-region expansion explicit in code review

what it is not:
    not IAM authorization
    not SCP / permission boundary
    not a replacement for backend bucket/key isolation
    not a replacement for reviewing terraform plan

with assume role#

Use allowed_account_ids together with assume_role in multi-account setups. The allowed account should be the final target account Terraform manages. Keep region explicit in the same provider block.

provider "aws" {
  region = "ap-east-1"

  allowed_account_ids = [
    "123456789012"
  ]

  assume_role {
    role_arn     = "arn:aws:iam::123456789012:role/TerraformProvisionRole"
    session_name = "terraform-prod-default"
  }

  default_tags {
    tags = {
      Environment = "prod"
      ManagedBy   = "terraform"
    }
  }
}

provider aliases#

Use provider aliases when one root module legitimately needs more than one AWS provider instance.

good cases:
    same environment, same account, multiple workload regions
    same environment, cross-account dependency such as shared DNS / shared network
    bootstrap module that intentionally wires two tightly related accounts

avoid:
    dev and prod in the same root module
    uat and prod in the same root module
    many unrelated accounts in one root module
    using aliases to avoid proper state/workspace/root-module separation

Good case: one prod root module manages active resources in two regions in the same account.

provider "aws" {
  region = "ap-east-1"

  allowed_account_ids = ["123456789012"]
}

provider "aws" {
  alias  = "singapore"
  region = "ap-southeast-1"

  allowed_account_ids = ["123456789012"]
}
module "prod_hong_kong_ecs" {
  source = "./modules/ecs-service"

  providers = {
    aws = aws
  }
}

module "prod_singapore_ecs" {
  source = "./modules/ecs-service"

  providers = {
    aws = aws.singapore
  }
}

Good case: one prod workload module creates records in a shared DNS account.

provider "aws" {
  region = "ap-east-1"

  allowed_account_ids = ["123456789012"]
}

provider "aws" {
  alias  = "shared_dns"
  region = "ap-east-1"

  allowed_account_ids = ["999999999999"]

  assume_role {
    role_arn     = "arn:aws:iam::999999999999:role/terraform-shared-dns"
    session_name = "terraform-prod-order"
  }
}
module "service_dns" {
  source = "./modules/route53-record"

  providers = {
    aws = aws.shared_dns
  }
}
rule:
    provider aliases should still be part of one coherent deployment boundary
    if a plan can change dev and prod together, the root module boundary is wrong
    split different environments into different root modules and different backend keys

verify account before plan#

aws sts get-caller-identity
aws configure get region

terraform init
terraform plan -var-file=envs/prod.tfvars
failure pattern:
    terraform uses credentials for account 111111111111
    provider allowed_account_ids only contains 123456789012
    plan/apply fails before managing resources

    terraform uses aws_region = eu-west-1
    variable validation only allows ap-east-1 / ap-southeast-1
    validate/plan fails before provider manages resources

related option:
    forbidden_account_ids is the inverse guardrail
    allowed_account_ids conflicts with forbidden_account_ids
    prefer allowed_account_ids for production root modules
    there is no matching allowed_regions option in the AWS provider
production rule:
    every AWS root module should set allowed_account_ids
    every AWS root module should validate aws_region
    each env tfvars should include expected aws_account_id
    each env tfvars should include expected aws_region
    CI should pass the same env tfvars used for the reviewed plan
    do not set skip_requesting_account_id in normal AWS accounts
    do not set skip_region_validation in normal AWS regions

3. State Bucket Strategy#

Terraform state bucket 分配先按 AWS account 边界设计,再按管理复杂度决定是否按 region 拆 bucket。不要一开始就把所有账号、所有环境、所有 region 都塞进一个 bucket,除非平台团队明确需要 central state bucket。

recommended:
    one state bucket per AWS account
    bucket lives in one fixed home region
    state key carries env / env_variant / workload region / component

example buckets:
    acme-tfstate-111111111111-ap-east-1   # dev account
    acme-tfstate-222222222222-ap-east-1   # uat account
    acme-tfstate-333333333333-ap-east-1   # prod account
why:
    account boundary is clear
    IAM and bucket policy are simpler
    blast radius is smaller than one central bucket
    bucket count stays manageable
    workload resources can still be multi-region because region is in the key

common bucket models#

Model When To Use Pros Cons
Per account, fixed home region default for most teams clear account boundary, simple IAM, manageable bucket count state for all workload regions is stored in home region
Per account, per region strict region isolation or regulatory requirement strong regional separation more buckets, more backend configs, harder bootstrap
Central state bucket across accounts mature platform team owns all Terraform state centrally centralized audit and backup complex cross-account policy, larger blast radius
default decision:
    use per account + fixed home region
    put account ID and bucket region in bucket name
    put environment, environment variant, workload region, and component in state key

bucket naming#

Detailed bucket naming rules are covered in Terraform AWS S3 Bucket.

S3 bucket name must be globally unique in the shared global namespace. Add organization, AWS account ID, and state bucket region to avoid collision.

recommended pattern:
    <company>-aws-terraform-<account_alias>-<account_id>-tfstate-<state_bucket_region>

example:
    acme-aws-terraform-prod-123456789012-tfstate-ap-east-1
bucket rules:
    use lowercase letters, numbers, and hyphen
    include AWS account ID
    include state bucket region
    do not include secret/project codename
    do not include environment if one bucket stores many env/variant keys
    do not include workload region if state bucket region is fixed

4. State Key Strategy#

State key is the object path inside the S3 bucket.

principle:
    bucket boundary first
    key pattern second
    do not repeat account/env/region in key if bucket already provides that boundary
    include workload_region in key when one bucket stores states for multiple workload regions

key fields#

Use only the fields needed by the bucket model.

account:
    account alias or account ID

env_group:
    dev / uat / prod

env_variant:
    default / market / hotfix / perf

workload_region:
    ap-east-1 / ap-southeast-1 / global

system:
    order / payment / platform

component:
    network / ecs / rds / iam / route53

model 1: one shared bucket for many accounts/environments#

Use this only when a platform team centrally owns state storage and bucket policy.

bucket:
    acme-tfstate-central-999999999999-ap-east-1

key pattern:
    <account_alias>/<env_group>/<env_variant>/<workload_region>/<system>/<component>/terraform.tfstate

examples:
    dev-app/dev/default/ap-east-1/order/network/terraform.tfstate
    uat-app/uat/market/ap-east-1/order/ecs/terraform.tfstate
    prod-app/prod/default/ap-southeast-1/order/ecs/terraform.tfstate
key must include:
    account
    env_group
    env_variant
    workload_region

because bucket does not provide those boundaries

model 2: each account has its own state bucket#

This is the normal default for many teams.

bucket:
    acme-tfstate-111111111111-ap-east-1     # dev account
    acme-tfstate-222222222222-ap-east-1     # uat account
    acme-tfstate-333333333333-ap-east-1     # prod account

key pattern:
    <system>/<env_group>/<env_variant>/<workload_region>/<component>/terraform.tfstate

examples:
    order/dev/default/ap-east-1/network/terraform.tfstate
    order/uat/default/ap-east-1/network/terraform.tfstate
    order/uat/market/ap-east-1/network/terraform.tfstate
    order/uat/hotfix/ap-east-1/network/terraform.tfstate
    order/prod/default/ap-east-1/ecs/terraform.tfstate
key does not need account:
    account boundary is already in bucket name and AWS account policy

keep env_group:
    useful when one account contains multiple env groups or variants
    can be omitted only if the account is strictly one environment

model 3: one account, one state bucket, many workload regions#

Use this when one account deploys resources to multiple AWS regions, but the state bucket is centralized in one home region.

bucket:
    acme-tfstate-333333333333-ap-east-1

bucket region:
    ap-east-1

workload regions:
    ap-east-1
    ap-southeast-1
    global

key pattern:
    <system>/<env_group>/<env_variant>/<workload_region>/<component>/terraform.tfstate

examples:
    order/prod/default/ap-east-1/network/terraform.tfstate
    order/prod/default/ap-southeast-1/ecs/terraform.tfstate
    platform/prod/default/global/iam/terraform.tfstate
important:
    backend region = S3 bucket region
    workload_region in key = region where resources are deployed
    these two can be different

model 4: each account and each workload region has its own state bucket#

Use only when there is a strict regional isolation or compliance requirement.

bucket:
    acme-tfstate-333333333333-ap-east-1
    acme-tfstate-333333333333-ap-southeast-1

key pattern:
    <system>/<env_group>/<env_variant>/<component>/terraform.tfstate

examples:
    order/prod/default/network/terraform.tfstate
    order/prod/default/ecs/terraform.tfstate
key can omit workload_region:
    bucket already represents account + state bucket region
    use only if workload resources in that state are in the same region as the bucket

key rules#

rules:
    one key = one Terraform root module state
    split by ownership boundary and blast radius
    keep workload_region in key when bucket stores multiple workload regions
    use global as workload_region for global resources
    keep env_variant if variants such as uat-market / uat-hotfix exist
    include account only when one bucket stores multiple accounts
    do not put every AWS resource into one giant state file
Scenario State Bucket State Key
Shared central bucket acme-tfstate-central-999999999999-ap-east-1 prod-app/prod/default/ap-east-1/order/network/terraform.tfstate
Per account bucket acme-tfstate-333333333333-ap-east-1 order/prod/default/ap-east-1/network/terraform.tfstate
Per account bucket, multi-region workload acme-tfstate-333333333333-ap-east-1 order/prod/default/ap-southeast-1/ecs/terraform.tfstate
Per account per region bucket acme-tfstate-333333333333-ap-southeast-1 order/prod/default/ecs/terraform.tfstate
Global resource acme-tfstate-333333333333-ap-east-1 platform/prod/default/global/iam/terraform.tfstate

5. backend.tf#

Use this when the backend config is fixed in the root module.

terraform {
  backend "s3" {
    bucket       = "acme-tfstate-123456789012-ap-east-1"
    key          = "order/prod/default/ap-east-1/network/terraform.tfstate"
    region       = "ap-east-1"
    encrypt      = true
    use_lockfile = true
  }
}
fields:
    bucket:
        S3 bucket that stores state
        enable versioning on this bucket

    key:
        object path for this root module state

    region:
        S3 bucket region

    encrypt:
        asks S3 backend to use server-side encryption

    use_lockfile:
        enables S3-native locking with a .tflock object

State bucket baseline:

required:
    versioning enabled
    server-side encryption enabled
    public access blocked
    access limited to Terraform roles/admins
    lifecycle policy reviewed, but do not expire current state
    use_lockfile = true for S3-native state locking

avoid:
    unversioned state bucket

6. Manual State Bucket Bootstrap#

Use this when the Terraform state bucket is created manually or by a one-time bootstrap script before the normal Terraform backend is available.

manual bootstrap order:
    create bucket
    enable versioning
    enable default encryption
    block public access
    enforce bucket owner object ownership
    attach bucket policy
    configure backend.hcl
    run terraform init -backend-config=backend.hcl

create bucket#

export AWS_REGION=ap-east-1
export STATE_BUCKET=acme-tfstate-123456789012-ap-east-1

aws s3api create-bucket \
  --bucket "$STATE_BUCKET" \
  --region "$AWS_REGION" \
  --create-bucket-configuration LocationConstraint="$AWS_REGION"
note:
    us-east-1 is special and does not use LocationConstraint
    for ap-east-1 / ap-southeast-1 / other regions, include LocationConstraint

enable versioning#

aws s3api put-bucket-versioning \
  --bucket "$STATE_BUCKET" \
  --versioning-configuration Status=Enabled

Versioning is important because Terraform state overwrite/delete mistakes need an object version to recover from.

enable encryption#

SSE-S3 baseline:

aws s3api put-bucket-encryption \
  --bucket "$STATE_BUCKET" \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "AES256"
        }
      }
    ]
  }'
SSE-KMS:
    use customer managed KMS key when central audit/key control is required
    Terraform roles then also need kms:Decrypt and kms:GenerateDataKey

block public access#

aws s3api put-public-access-block \
  --bucket "$STATE_BUCKET" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

object ownership#

aws s3api put-bucket-ownership-controls \
  --bucket "$STATE_BUCKET" \
  --ownership-controls '{
    "Rules": [
      {
        "ObjectOwnership": "BucketOwnerEnforced"
      }
    ]
  }'
why:
    disables ACL-based ownership complexity
    bucket owner owns state and lockfile objects
    simpler for centralized state bucket operations

bucket policy#

Bucket policy examples are covered in Terraform AWS S3 Bucket.

verify#

aws s3api get-bucket-versioning \
  --bucket "$STATE_BUCKET"

aws s3api get-bucket-encryption \
  --bucket "$STATE_BUCKET"

aws s3api get-public-access-block \
  --bucket "$STATE_BUCKET"

aws s3api get-bucket-policy \
  --bucket "$STATE_BUCKET"

Then initialize Terraform:

terraform init -backend-config=backend.hcl

7. backend.hcl#

Use backend.hcl when bucket/key/region differ by environment or repository.

bucket       = "acme-tfstate-123456789012-ap-east-1"
key          = "order/prod/default/ap-east-1/network/terraform.tfstate"
region       = "ap-east-1"
encrypt      = true
use_lockfile = true

Initialize with:

terraform init -backend-config=backend.hcl
why backend.hcl:
    backend block does not support normal Terraform variables
    backend.hcl keeps env-specific backend values outside reusable module code
    do not put AWS credentials in backend.hcl

8. Backend Examples By Bucket Model#

per account bucket: dev#

bucket       = "acme-tfstate-111111111111-ap-east-1"
key          = "order/dev/default/ap-east-1/network/terraform.tfstate"
region       = "ap-east-1"
encrypt      = true
use_lockfile = true

per account bucket: uat market#

bucket       = "acme-tfstate-222222222222-ap-east-1"
key          = "order/uat/market/ap-east-1/ecs/terraform.tfstate"
region       = "ap-east-1"
encrypt      = true
use_lockfile = true

per account bucket: prod ap-east-1 workload#

bucket       = "acme-tfstate-333333333333-ap-east-1"
key          = "order/prod/default/ap-east-1/ecs/terraform.tfstate"
region       = "ap-east-1"
encrypt      = true
use_lockfile = true

per account bucket: prod ap-southeast-1 workload#

bucket       = "acme-tfstate-333333333333-ap-east-1"
key          = "order/prod/default/ap-southeast-1/ecs/terraform.tfstate"
region       = "ap-east-1"
encrypt      = true
use_lockfile = true
note:
    region above is the S3 state bucket region
    ap-southeast-1 in key is the workload region

shared central bucket#

bucket       = "acme-tfstate-central-999999999999-ap-east-1"
key          = "prod-app/prod/default/ap-east-1/order/network/terraform.tfstate"
region       = "ap-east-1"
encrypt      = true
use_lockfile = true

per account per region bucket#

bucket       = "acme-tfstate-333333333333-ap-southeast-1"
key          = "order/prod/default/ecs/terraform.tfstate"
region       = "ap-southeast-1"
encrypt      = true
use_lockfile = true

9. use_lockfile#

terraform plan/apply starts:
    create lock object:
        order/prod/default/ap-east-1/network/terraform.tfstate.tflock

another terraform process starts:
    sees the lock object
    fails/waits instead of writing state at the same time

terraform plan/apply finishes:
    deletes the .tflock object
notes:
    use_lockfile is the Terraform backend option
    user_lockfile is not a Terraform backend option
    lockfile is stored in the same S3 bucket as state
    S3 Object Lock is not required for use_lockfile
    do not apply Object Lock retention/legal hold to .tflock objects
    lockfile name is derived from the state key:
        <key>.tflock

    example:
        key:
            order/prod/default/ap-east-1/network/terraform.tfstate

        lockfile:
            order/prod/default/ap-east-1/network/terraform.tfstate.tflock

    this is Terraform S3 backend behavior, not a project-specific convention

10. Migration From Local State#

If a project already has local terraform.tfstate, migrate it after the S3 backend exists.

terraform init -backend-config=backend.hcl -migrate-state

Verify:

terraform state list

aws s3api list-object-versions \
  --bucket acme-tfstate-123456789012-ap-east-1 \
  --prefix order/prod/default/ap-east-1/network/
rollback:
    keep a local backup of terraform.tfstate before migration
    do not delete the local backup until remote state is verified
    if migration fails, fix backend config and rerun terraform init -migrate-state