Refactoring Legacy Terraform for Multi-Team Governance
This starts with a legacy Terraform repo: one root module, one shared state file, every team's resources tangled together, and a terraform plan that takes several minutes just to refresh. It then splits that setup into versioned shared modules and separate state files per team so each team can own its infrastructure without stepping on the others.
Before and after
BEFORE AFTER
───────────────────────────── ─────────────────────────────
infra/ infra/
├── main.tf (2,400 lines) ├── modules/
├── variables.tf │ ├── networking/
├── outputs.tf │ ├── postgres-service/
└── (one shared state file │ └── eks-node-pool/
for every team's resources) ├── teams/
│ ├── payments/
│ │ ├── main.tf
│ │ └── backend.tf (own state)
│ ├── platform/
│ │ ├── main.tf
│ │ └── backend.tf (own state)
│ └── data-eng/
│ ├── main.tf
│ └── backend.tf (own state)
└── policy/
└── guardrails.regoTwo moves fix it: pull shared, stable primitives (VPCs, node pools, a Postgres module) into versioned modules, then hand every team ownership of their own state file. A payments-team apply can't touch data-eng's resources, not by naming convention, but because they sit in structurally separate state files.
Drawing module boundaries by ownership, not by resource type
The instinct is to organize modules by AWS/GCP service (modules/vpc, modules/rds, modules/iam), fine for the shared primitives, but the wrong boundary for the teams layer, which should be organized by who's on call for it:
teams/payments/main.tfmodule "postgres" {
source = "../../modules/postgres-service"
version = "2.3.0"
team = "payments"
instance_size = "db.r6g.xlarge"
multi_az = true
}
module "eks_node_pool" {
source = "../../modules/eks-node-pool"
version = "1.4.0"
team = "payments"
min_size = 3
max_size = 12
instance_types = ["m6i.xlarge"]
}Migrating state safely with moved blocks
The old way to split a monolithic state file was manual terraform state mv commands against production state (one typo away from orphaning a resource). Terraform 1.1+'s moved block does the same job declaratively, in a PR that's reviewable and repeatable:
# In teams/payments/main.tf, after the resource has been
# physically moved into this new configuration:
moved {
from = module.monolith.aws_db_instance.payments_primary
to = module.postgres.aws_db_instance.primary
}Run terraform plan after adding a moved block: Terraform shows the resource being renamed, not destroyed and recreated, your green light to run apply.
For splitting the state file across backends, terraform state mv -state-out=<new-backend-state> remains necessary for the physical move. Do it once per resource group, then confirm with terraform plan that both the old and new configurations show zero changes before you delete anything from the old state.
Remote state and locking, per team
# teams/payments/backend.tf
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "teams/payments/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}Separate state per team, same bucket and lock table: isolation with no extra backend infrastructure to maintain.
Policy-as-code guardrails
Policy-as-code stops anyone, you included, from shipping something unsafe on purpose under deadline pressure. This runs as a CI check (conftest test against the plan JSON) before apply is reachable:
package terraform.guardrails
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_acl"
resource.change.after.acl == "public-read"
msg := sprintf("S3 bucket '%s' must not be publicly readable", [resource.address])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
not resource.change.after.tags.team
msg := sprintf("RDS instance '%s' is missing a required 'team' tag", [resource.address])
}# CI step, fails the pipeline before apply is reachable
- name: Terraform plan → JSON → policy check
run: |
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test --policy policy/ tfplan.jsonDrift detection
Once teams own their own state, drift becomes each team's problem to catch early, not a platform-wide mystery. Closing that loop takes a scheduled, read-only plan against every team's state, wired to alert on non-empty diffs:
name: drift-detection
on:
schedule:
- cron: "0 6 * * *"
jobs:
detect:
strategy:
matrix:
team: [payments, platform, data-eng]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: terraform -chdir=teams/${{ matrix.team }} init
- run: terraform -chdir=teams/${{ matrix.team }} plan -detailed-exitcode
# exit code 2 means drift was detected, alert accordinglyWhat changes for the teams
A new engineer on the payments team can safely run terraform apply in their first week, because the blast radius of their state file is only their own resources.
A representative before/after:
Before: 1 shared state file, ~2,400-line root module
terraform plan: 3m40s | 1 person on the team willing to run apply
After: 6 team-owned state files + 3 versioned shared modules
terraform plan: 20-35s per team | every engineer runs their own appliesIf any part of this (module boundaries, a safe state migration, or getting policy-as-code wired into CI) sounds like the wall you're stuck at, that's what an Architecture Review engagement is for.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out