{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreig4hvrxoonigudrltsa243e7qehzl6cm4grryvo6tokt2dkzm6rjq",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpjelaatul62"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreifx45cdtwlfh7cqosjoizcy5n2npsuhaxv2oezj534fnpgqaulj6y"
},
"mimeType": "image/webp",
"size": 586868
},
"path": "/karlschriek/splitting-a-terraform-monolith-into-smaller-states-4b5a",
"publishedAt": "2026-06-30T15:39:06.000Z",
"site": "https://dev.to",
"tags": [
"terraform",
"cloud",
"cicd",
"infrastructureascode",
"The Problem with Large Terraform States",
"Snap CD",
"Module",
"Modular Deployments",
"input",
"Self-Hosted Terraform Runners with Credential Isolation",
"Managing Secrets in Terraform"
],
"textContent": "If your Terraform plans are slow, your blast radius is too wide, or multiple teams are stepping on each other's changes, it's time to split your monolith. See The Problem with Large Terraform States for how to diagnose whether you've reached that point.\n\nThis guide walks through the process of breaking a monolithic Terraform state into smaller, focused states — and how Snap CD can manage the dependencies between them so you don't have to.\n\n## The approach\n\n### 1. Identify natural boundaries\n\nLook at your resources and group them by lifecycle and ownership. Common boundaries:\n\n * **Networking** — VPCs, subnets, route tables, NAT gateways. Changes rarely, underpins everything.\n * **DNS** — Zones, records. Usually owned by a platform team.\n * **Compute** — Kubernetes clusters, VM scale sets, container services. Changes more often, depends on networking.\n * **Application infrastructure** — Databases, caches, queues, storage accounts. Owned by application teams.\n * **Monitoring** — Dashboards, alerts, log sinks. Changes frequently, depends on everything but nothing depends on it.\n\n\n\nA useful test: if two resources would never be changed in the same PR by the same person, they probably belong in different states.\n\n### 2. Map the dependencies\n\nBefore you move anything, draw the dependency graph. Which groups produce values that other groups consume?\n\n\n\n networking dns\n │ ▲\n ▼ │\n compute ──────────►─┘\n │\n ▼\n application\n │\n ▼\n monitoring\n\n\nThe outputs that cross these boundaries are what you'll need to wire up after the split. Typical examples:\n\n * Networking → Compute: `vpc_id`, `private_subnet_ids`\n * Compute → DNS: `load_balancer_ip`\n * Compute → Application: `cluster_endpoint`, `cluster_ca_certificate`\n * Application → Monitoring: `database_id`, `cache_name`\n\n\n\n### 3. Use `terraform state mv` to migrate resources\n\nTerraform's `state mv` command lets you move resources from one state to another without destroying and recreating them.\n\n\n\n # Initialize the destination state\n cd modules/networking\n terraform init\n\n # Move resources from the monolith to the new state\n terraform state mv \\\n -state=../monolith/terraform.tfstate \\\n -state-out=./terraform.tfstate \\\n aws_vpc.main aws_vpc.main\n\n terraform state mv \\\n -state=../monolith/terraform.tfstate \\\n -state-out=./terraform.tfstate \\\n aws_subnet.private aws_subnet.private\n\n\nDo this methodically, one logical group at a time. After each move:\n\n 1. Run `terraform plan` on the new state — it should show no changes.\n 2. Run `terraform plan` on the monolith — the moved resources should no longer appear.\n\n\n\n### 4. Replace hard references with inputs\n\nIn the monolith, your compute module might directly reference `aws_vpc.main.id`. After the split, that VPC lives in a different state. You need to replace the hard reference with a variable:\n\n\n\n # Before (monolith)\n resource \"aws_eks_cluster\" \"main\" {\n vpc_config {\n subnet_ids = aws_subnet.private[*].id\n }\n }\n\n # After (separate compute module)\n variable \"private_subnet_ids\" {\n type = list(string)\n }\n\n resource \"aws_eks_cluster\" \"main\" {\n vpc_config {\n subnet_ids = var.private_subnet_ids\n }\n }\n\n\nAnd in the networking module, expose the value as an output:\n\n\n\n output \"private_subnet_ids\" {\n value = aws_subnet.private[*].id\n }\n\n\n### 5. Wire up the cross-state dependencies\n\nThis is where it gets interesting. You've split the monolith, and now you need the outputs from one state to flow into another. There are a few ways to do this:\n\n**Option A:`terraform_remote_state` data sources**\n\nThe built-in approach. Each consuming module reads the producer's state directly:\n\n\n\n data \"terraform_remote_state\" \"networking\" {\n backend = \"s3\"\n config = {\n bucket = \"my-terraform-state\"\n key = \"networking/terraform.tfstate\"\n region = \"us-east-1\"\n }\n }\n\n resource \"aws_eks_cluster\" \"main\" {\n vpc_config {\n subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids\n }\n }\n\n\nThis works but has significant drawbacks:\n\n * Every consumer needs to know the backend configuration of every producer.\n * There's no enforcement of the dependency order — you have to manually ensure networking is applied before compute.\n * Changes to networking outputs don't automatically trigger a re-plan of compute.\n\n\n\n**Option B: Wrapper scripts and CI glue**\n\nYou write shell scripts or CI pipeline steps that run `terraform output` on one state and feed the values into `terraform apply -var` on the next. This is what most teams end up doing, and it's fragile — the dependency graph lives in CI config rather than in code.\n\n**Option C: Terragrunt**\n\nTerragrunt adds a dependency layer on top of Terraform:\n\n\n\n # compute/terragrunt.hcl\n dependency \"networking\" {\n config_path = \"../networking\"\n }\n\n inputs = {\n vpc_id = dependency.networking.outputs.vpc_id\n private_subnet_ids = dependency.networking.outputs.private_subnet_ids\n }\n\n\nThis is a genuine improvement — dependencies are declared in code, ordering is enforced, and `terragrunt run-all apply` handles the graph. But Terragrunt is a local CLI tool. It doesn't provide a persistent view of deployment status, approval gates, automatic re-deployment when upstream outputs change, or scoped permissions.\n\n**Option D: Snap CD**\n\nSnap CD was built for this problem. Each split becomes a Snap CD Module, and cross-state dependencies are declared as code. Snap CD enforces apply ordering, runs independent Modules in parallel, and automatically cascades changes when upstream outputs change. See Modular Deployments for a detailed walkthrough of how the Module and input system works.\n\n## Tips\n\n * **Split incrementally.** Move one logical group at a time. Don't try to split everything in one go.\n * **Start with the layer that changes least.** Networking is usually the best first candidate — it has many dependents but few dependencies.\n * **Keep shared modules small.** If a Terraform module (in the `module {}` sense) is used by multiple states, keep it focused. A module that provisions \"everything for an app\" is just a monolith in disguise.\n * **Test with`terraform plan` after every move.** A clean plan (no changes) on both the source and destination states confirms the migration was correct.\n\n\n\n## See also\n\n * The Problem with Large Terraform States — diagnosing when it's time to split\n * Modular Deployments — how Snap CD manages cross-state dependencies after the split\n * Self-Hosted Terraform Runners with Credential Isolation — scoping credentials per environment with dedicated Runners\n * Managing Secrets in Terraform — keeping secrets scoped when splitting states\n\n",
"title": "Splitting a Terraform Monolith into Smaller States"
}