3 real-world Terraform scenarios with solutions, commands, and outcomes.
Context
A SaaS company had one 9,000-resource Terraform state for VPCs, EKS, IAM, RDS, queues, and application infrastructure. Plans took 28 minutes and application releases regularly waited behind platform network changes.
Problem
Every team touched the same backend and lock. A harmless application queue change could not run while the network team modified route tables. Worse, failed applies left the shared state locked and created broad fear of Terraform changes.
Solution
They split state by lifecycle and ownership: foundation networking, cluster platform, shared data stores, and per-service application stacks. Outputs were narrowed to stable contracts such as subnet IDs and security group IDs. CI acquired locks per state and required refresh before high-risk plans.
Commands
terraform init -backend-config=env/prod.s3backend # Uses the production remote backend
terraform plan -out=network.tfplan # Produces a reviewable plan for one ownership domain
terraform state list # Audits resources owned by the current state
Outcome
Median application plan time dropped from 28 minutes to 4 minutes. Lock contention incidents fell by 80%, and state force-unlocks stopped being a weekly event.
Lessons Learned
Splitting state by folder size is not enough. The useful boundary is ownership plus lifecycle plus blast radius.
◈ Architecture Diagram
┌──────────┐
│ Trigger │
└────┬─────┘
↓
┌──────────┐
│ UseCase │
└────┬─────┘
↓
┌──────────┐
│ Verify │
└──────────┘💬 Comments
Context
A platform team manages networking, Kubernetes, secrets, observability, and application namespaces across development, staging, production, and several regions. Traditional root modules and workspaces have become hard to coordinate because each environment drifts in module versions, provider configuration, and rollout order.
Problem
The team needs repeatable infrastructure with separate state per environment, but also wants to manage the platform as one versioned lifecycle. Manual workspace orchestration causes inconsistent plans, skipped dependencies, and risky production rollouts.
Solution
Model the platform as a Terraform Stack composed of components, with component configuration in `.tfcomponent.hcl` and deployment configuration in `.tfdeploy.hcl`. Define deployments for environments, regions, or accounts, customize them with input variables, and use HCP Terraform to coordinate rollout. Keep modules small and reusable, and design Stack boundaries around lifecycle ownership rather than organizational wish lists.
Commands
terraform init
terraform fmt -recursive
Review <name>.tfcomponent.hcl for component boundaries
Review <name>.tfdeploy.hcl for deployment definitions
Use HCP Terraform Stack runs to roll out changes across deployments
Outcome
The team gets a consistent platform definition, isolated state per deployment, and clearer rollout tracking across environments. Production changes become easier to reason about because the component graph and deployment set are explicit.
Lessons Learned
Stacks are useful for complex, repeated infrastructure, but they are not a substitute for module quality, clear ownership, or safe rollout policy. Respect Stack limits and avoid stuffing unrelated infrastructure lifecycles into one Stack.
◈ Architecture Diagram
Stack components ↓ Deployments: dev/stage/prod ↓ Isolated state ↓ Coordinated rollout
💬 Comments
Context
A mid-size fintech platform team of 6 engineers ran 50 microservices on ECS Fargate, all provisioned from a single root Terraform module using count and a shared list(object) variable, a pattern inherited from when the platform had 5 services 18 months earlier. The team was decommissioning a deprecated fraud-scoring-v1 service and needed to prove the removal wouldn't touch the other 49 healthy services in production.
Problem
During a routine cleanup sprint, an engineer removed the fraud-scoring-v1 entry from the shared services list variable and ran terraform plan expecting a single destroy operation. The plan instead showed 31 resources scheduled for destroy-and-recreate — every ECS service, task definition, and target group defined after fraud-scoring-v1's position in the original list, because count addresses resources by numeric index and everything downstream shifted by one slot. The team caught it in code review because the plan output was unusually large for what should have been a one-line change, but they realized this exact pattern had likely gone unnoticed on at least two prior removals in the last year, meaning some of their production services had almost certainly been silently destroyed and recreated before, with a brief service interruption each time masked by the ECS deployment's rolling replacement. The obvious quick fix — just approve the plan since it's technically a no-op for those 31 services' actual configuration — was rejected, because destroy-and-recreate on an ECS service briefly drops it from its target group during the swap, which for user-facing services meant real request failures during the cutover window.
Solution
The team decided to migrate the entire root module from count/list to for_each/map before touching fraud-scoring-v1 at all, treating the migration itself as a zero-diff refactor that had to produce an empty terraform plan when complete. First, they converted the list(object) variable into an equivalent map(object) keyed by each service's name field, generated programmatically from the existing list to guarantee no typos. Second, for every existing resource address (aws_ecs_service.svc[0] through [49]), they ran terraform state mv to remap it to the new string-keyed address (aws_ecs_service.svc["payments-api"]) — scripted in bash by zipping the old list order against service names, since doing 50 manual state mv commands by hand was judged too error-prone for a shared production state file. Third, they updated the resource block itself to use for_each = var.microservices instead of count = length(var.microservices), with each.key and each.value replacing count.index and var.microservices[count.index] throughout. Fourth, and critically, they ran terraform plan after every batch of 10 state mv operations, requiring a clean 'no changes' diff before proceeding to the next batch, treating any unexpected diff as a signal to stop and investigate rather than push forward. Only after the entire 50-service migration produced a genuinely empty plan did they remove fraud-scoring-v1 from the map and re-run plan, this time correctly showing exactly one resource group scheduled for destruction.
Commands
# Generate the state mv script from the old list order and new service names
for i in "${!old_service_names[@]}"; do echo "terraform state mv 'aws_ecs_service.svc[$i]' 'aws_ecs_service.svc[\"${old_service_names[$i]}\"]'"; done > migrate.sh# Run in batches of 10, verifying a clean plan after each batch bash migrate_batch_1.sh && terraform plan -detailed-exitcode
# Confirm zero-diff before touching the deprecated service terraform plan -detailed-exitcode # exit code 0 means no changes
# Only now remove the deprecated entry and re-plan terraform plan -target='module.microservice["fraud-scoring-v1"]'
Outcome
The for_each migration completed over two maintenance windows with zero production incidents and zero dropped requests, verified via ALB target group health check logs showing continuous healthy targets throughout. The subsequent fraud-scoring-v1 removal produced exactly one resource group destroyed, down from the original 31-resource destructive plan. The team also discovered, via CloudTrail history correlated with two prior list-reordering commits, that they had indeed caused two brief unplanned ECS service interruptions in the previous 10 months without realizing the root cause — both had been misattributed to 'Fargate flakiness' at the time.
Lessons Learned
A terraform plan that looks unexpectedly large for a small code change is a signal to stop and understand why, not something to approve because 'the end state is probably fine.' Migrating count to for_each is safest done as its own isolated, zero-diff change verified in small batches with terraform state mv, never combined with an actual infrastructure change in the same apply.
◈ Architecture Diagram
Before: count + list (index-addressed)
[0]payments [1]fraud-v1 [2]checkout ...
remove [1] → [2..49] all shift → 31 destroy/recreate
After: for_each + map (name-addressed)
{"payments","fraud-v1","checkout",...}
remove "fraud-v1" → only that key destroyed💬 Comments