Layer 1 - bootstrap: Creating the State Backend

Goal: create the secure S3 bucket (and KMS key) that will hold the remote state for one AWS account. This is the first thing you run in any account, and it is the only layer that uses local state.

Read along in bootstrap/main.tf. It creates, in order:

  1. A KMS key + alias to encrypt the state.

    HCL
    resource "aws_kms_key" "state" {
      description             = "${var.project}-${var.environment} OpenTofu state encryption"
      deletion_window_in_days = 14
      enable_key_rotation     = true     # automatically rotate the key
    }
  2. The S3 bucket itself, plus several hardening resources applied to it. Notice that AWS S3 security is configured as separate resources that each point back at the bucket - this is normal in the AWS provider:

    • aws_s3_bucket_versioning - keep old versions of the state, so you can recover if it's corrupted.
    • aws_s3_bucket_server_side_encryption_configuration - encrypt objects with the KMS key from step 1.
    • aws_s3_bucket_public_access_block - make it impossible to accidentally make this bucket public (all four switches on).
    • aws_s3_bucket_lifecycle_configuration - automatically delete state versions older than 90 days so the bucket doesn't grow forever.
    • aws_s3_bucket_policy - a bucket policy that denies any non-TLS (unencrypted in transit) access.
  3. The TLS-only policy is built with a data "aws_iam_policy_document" block. This is the idiomatic way to write IAM/bucket policies in HCL instead of raw JSON - you describe the policy in HCL and the data source renders the JSON:

    HCL
    data "aws_iam_policy_document" "state_bucket" {
      statement {
        sid    = "DenyInsecureTransport"
        effect = "Deny"
        principals { type = "*"  identifiers = ["*"] }
        actions   = ["s3:*"]
        resources = [aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*"]
        condition {
          test     = "Bool"
          variable = "aws:SecureTransport"
          values   = ["false"]   # i.e. deny when the request is NOT over TLS
        }
      }
    }

The outputs (bootstrap/outputs.tf) then expose the bucket name, the region, and crucially the KMS key ARN - the value every other layer's backend needs.

The one-workspace-per-account trick

A workspace in OpenTofu is a way to keep multiple separate states from the same code. Bootstrap uses one workspace per account (management/dev/staging/prod), so a single bootstrap/ directory remembers the backend it created in every account. The Makefile does this for you:

MAKE
bootstrap-apply: bootstrap-init
	$(TOFU) -chdir=bootstrap workspace select -or-create $(ENV)
	$(TOFU) -chdir=bootstrap apply -input=false -var-file=$(ENV).tfvars

So make bootstrap-apply ENV=dev selects (or creates) the dev workspace and applies with dev.tfvars. Run it once per account, switching your AWS credentials to the matching account each time.

-var-file=dev.tfvars supplies the input variables from a file. The .tfvars file holds the real account ID and a globally-unique bucket name. Real *.tfvars files are git-ignored (they hold account IDs); only *.example copies are committed.

Adesh Tamrakar
SOFTWARE ENGINEER · VAULT

Notes, insights and random discoveries from a working engineer's vault - written for future me, published for you.