TerraformIaCGovernancePlatform EngineeringAdvanced

Refactoring Legacy Terraform for Teams

Jerry RajaonarivoUpdated Sep 30, 20268 min read

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

text
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.rego

Two 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:

text
teams/payments/main.tf
hcl
module "postgres" {
  source = "git::https://github.com/example-org/terraform-modules.git//postgres-service?ref=postgres-service-v2.3.0"
 
  team          = "payments"
  instance_size = "db.r6g.xlarge"
  multi_az      = true
}
 
module "eks_node_pool" {
  source = "git::https://github.com/example-org/terraform-modules.git//eks-node-pool?ref=eks-node-pool-v1.4.0"
 
  team           = "payments"
  min_size       = 3
  max_size       = 12
  instance_types = ["m6i.xlarge"]
}

Teams consume the shared modules through Git tags rather than relative paths. Terraform rejects a version argument on a local source like ../../modules/postgres-service, and a relative path always points at whatever is on the current branch, so nothing is pinned. With one tag per module release (postgres-service-v2.3.0, eks-node-pool-v1.4.0), each module moves at its own pace, and a team upgrades by bumping the ref in a reviewed PR. If you'd rather write version = "~> 2.3" constraints, publish the modules to a private registry (HCP Terraform, or a self-hosted one) and use a registry source instead.

Migrating state safely: moved, import and removed

Reshuffling state used to mean running terraform state mv by hand against production state, where one typo could orphan a resource. Current Terraform has three declarative blocks for this, and they go through PR review like any other change. Each one covers a different case.

Renames inside one state: moved

moved (Terraform 1.1+) only works within a single state. I use it for the first step of the refactor, which is wrapping the monolith's inline resources in the new shared modules while everything still lives in the monolith's state.

hcl
# In the monolith config, after replacing the inline
# aws_db_instance with a call to the postgres-service module:
moved {
  from = aws_db_instance.payments_primary
  to   = module.payments_postgres.aws_db_instance.primary
}

Run terraform plan after adding a moved block. Terraform should show the resource being moved to its new address, with no destroy and recreate. If you see a destroy/create pair, the addresses don't line up, and you need to fix that before going further.

Splitting state across teams: import + removed

A moved block can't push a resource into a different state file, and terraform state mv -state-out only works with local state files, which rules out an S3 backend. To hand the payments database over to teams/payments, I use two coordinated PRs and freeze applies to the monolith until both are merged.

1. The new team config adopts the resource with an import block (Terraform 1.5+):

hcl
# teams/payments/imports.tf
import {
  to = module.postgres.aws_db_instance.primary
  id = "payments-primary" # the RDS DB identifier
}

terraform plan in teams/payments should report 1 to import, 0 to add, 0 to change, 0 to destroy. If it wants to change or replace anything, the module inputs don't match what's deployed. Adjust them until the plan only imports, then apply. Repeat this for every related resource (subnet group, parameter group, security group). When there are many, import blocks accept for_each (Terraform 1.7+).

2. The monolith lets go of it without destroying it with a removed block (Terraform 1.7+), in place of the deleted resource or module block:

hcl
# In the monolith config, replacing the module "payments_postgres" block:
removed {
  from = module.payments_postgres
 
  lifecycle {
    destroy = false
  }
}

terraform plan in the monolith should say the resources will no longer be managed by Terraform and show 0 to destroy. Apply it, then run terraform plan in both configs again. Neither should show any changes. The database is never destroyed or recreated, so the split causes no downtime. Between steps 1 and 2 the resource is tracked by two states at once, and that's the reason applies to the monolith stay frozen until step 2 is merged.

Remote state and locking, per team

hcl
# teams/payments/backend.tf
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "teams/payments/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true # S3-native locking, no DynamoDB table
    encrypt      = true
  }
 
  required_version = ">= 1.11"
}

Each team gets its own state in the same bucket, so they're isolated without any extra backend infrastructure to maintain. use_lockfile writes a .tflock object next to each state file using S3 conditional writes. The old dynamodb_table argument has been deprecated since Terraform 1.11, so you no longer need a lock table. If you're migrating an existing backend, you can set both during the transition and drop dynamodb_table afterwards.

Policy-as-code guardrails

Policy-as-code catches unsafe changes that anyone, including you, might ship by accident when a deadline is close. It runs as a CI check (conftest test against the plan JSON) before apply is reachable:

text
# policy/guardrails.rego
package main
 
deny contains msg if {
  some resource in input.resource_changes
  resource.type == "aws_s3_bucket_acl"
  resource.change.after.acl == "public-read"
  msg := sprintf("S3 bucket ACL '%s' must not be public-read", [resource.address])
}
 
deny contains msg if {
  some resource in input.resource_changes
  resource.type == "aws_db_instance"
  resource.change.after != null # skip deletions
  not resource.change.after.tags.team
  msg := sprintf("RDS instance '%s' is missing a required 'team' tag", [resource.address])
}
yaml
# 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.json

Two details can make this policy pass without checking anything. First, conftest only evaluates the main package by default, so a policy declared as package terraform.guardrails never runs unless you add --namespace terraform.guardrails (or --all-namespaces). Second, current conftest ships OPA 1.x, which requires the contains/if syntax shown here and rejects the older deny[msg] { form. On a pre-1.0 conftest, add import rego.v1 under the package line. The tags check also reads only the resource's own tags. If you set team through provider default_tags, check tags_all instead.

Drift 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:

yaml
name: drift-detection
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch: {}
 
permissions:
  id-token: write # required for AWS OIDC
  contents: read
 
jobs:
  detect:
    strategy:
      fail-fast: false
      matrix:
        team: [payments, platform, data-eng]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
 
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "~1.16.0"
 
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-drift-${{ matrix.team }}
          aws-region: us-east-1
 
      - run: terraform -chdir=teams/${{ matrix.team }} init -input=false
 
      - run: terraform -chdir=teams/${{ matrix.team }} plan -detailed-exitcode -lock=false -input=false
        # exit code 2 means drift was detected: the job fails, which is the alert

The job authenticates to AWS through GitHub's OIDC provider, so there are no long-lived access keys in repository secrets. Each team has its own read-only role (ReadOnlyAccess plus read access to its own state key), and the role's trust policy only accepts tokens from this repository's default branch. With -lock=false, the scheduled plan can never block a team's real apply. That's safe because this plan never writes state.

What 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.

Here's what I typically see before and after (your timings will depend on provider count and API latency):

text
Before: 1 shared state file, ~2,400-line root module
        terraform plan: several minutes  |  very few people willing to run apply
 
After:  3 team-owned state files + 3 versioned shared modules
        terraform plan: well under a minute per team  |  every engineer runs their own applies

If 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.

GO FURTHER

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
Get in TouchReply within 1 business day.

Related Tutorials