What You'll Build
This lab walks you through one of the most common real-world Terraform operations: migrating an existing Terraform configuration from local state (terraform.tfstate on disk) to a remote S3 backend with DynamoDB locking. You will start with the EC2 configuration from M1 Lesson 7 (or a simple configuration you write), verify it has local state with resources deployed, bootstrap the S3 + DynamoDB backend infrastructure, reconfigure the Terraform backend in the configuration, run 'terraform init -migrate-state', verify the migration was successful, and simulate a state lock to understand the locking mechanism. This migration scenario is one that every Terraform engineer encounters when a solo project grows into a team project or when a project is adopted by a platform team that requires standardised backend configuration.
Prerequisites
- AWS CLI configured with permissions for S3, DynamoDB and EC2
- Terraform 1.6+ installed
- An existing Terraform configuration with local state (complete the M1 L7 lab first, or use the simple configuration in Step 0 below)
- The existing local state should have at least one resource deployed so the migration has something to transfer
Step 0 — Create a Starting Configuration with Local State
If you completed the M1 Lab 7 exercise, use that configuration. Otherwise, create a minimal starting configuration that provisions a single S3 bucket with local state — the resource itself is unimportant; what matters is that Terraform has created a local terraform.tfstate file with a tracked resource.
#!/bin/bash
# Step 0: Create a simple configuration with local state
mkdir -p ~/state_migration_lab && cd ~/state_migration_lab
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
cat > main.tf << 'HCL'
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
# NOTE: No backend block — uses local state by default
}
provider "aws" { region = "ap-south-1" }
# Simple resource to track in state
resource "aws_s3_bucket" "cricket_lab" {
bucket = "cricket-state-migration-lab-${data.aws_caller_identity.current.account_id}"
tags = { Purpose = "state-migration-lab", ManagedBy = "terraform" }
}
data "aws_caller_identity" "current" {}
output "bucket_name" {
value = aws_s3_bucket.cricket_lab.bucket
}
HCL
echo '=== Apply with local state ==='
terraform init
terraform apply -auto-approve
echo
echo '=== Verify local state exists ==='
ls -la terraform.tfstate
cat terraform.tfstate | python3 -m json.tool | grep -E 'lineage|serial|bucket'
echo
echo 'Local state confirmed — ready for migration'Step 1 — Bootstrap the Remote Backend Infrastructure
Before migrating, the S3 bucket and DynamoDB table that will host the remote state must exist. This bootstrapping step uses the AWS CLI rather than Terraform to avoid the chicken-and-egg problem. All security settings (versioning, encryption, public access block) are applied during bootstrap so the state file is secure from the first write.
#!/bin/bash
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION='ap-south-1'
STATE_BUCKET="cricket-terraform-state-${ACCOUNT_ID}"
LOCK_TABLE='cricket-terraform-locks'
echo '=== Bootstrap: S3 state bucket ==='
# Create bucket
aws s3api create-bucket \
--bucket "$STATE_BUCKET" \
--region "$REGION" \
--create-bucket-configuration LocationConstraint="$REGION" 2>/dev/null || \
echo 'Bucket already exists'
# Block all public access
aws s3api put-public-access-block \
--bucket "$STATE_BUCKET" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
# Enable versioning — critical for state recovery
aws s3api put-bucket-versioning \
--bucket "$STATE_BUCKET" \
--versioning-configuration Status=Enabled
# Enable encryption
aws s3api put-bucket-encryption \
--bucket "$STATE_BUCKET" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
echo "S3 bucket: s3://${STATE_BUCKET}"
echo
echo '=== Bootstrap: DynamoDB lock table ==='
aws dynamodb create-table \
--table-name "$LOCK_TABLE" \
--billing-mode PAY_PER_REQUEST \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--region "$REGION" 2>/dev/null || echo 'Table already exists'
# Wait for table to be active
aws dynamodb wait table-exists --table-name "$LOCK_TABLE" --region "$REGION"
echo "DynamoDB table: ${LOCK_TABLE} — ACTIVE"
echo
echo "Bootstrap complete. State bucket: ${STATE_BUCKET}"
export STATE_BUCKET LOCK_TABLEStep 2 — Add Backend Configuration and Migrate
Update the Terraform configuration to declare the S3 backend, then run 'terraform init -migrate-state'. Terraform detects the existing local state file, prompts to copy it to the new backend, and verifies the migration. The existing resources remain unchanged in AWS — only the state's storage location changes.
#!/bin/bash
cd ~/state_migration_lab
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
STATE_BUCKET="cricket-terraform-state-${ACCOUNT_ID}"
echo '=== Step 2a: Back up local state before migration ==='
cp terraform.tfstate "terraform.tfstate.backup.$(date +%Y%m%d_%H%M%S)"
echo 'Local state backed up'
echo
echo '=== Step 2b: Add S3 backend to versions.tf ==='
# Update main.tf to include the backend block
cat > main.tf << HCLDOC
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
# Remote backend — replacing local state
backend "s3" {
bucket = "${STATE_BUCKET}"
key = "cricket-analytics/lab/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "cricket-terraform-locks"
encrypt = true
}
}
provider "aws" { region = "ap-south-1" }
resource "aws_s3_bucket" "cricket_lab" {
bucket = "cricket-state-migration-lab-\${data.aws_caller_identity.current.account_id}"
tags = { Purpose = "state-migration-lab", ManagedBy = "terraform" }
}
data "aws_caller_identity" "current" {}
output "bucket_name" {
value = aws_s3_bucket.cricket_lab.bucket
}
HCLDOC
echo
echo '=== Step 2c: Migrate state — the key command ==='
# -migrate-state: Terraform detects existing local state and offers to copy it
terraform init -migrate-state
# Terraform will prompt:
# 'Do you want to copy existing state to the new backend?'
# Type: yes
echo
echo '=== Step 2d: Verify migration success ==='
# 1. Remote state should show the same resources as the local state did
terraform state list
# 2. Plan should show NO changes (resources are unchanged in AWS)
terraform plan
# Expected: No changes. Infrastructure is up-to-date.
# 3. Verify state file exists in S3
aws s3 ls "s3://${STATE_BUCKET}/cricket-analytics/lab/"
# 4. Verify state content matches
terraform state pull | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Resources: {len(d[\"resources\"])}, Serial: {d[\"serial\"]}')"
echo 'Migration verified — state is now in S3'Step 3 — Simulate State Locking
Understand DynamoDB locking by examining what happens when a lock is held. This simulation shows the lock record structure and demonstrates the error a concurrent apply would receive. This understanding is critical for on-call scenarios where a lock must be force-released after a crashed Terraform process.
#!/bin/bash
cd ~/state_migration_lab
echo '=== Step 3: Understand state locking ==='
# Start an apply in the background (it will complete quickly since no changes are needed)
# We examine the DynamoDB lock while it runs
(terraform apply -auto-approve &)
AP_PID=$!
# Immediately check for lock record in DynamoDB
sleep 2
LOCK_TABLE='cricket-terraform-locks'
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo 'Checking DynamoDB for lock record:'
aws dynamodb scan \
--table-name "$LOCK_TABLE" \
--query 'Items' \
--output json | python3 -m json.tool
# Wait for the apply to complete
wait $AP_PID 2>/dev/null || true
# After apply completes, lock should be released (no items in table)
sleep 2
echo
echo 'After apply completes — lock should be released:'
LOCK_COUNT=$(aws dynamodb scan --table-name "$LOCK_TABLE" --query 'Count' --output text)
echo "Active locks: ${LOCK_COUNT}"
echo
echo '=== Simulate a stuck lock ==='
# Manually write a fake lock to DynamoDB
LOCK_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)
STATE_KEY="cricket-analytics/lab/terraform.tfstate"
aws dynamodb put-item \
--table-name "$LOCK_TABLE" \
--item "{
\"LockID\": {\"S\": \"${STATE_BUCKET}/${STATE_KEY}\"},
\"Info\": {\"S\": \"{\\\"ID\\\":\\\"${LOCK_ID}\\\",\\\"Operation\\\":\\\"OperationTypeApply\\\",\\\"Who\\\":\\\"nagarajarao@cricket-laptop\\\",\\\"Created\\\":\\\"2024-04-15T10:00:00.000Z\\\"}\"}
}"
echo 'Fake lock written. Attempting terraform plan:'
terraform plan 2>&1 | grep -A10 'Error.*lock\|lock.*Error' | head -15
echo
echo '=== Force-unlock (only safe when original process is confirmed dead) ==='
terraform force-unlock -force "$LOCK_ID"
echo 'Lock released. Terraform plan should now succeed:'
terraform plan 2>&1 | tail -3Step 4 — State Versioning Recovery Test
#!/bin/bash
cd ~/state_migration_lab
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
STATE_BUCKET="cricket-terraform-state-${ACCOUNT_ID}"
STATE_KEY='cricket-analytics/lab/terraform.tfstate'
echo '=== Step 4: Verify S3 versioning enables recovery ==='
# Show all state versions in S3
echo 'S3 state file versions:'
aws s3api list-object-versions \
--bucket "$STATE_BUCKET" \
--prefix "$STATE_KEY" \
--query 'Versions[*].{Version:VersionId, Modified:LastModified, Size:Size}' \
--output table
# Download the most recent version
terraform state pull > /tmp/current_state.json
python3 -c "import json; d=json.load(open('/tmp/current_state.json')); print(f'Current serial: {d[\"serial\"]} Resources: {len(d[\"resources\"])}')"
# Simulate corruption by modifying the state serial
python3 -c "
import json
with open('/tmp/current_state.json') as f:
d = json.load(f)
print(f'Original serial: {d[\"serial\"]} Lineage: {d[\"lineage\"]}')
"
# Recovery procedure:
echo
echo 'Recovery procedure (if state is corrupted):'
echo ' 1. Identify the last-known-good version ID from S3:'
echo ' aws s3api list-object-versions --bucket BUCKET --prefix KEY'
echo ' 2. Download the version:'
echo ' aws s3api get-object --bucket BUCKET --key KEY --version-id VERSION_ID recovered_state.json'
echo ' 3. Restore using state push:'
echo ' terraform state push recovered_state.json'
echo ' 4. Verify with terraform plan (should show no changes if recovery was correct)'
echo
echo '=== Cleanup ==='
echo 'Resources to clean up:'
echo ' 1. Lab S3 bucket: terraform destroy -auto-approve'
terraform destroy -auto-approve
echo ' 2. State bucket and DynamoDB (manually — state infrastructure persists):'
echo " aws s3 rb s3://${STATE_BUCKET} --force"
echo " aws dynamodb delete-table --table-name cricket-terraform-locks"Warning: After running 'terraform init -migrate-state', do NOT delete the local terraform.tfstate file immediately. Keep it as a backup for at least 24 hours while verifying the remote state is correct and all team members can successfully run 'terraform plan' from the remote state. Only after all team members have confirmed that 'terraform plan' shows no changes (meaning the remote state matches the actual infrastructure) should the local state backup be deleted. The migration is a copy, not a move — both local and remote state contain identical content after migration, providing a recovery path if something goes wrong in the first day of remote state usage.
- The state migration procedure is: backup local state → add backend block to configuration → run 'terraform init -migrate-state' → verify with 'terraform plan' showing no changes → verify state in S3 → verify DynamoDB lock table.
- Bootstrap the S3 bucket and DynamoDB table with the AWS CLI before running Terraform init — the state backend infrastructure cannot be managed by the same Terraform configuration that uses it.
- S3 object versioning on the state bucket enables recovery from accidental state overwrites — list versions with 'aws s3api list-object-versions', download a specific version, and restore with 'terraform state push'.
- DynamoDB lock records contain the lock ID, operation type, who acquired the lock, and when — use this information to verify that a lock is truly stale before force-unlocking, and only force-unlock after confirming the original process has terminated.
- After migration, keep the local terraform.tfstate as a backup for at least 24 hours — only delete it after verifying that 'terraform plan' from the remote state shows no changes for all team members.
- The state bucket key should be structured as 'project/environment/component/terraform.tfstate' — this organises multiple configurations in the same bucket with self-documenting paths that make state files easy to locate during incident response.