How to Refactor a Terraform Monolith into Modules Without Downtime
Description and use
Successfully refactored a 1,600-line Terraform monolith into seven local modules with zero changes to real infrastructure (0 to add, 0 to change, 0 to destroy). The root main.tf went from ~1,600 lines to 47 lines. Resource names now reflect role rather than discovery order. State preserved via 45 moved blocks. Two Sentinel policies (no-destructive-changes, flag-resource-updates) independently verified the move-only plan.
Problem
A single 1,600-line Terraform monolith with 45 resources, hardcoded IDs, and resource names describing discovery order rather than role, making it impossible to reuse across environments and difficult to maintain.
Outcome
Successfully refactored a 1,600-line Terraform monolith into seven local modules with zero changes to real infrastructure (0 to add, 0 to change, 0 to destroy). The root main.tf went from ~1,600 lines to 47 lines. Resource names now reflect role rather than discovery order. State preserved via 45 moved blocks. Two Sentinel policies (no-destructive-changes, flag-resource-updates) independently verified the move-only plan.
Workflow
- Inventory every resource and import block: address, Name tag, references
- Propose a module layout and the full old-address -> new-address map; STOP and wait for approval
- Rename logical names by role, not discovery order
- Preserve state with moved blocks; remove import blocks
- Replace hardcoded cross-references with real references
- Add depends_on only where a reference can't express ordering, each with one-line justification
- Clean up and apply style guide
- Validate: fmt, init, validate, plan
Evidence
At the end of Part 1 I made a confession: I had a managed application, not a well-structured one. Everything was in Terraform state, the app never went down, and the code was still one enormous main.tf that I would not wish on a colleague.
Resource names like stack_subnets_3, iam_roles_33 and ingress_rules_in_stack_groups_1_5. They describe the query that found the resource, not what the resource does.
Hardcoded IDs and ARNs everywhere. Terraform did not know the subnets belonged to the VPC. The strings just happened to match.
45 import blocks still sitting next to the resources they imported.
The refactor had a single acceptance test: 0 to add, 0 to change, 0 to destroy. Every resource keeps its real-world identity. Only its address in Terraform state and the layout of the code change.
The two Sentinel policies from Part
- Record date: 2026-10-23T18:11:07.743Z