What You'll Build
You will bootstrap a remote Terraform backend using a dedicated bootstrap configuration, then migrate the CricketPulse infrastructure from local state to the remote S3 backend. You will verify that state locking works by simulating a concurrent apply scenario, use `terraform state` commands to inspect and manipulate state, and practice the production plan-file workflow. By the end of this exercise, the CricketPulse infrastructure is managed with a production-grade state configuration that supports team collaboration.
Prerequisites
- Completed Lesson 4 exercise — the CricketPulse EC2 infrastructure with local state.
- AWS account with permissions to create S3 buckets and DynamoDB tables.
- Terraform CLI >= 1.7.0 installed.
- AWS CLI installed for verifying S3 bucket contents — `brew install awscli` or download from aws.amazon.com/cli.
- Understanding of S3 bucket versioning and DynamoDB from Lessons 5–7.
Setup & Project Structure
Create a dedicated bootstrap configuration in a separate directory for the state backend infrastructure. This separation is essential — the bootstrap config manages the S3 bucket and DynamoDB table; the main config uses them.
# Project structure
mkdir -p cricketpulse-infra/bootstrap
mkdir -p cricketpulse-infra/infrastructure
# The bootstrap config creates the state backend
# The infrastructure config uses the created backend
tree cricketpulse-infra
# cricketpulse-infra/
# bootstrap/
# main.tf # Creates S3 bucket + DynamoDB table
# providers.tf # Local state (bootstrap uses local state!)
# outputs.tf
# infrastructure/
# providers.tf # Uses the remote backend created by bootstrap
# main.tf # CricketPulse EC2 + security group
# outputs.tf
cd cricketpulse-infraStep 1 — Foundation
Write and apply the bootstrap configuration that creates the S3 bucket (with versioning and encryption) and DynamoDB table for state locking. The bootstrap configuration itself uses local state — a necessary compromise for the initial setup.
# bootstrap/providers.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.31" }
}
# No backend block — bootstrap uses LOCAL state intentionally
}
provider "aws" {
region = "ap-south-1"
}
# bootstrap/main.tf
resource "aws_s3_bucket" "tf_state" {
bucket = "cricketpulse-tf-state-${data.aws_caller_identity.current.account_id}"
force_destroy = false # Prevent accidental deletion
tags = { Name = "Terraform State", ManagedBy = "terraform-bootstrap" }
}
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket_versioning" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
rule {
apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
}
}
resource "aws_s3_bucket_public_access_block" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "tf_locks" {
name = "cricketpulse-tf-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute { name = "LockID"; type = "S" }
tags = { Name = "Terraform State Locks" }
}
# bootstrap/outputs.tf
output "state_bucket" { value = aws_s3_bucket.tf_state.id }
output "lock_table" { value = aws_dynamodb_table.tf_locks.name }Step 2 — Core Logic
Apply the bootstrap configuration and then configure the infrastructure config to use the remote backend. Migrate existing local state to the remote backend using `terraform init -migrate-state`.
# Apply bootstrap (creates S3 bucket + DynamoDB table)
cd bootstrap
terraform init && terraform apply -auto-approve
# Note the outputs
STATE_BUCKET=$(terraform output -raw state_bucket)
echo "State bucket: $STATE_BUCKET"
cd ../infrastructure
# infrastructure/providers.tf (with remote backend)
cat > providers.tf << EOF
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.31" }
}
backend "s3" {
bucket = "$STATE_BUCKET"
key = "cricketpulse/dev/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "cricketpulse-tf-locks"
encrypt = true
}
}
provider "aws" {
region = "ap-south-1"
default_tags {
tags = { ManagedBy = "terraform", Project = "cricketpulse" }
}
}
EOF
# If you have existing local state, migrate it:
terraform init -migrate-state
# Initializing the backend...
# Do you want to copy existing state to the new backend? yes
# Successfully configured the backend 'S3'!
# State migrated to remote backend.
# Verify state is in S3
aws s3 ls s3://$STATE_BUCKET/cricketpulse/dev/
# terraform.tfstate (should appear in S3)Step 3 — Integration & Enhancement
Verify state locking by opening two terminals and running `terraform apply` simultaneously. Practice the production plan-file workflow with the remote backend.
# Simulate state locking (two terminals)
# Terminal 1: Start a slow apply
terraform apply &
# Acquiring state lock... Lock acquired!
# aws_instance.cricketpulse_api: Modifying...
# Terminal 2: Try to apply simultaneously
terraform apply
# Error: Error acquiring the state lock
# Error message: ConditionalCheckFailedException
# Lock Info:
# ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Path: cricketpulse-tf-state.../cricketpulse/dev/terraform.tfstate
# Operation: OperationTypeApply
# Who: user@hostname
# Created: 2024-01-15 10:30:00
# ← Second apply correctly blocked
# Production plan-file workflow
# Step 1: Generate plan file
terraform plan -out=cricketpulse-$(date +%Y%m%d).plan
# Step 2: Review the saved plan
terraform show cricketpulse-$(date +%Y%m%d).plan | head -20
# Step 3: Apply the saved plan (guarantees exact reviewed actions)
terraform apply cricketpulse-$(date +%Y%m%d).plan
# Verify state in S3 (should show new version)
aws s3api list-object-versions \
--bucket $STATE_BUCKET \
--prefix cricketpulse/dev/terraform.tfstate \
--query 'Versions[].{ID:VersionId,Date:LastModified}' \
--output tableStep 4 — Testing & Verification
Inspect state commands and clean up the exercise.
# State inspection commands
terraform state list
terraform state show aws_instance.cricketpulse_api
terraform state pull | python3 -m json.tool | head -30
# Verify drift detection
terraform plan -refresh-only
# Should show no changes if infrastructure matches state
# Clean up infrastructure
terraform destroy -auto-approve
# Clean up bootstrap (careful: this deletes the state bucket)
# Only do this when you're completely done
cd ../bootstrap
# aws s3 rm s3://$STATE_BUCKET --recursive # Empty bucket first
# terraform destroy -auto-approve
echo 'State exercise complete!'Warning: When using the S3 remote backend, the state bucket name must be globally unique across all AWS accounts. Using your AWS account ID as a suffix (as shown in the bootstrap configuration) ensures uniqueness: `cricketpulse-tf-state-123456789012`. If you choose a bucket name that already exists in any other account, the S3 bucket creation will fail with a 'BucketAlreadyExists' error. Do NOT delete the bootstrap state (the local terraform.tfstate in the bootstrap directory) — you'll need it to manage the state bucket and DynamoDB table later.
Extension Challenge: Configure S3 bucket lifecycle rules on the state bucket to transition old state versions to S3 Glacier after 30 days and expire them after 90 days. This reduces storage costs while maintaining recovery capability. Also add an S3 bucket notification that sends an SNS alert whenever the state file is modified — this creates an audit trail of when Terraform apply operations occurred. Configure the SNS topic to send an email to your team's infrastructure alerts distribution list.
- The bootstrap pattern creates the state backend infrastructure (S3+DynamoDB) using local state, then all subsequent configurations use the remote backend.
- `terraform init -migrate-state` safely moves existing local state to the newly configured remote backend — run it after adding the backend block to providers.tf.
- State locking verification is possible by running two simultaneous applies — the second should fail with 'Error acquiring state lock' immediately.
- The production plan-file workflow (`plan -out=file`, then `apply file`) guarantees that exactly the reviewed plan is applied, even if infrastructure changed between plan and apply.
- Enable S3 bucket versioning on the state bucket to create automatic backups of every state version — essential for recovery from state corruption.
- The S3 state bucket must be globally unique — use your AWS account ID as a suffix to guarantee uniqueness across all accounts.