DEV Community

Cover image for I Inherited 47,000 Lines of Terraform Spaghetti — Here's How I Untangled It Without Burning Production
S, Sanjay
S, Sanjay

Posted on

I Inherited 47,000 Lines of Terraform Spaghetti — Here's How I Untangled It Without Burning Production

The Slack Message That Ruined My Monday

"Hey, the previous platform team left. Here's the repo. Good luck 🫡"

I stared at the Git repository. 47,000 lines of Terraform. One state file. Zero modules. Variables named x, temp2, and my personal favorite — DO_NOT_TOUCH_ask_raj. Raj had left the company two years ago.

If you've been a Senior DevOps Engineer for more than a year, you've inherited something like this. Maybe not 47K lines, but you've opened a main.tf that made you question your career choices.

This isn't a "Terraform best practices" article. Those are written by people who've never had to run terraform plan on a 3,000-resource state file at 2 AM while the VP of Engineering watches.

This is a survival guide.


Anti-Pattern #1: The Monolith State File (aka "The Single Point of Career Failure")

What I Found

# main.tf — 8,400 lines# "Managed" networking, compute, databases, DNS, IAM, monitoring,# and somehow... a CloudFront distribution for a marketing site# that was decommissioned in 2023.resource"aws_vpc""main"{...}resource"aws_instance""api_server_1"{...}resource"aws_instance""api_server_2"{...}# ... 200 more instances ...resource"aws_rds_instance""prod_db"{...}resource"aws_iam_role""god_mode"{...}# yes, really
Enter fullscreen modeExit fullscreen mode

A single terraform apply touched everything. Networking, databases, compute, DNS — all entangled like Christmas lights in January. One typo in a security group rule? Congratulations, your plan just showed 847 resources to evaluate, and Terraform decided your RDS instance needs replacing.

The Real Danger

This isn't just messy — it's operationally catastrophic. Here's what happens:

  • terraform plan takes 14 minutes. Developers stop running it.
  • State file locking means only one person can work at a time.
  • Blast radius of any mistake = the entire infrastructure.
  • New team members are terrified to touch anything (rightfully so).

How I Fixed It (Without Downtime)

Step 1: State Surgery with terraform state mv

# First, I mapped resource dependencies visually
terraform graph | dot -Tsvg> infra-dependency-map.svg
# Then, split by domain boundaries
terraform state mv'aws_vpc.main'-state-out=networking/terraform.tfstate
terraform state mv'aws_subnet.public[0]'-state-out=networking/terraform.tfstate
terraform state mv'aws_subnet.public[1]'-state-out=networking/terraform.tfstate
Enter fullscreen modeExit fullscreen mode

Step 2: Introduce State Boundaries by Blast Radius

I split into five state files based on change frequency and blast radius:

LayerContentsChange FrequencyBlast Radius
foundationVPC, Subnets, Route TablesMonthlyCritical
securityIAM, KMS, Security GroupsWeeklyCritical
dataRDS, ElastiCache, S3RareCatastrophic
computeECS/EKS, ASGs, ALBsDailyHigh
edgeCloudFront, Route53, WAFWeeklyMedium

Step 3: Wire Them Together with Remote State Data Sources

# In compute/main.tfdata"terraform_remote_state""networking"{backend="s3"config={bucket="company-terraform-state"key="foundation/terraform.tfstate"region="us-east-1"}}resource"aws_ecs_service""api"{# Reference networking outputs safelynetwork_configuration{subnets=data.terraform_remote_state.networking.outputs.private_subnet_ids}}
Enter fullscreen modeExit fullscreen mode

Result:terraform plan went from 14 minutes to 45 seconds. Team velocity tripled. I stopped getting 2 AM pages about state locks.


Anti-Pattern #2: The Copy-Paste Empire (aka "Modules at Home")

What I Found

environments/
├── dev/
│ └── main.tf # 1,200 lines
├── staging/
│ └── main.tf # 1,200 lines (95% identical to dev)
├── prod/
│ └── main.tf # 1,200 lines (90% identical... with 47 "hotfixes")
└── dr/
└── main.tf # 1,200 lines (copied from prod 8 months ago, never updated)
Enter fullscreen modeExit fullscreen mode

Four copies of the same infrastructure with subtle drift. Staging had a security group rule that prod didn't. DR was missing three services entirely. Nobody knew which differences were intentional.

Why This Kills Senior Engineers

You can't diff your way out of this. The files have diverged in ways that are both intentional (prod has larger instances) and accidental (someone fixed a bug in dev but forgot to propagate it). You have no source of truth.

The Refactoring Strategy That Actually Works

Don't try to unify everything at once. I learned this the hard way after a failed "big bang" refactor that took 3 sprints and broke staging for a week.

Instead, use the Strangler Fig pattern:

# modules/api-platform/main.tfvariable"environment"{type=stringvalidation{condition=contains(["dev","staging","prod","dr"],var.environment)error_message="Environment must be dev, staging, prod, or dr."}}variable"config"{type=object({instance_type=stringmin_capacity=numbermax_capacity=numberenable_waf=boolmulti_az=boolbackup_retention=number})}locals{# Environment-specific defaults that document WHY they differenv_config={dev={instance_type="t3.medium"min_capacity=1max_capacity=2enable_waf=falsemulti_az=falsebackup_retention=1}prod={instance_type="m5.xlarge"min_capacity=3max_capacity=20enable_waf=truemulti_az=truebackup_retention=35}}}
Enter fullscreen modeExit fullscreen mode

The key insight: Every environment difference should be documented in code as a conscious decision, not hidden in a 1,200-line file as an accidental divergence.


Anti-Pattern #3: The terraform apply -auto-approve YOLO Pipeline

What I Found in .gitlab-ci.yml

deploy_prod:stage:deployscript:-terraform init-terraform apply -auto-approve# 🚨 WHATonly:-main
Enter fullscreen modeExit fullscreen mode

No plan artifact. No approval gate. No diff review. Push to main → infrastructure changes in production. The commit history told the horror story:

fix: revert the revert of the fix
fix: actually fix prod this time
fix: ok THIS one fixes it
revert: revert everything from today
Enter fullscreen modeExit fullscreen mode

What Senior Engineers Actually Need

# .github/workflows/terraform.ymlname:"Terraform"on:pull_request:paths:['infrastructure/**']push:branches:[main]paths:['infrastructure/**']jobs:plan:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-name:Terraform Planid:planrun:|terraform initterraform plan -no-color -out=tfplan \-detailed-exitcode 2>&1 | tee plan_output.txtcontinue-on-error:true-name:Comment Plan on PRuses:actions/github-script@v7if:github.event_name == 'pull_request'with:script:|const fs = require('fs');const plan = fs.readFileSync('plan_output.txt', 'utf8');const truncated = plan.length > 60000 ? plan.substring(0, 60000) + '\n\n... truncated ...' : plan;github.rest.issues.createComment({issue_number: context.issue.number,owner: context.repo.owner,repo: context.repo.repo,body: `## Terraform Plan Output\n\`\`\`\n${truncated}\n\`\`\``});-name:Upload Plan Artifactuses:actions/upload-artifact@v4with:name:tfplanpath:tfplanapply:needs:planruns-on:ubuntu-latestif:github.ref == 'refs/heads/main' && github.event_name == 'push'environment:production# Requires manual approvalsteps:-uses:actions/checkout@v4-name:Download Planuses:actions/download-artifact@v4with:name:tfplan-name:Terraform Applyrun:terraform apply tfplan# Apply ONLY the reviewed plan
Enter fullscreen modeExit fullscreen mode

The non-negotiable rules:

  1. Plans are generated on PR and attached as artifacts.
  2. Humans review the diff before any production apply.
  3. Apply uses the exact plan that was reviewed (not a new plan).
  4. The production environment requires manual approval from a senior engineer.

Anti-Pattern #4: Secrets in State (The Ticking Compliance Bomb)

What I Found

resource"aws_db_instance""prod"{engine="postgres"instance_class="db.r5.2xlarge"username="admin"password="Pr0d_P@ssw0rd_2022!"# I wish I was jokingpublicly_accessible=true# I really wish I was joking}
Enter fullscreen modeExit fullscreen mode

The password was in the .tf file, the state file, the plan output, and the Git history. Four places to leak from. And publicly_accessible = true was the cherry on this dumpster fire sundae.

The Fix (That Also Passes Audit)

# Use a data source to pull secrets at plan/apply timedata"aws_secretsmanager_secret_version""db_password"{secret_id="prod/rds/master-password"}resource"aws_db_instance""prod"{engine="postgres"instance_class="db.r5.2xlarge"username="admin"password=data.aws_secretsmanager_secret_version.db_password.secret_stringpublicly_accessible=false# Prevent Terraform from detecting password "drift"lifecycle{ignore_changes=[password]}}
Enter fullscreen modeExit fullscreen mode

But that's not enough. The state file still contains sensitive values. The complete solution:

# backend.tfterraform{backend"s3"{bucket="company-terraform-state"key="prod/data/terraform.tfstate"region="us-east-1"encrypt=true# SSE-KMS encryptionkms_key_id="arn:aws:kms:us-east-1:xxx:key/yyy"dynamodb_table="terraform-state-lock"}}
Enter fullscreen modeExit fullscreen mode

Plus strict S3 bucket policies, access logging, and never giving developers direct state file access. Use terraform output instead.


Anti-Pattern #5: The "God Resource" With 200 Lines of Nested Blocks

What I Found

resource"aws_ecs_task_definition""api"{family="api"network_mode="awsvpc"requires_compatibilities=["FARGATE"]cpu=1024memory=2048execution_role_arn=aws_iam_role.ecs_execution.arntask_role_arn=aws_iam_role.ecs_task.arncontainer_definitions=jsonencode([{name="api"image="company/api:latest"# 🚨 LATEST TAG IN PRODportMappings=[{containerPort=8080}]environment=[{name="DB_HOST",value="prod-db.cluster-xxx.us-east-1.rds.amazonaws.com"},{name="DB_NAME",value="production"},{name="REDIS_URL",value="prod-redis.xxx.cache.amazonaws.com:6379"},# ... 45 more environment variables hardcoded here ...]logConfiguration={logDriver="awslogs"options={"awslogs-group"="/ecs/api""awslogs-region"="us-east-1""awslogs-stream-prefix"="api"}}# ... 80 more lines of health checks, mount points, ulimits ...}])}
Enter fullscreen modeExit fullscreen mode

The problems compound:

  • Environment variables are hardcoded (not sourced from SSM/Secrets Manager).
  • latest tag means deployments are non-reproducible.
  • The jsonencode blob is untestable and un-diffable in PR reviews.
  • One change to any env var triggers a full task definition replacement.

The Refactored Version

# Use templatefile for complex JSON — it's testable and readableresource"aws_ecs_task_definition""api"{family="api-${var.environment}"network_mode="awsvpc"requires_compatibilities=["FARGATE"]cpu=var.task_cpumemory=var.task_memoryexecution_role_arn=aws_iam_role.ecs_execution.arntask_role_arn=aws_iam_role.ecs_task.arncontainer_definitions=templatefile("${path.module}/templates/api-container.json.tpl",{image_tag=var.image_tag# Pinned, passed from CI/CDenvironment=var.environmentdb_host=data.aws_ssm_parameter.db_host.valueredis_url=data.aws_ssm_parameter.redis_url.valuelog_group=aws_cloudwatch_log_group.api.nameaws_region=data.aws_region.current.name})}
Enter fullscreen modeExit fullscreen mode

The Refactoring Playbook (Do This Monday)

After untangling this mess across three months, here's the sequence that works:

Week 1: Triage and Protect

# 1. Enable state file encryption and locking NOW# 2. Add branch protection — no direct pushes to main# 3. Run terraform plan and SAVE the output as your baseline
terraform plan -no-color> baseline_plan_$(date +%Y%m%d).txt
# 4. Enable detailed audit logging on your state bucket
Enter fullscreen modeExit fullscreen mode

Week 2-4: Split the Monolith

# Use terraform state list to inventory everything
terraform state list > all_resources.txt
wc-l all_resources.txt # Mine had 2,847 resources# Group by service domaingrep"aws_vpc\|aws_subnet\|aws_route" all_resources.txt > networking.txt
grep"aws_iam\|aws_kms" all_resources.txt > security.txt
grep"aws_rds\|aws_elasticache\|aws_s3" all_resources.txt > data.txt
grep"aws_ecs\|aws_alb\|aws_autoscaling" all_resources.txt > compute.txt
Enter fullscreen modeExit fullscreen mode

Week 5-8: Modularize Incrementally

Move one service at a time into a module. After each move:

  1. Run terraform plan — it should show zero changes.
  2. If plan shows changes, you have a bug. Fix it before moving on.
  3. Get a PR review from another senior engineer.
  4. Apply and monitor for 24 hours.

Week 9-12: Harden the Pipeline

  • Add terraform validate and tflint to CI.
  • Add checkov or tfsec for security scanning.
  • Implement drift detection (scheduled plan that alerts on differences).
  • Add cost estimation with infracost.

The Drift Detection Cron That Saved Us

This is the thing nobody talks about. Even after a perfect refactor, drift happens. Someone clicks in the console. An auto-remediation tool makes changes. A Lambda modifies a security group.

# .github/workflows/drift-detection.ymlname:"DriftDetection"on:schedule:-cron:'06**1-5'# Every weekday at 6 AMjobs:detect-drift:runs-on:ubuntu-lateststrategy:matrix:layer:[foundation,security,data,compute,edge]steps:-uses:actions/checkout@v4-name:Terraform Plan (Drift Check)id:planworking-directory:infrastructure/${{ matrix.layer }}run:|terraform initterraform plan -detailed-exitcode -no-color > plan.txt 2>&1echo "exitcode=$?" >> $GITHUB_OUTPUTcontinue-on-error:true-name:Alert on Driftif:steps.plan.outputs.exitcode == '2'run:|# Exit code 2 = changes detected (drift!)curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \-H 'Content-type: application/json' \-d "{\"text\":\"🚨 Drift detected in *${{ matrix.layer }}* layer. Check the plan output.\"}"
Enter fullscreen modeExit fullscreen mode

We caught 3 unauthorized console changes in the first week alone.


Parting Wisdom for the Senior Engineer Who Just Inherited a Mess

  1. Don't refactor everything at once. You'll break things and lose credibility.

  2. Document what you find before you fix it. Screenshot the horrors. You'll need them for the post-mortem and for your performance review.

  3. Get buy-in from leadership BEFORE you start. "I need 3 sprints for tech debt" is a hard sell. "Our current setup means any infrastructure change has a 40% chance of causing an incident" gets budget approved.

  4. Every terraform state mv should be a separate, reviewed PR. Not because it's technically necessary, but because when something breaks at step 37 of 50, you want a clean git history to bisect.

  5. The goal isn't perfect Terraform. The goal is Terraform that your team can safely operate at 2 AM. If a junior engineer can't run terraform plan without fear, your refactor isn't done.


TL;DR for the Scrollers

Anti-PatternFixPriority
Monolith state fileSplit by blast radius and change frequencyP0
Copy-paste environmentsModules + environment configsP1
-auto-approve in CIPlan artifacts + manual approval gatesP0
Secrets in state/codeSecrets Manager + encrypted state + ignore_changesP0
God resources with inline JSONtemplatefile + SSM parametersP2
No drift detectionScheduled plan with alertingP1

If you've ever stared at a Terraform codebase and whispered "who did this?!" into the void — you're not alone. We've all been there. The good news? It's fixable. One state move at a time.


Found this useful? Follow me for more battle-tested DevOps content. I write about the stuff that actually happens in production — not the happy path from the docs.

Top comments (1)

Collapse
 
harjjotsinghh profile image
Harjot Singh

"Without burning production" is the part that earns this post its credibility - anyone can rewrite Terraform; doing it on live infra without an outage is the actual skill. The discipline you're describing (small reversible steps, plan-diff obsession, never trusting a refactor you can't roll back) is exactly the kind of high-stakes work that does NOT belong on autopilot.

This is a nice counterweight to the "just let the AI refactor it" hype. 47k lines of state-managing infra is the textbook case where an agent confidently producing a plausible plan is terrifying - a wrong apply isn't a bad PR, it's downtime. The right use of AI here is narrow and supervised: help you understand a module, draft a single scoped change, explain a diff - with you owning every apply. Great writeup; the "untangle without burning prod" framing should be required reading before anyone points an agent at their IaC.