LAB25_AWS_S3 Remote State with DynamoDB Locking and Workspaces

S3 Remote State with DynamoDB Locking and Workspaces

☁ AWS ⭐ Beginner Remote State S3 Backend DynamoDB Locking Workspaces terraform.tfstate

Scenario

Your team has been running Terraform on individual laptops with local state files. Two engineers ran terraform apply simultaneously last week and corrupted the state. You need to move to remote state — stored in S3 so it's shared — with DynamoDB locking to prevent concurrent applies. You also want separate state files per environment using Terraform workspaces.

Your Objectives
  • Bootstrap: Create the S3 bucket and DynamoDB table needed for the backend using a local-state config first.
  • Migrate: Configure the terraform { backend "s3" { } } block and run terraform init to migrate state.
  • Use terraform.workspace to make resource names workspace-aware.
  • Create dev and prod workspaces and observe separate state files in S3.
  • Output the current workspace name and the state file S3 key.

Additional Context

The S3 backend state file path follows this pattern when using workspaces: env:/WORKSPACE_NAME/STATE_KEY. For example, with key = "networking/terraform.tfstate" and workspace prod, the actual state file is stored at env:/prod/networking/terraform.tfstate. The default workspace is the exception — it uses the key directly without the env:/default/ prefix.

The DynamoDB table for locking requires a single attribute LockID (string) as the partition key. Terraform writes a lock record to this table each time an operation that modifies state begins, and removes it when finished.

Step 1: bootstrap/main.tf (local state — run FIRST)

# bootstrap/main.tf
# Run this ONCE with local state to create the backend infrastructure.
# Then configure the S3 backend in your main config.

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  # NO backend block here — use local state for bootstrap
}

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

data "aws_caller_identity" "current" {}

# S3 bucket for Terraform state — must be globally unique.
resource "aws_s3_bucket" "tfstate" {
  bucket = "terraform-state-${data.aws_caller_identity.current.account_id}-us-east-1"
  tags   = { Purpose = "terraform-state", ManagedBy = "terraform-bootstrap" }
}

resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket                  = aws_s3_bucket.tfstate.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
  }
}

# DynamoDB table for state locking.
# IMPORTANT: the hash key MUST be named "LockID" (case-sensitive).
resource "aws_dynamodb_table" "tflock" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }

  tags = { Purpose = "terraform-state-lock", ManagedBy = "terraform-bootstrap" }
}

output "state_bucket_name" { value = aws_s3_bucket.tfstate.id }
output "lock_table_name"   { value = aws_dynamodb_table.tflock.name }

Step 2: versions.tf (main config — configure after bootstrap)

# versions.tf — for your actual infrastructure config
# After running the bootstrap, add this backend block and run "terraform init".

terraform {
  required_version = ">= 1.5.0"

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

  # S3 backend with DynamoDB locking.
  # Replace the values with your actual bucket and table names.
  backend "s3" {
    bucket         = "terraform-state-ACCOUNT_ID-us-east-1"  # from bootstrap output
    key            = "networking/terraform.tfstate"           # path within the bucket
    region         = "us-east-1"
    encrypt        = true                                      # encrypt state at rest
    dynamodb_table = "terraform-state-lock"                   # from bootstrap output
  }
}

main.tf (workspace-aware resources)

# main.tf — uses terraform.workspace to create workspace-scoped resources

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

locals {
  # terraform.workspace is "default", "dev", "prod", etc.
  # Use it to differentiate resources across workspaces.
  env = terraform.workspace == "default" ? "dev" : terraform.workspace
}

# Example: an S3 bucket scoped to the current workspace.
resource "aws_s3_bucket" "app_data" {
  bucket = "app-data-${local.env}-${data.aws_caller_identity.current.account_id}"
  tags   = { Environment = local.env, ManagedBy = "terraform" }
}

data "aws_caller_identity" "current" {}

outputs.tf

output "current_workspace" { value = terraform.workspace }
output "environment"       { value = local.env }
output "bucket_name"       { value = aws_s3_bucket.app_data.id }

Workflow Commands

# ---- STEP 1: Bootstrap the backend ----
cd bootstrap
terraform init
terraform apply
cd ..

# ---- STEP 2: Configure your main config ----
# Edit versions.tf: add the backend "s3" block with values from bootstrap outputs.

# ---- STEP 3: Initialise and migrate state ----
terraform init
# Terraform asks: "Do you want to migrate your existing state to the new backend?"
# Type 'yes'

# ---- STEP 4: Use workspaces ----
terraform workspace new dev      # create dev workspace
terraform workspace new prod     # create prod workspace
terraform workspace select dev   # switch to dev
terraform workspace list         # list all workspaces

# Apply in dev workspace
terraform apply
# State stored at: s3://...bucket.../env:/dev/networking/terraform.tfstate

# Switch to prod and apply
terraform workspace select prod
terraform apply
# State stored at: s3://...bucket.../env:/prod/networking/terraform.tfstate

# View current workspace in output
terraform output current_workspace  # "prod"
✓ Expected Output After Apply (in dev workspace)
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

bucket_name       = "app-data-dev-123456789012"
current_workspace = "dev"
environment       = "dev"