Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 056 / 100

Splitting large Terraform states: lessons from production

Faster plans and safer changes.

Expona Admin8 min read

The cost of monolithic Terraform state

We've all been there: a Terraform plan that takes five minutes to run, a tiny DNS change that somehow ripples through your entire infrastructure graph, or a junior team member holding their breath while applying changes that affect services they don't own. Splitting large Terraform states: lessons from production isn't a theoretical exercise—it's a survival skill when you're managing infrastructure at scale. A single monolithic state file becomes a bottleneck, a risk multiplier, and a coordination nightmare across teams.

By the end of this guide, you'll understand when and how to split states, how to share values safely between them, and the tooling patterns that make migrations painless.

Why monolithic states break down

A 500-resource state file isn't just slow—it's a liability. Every plan operation locks the state, walks the entire graph, and forces human reviewers to parse changes across unrelated systems. Worse, a single mistake in one section can corrupt or damage resources managed by a different team entirely.

Production experience shows that beyond 200–300 resources, state management overhead becomes tangible. Plan times exceed two minutes, apply windows stretch, and the blast radius of any mistake grows exponentially.

Split by lifecycle: network infrastructure rarely changes

Network infrastructure—VPCs, subnets, security groups, route tables—tends toward immutability once deployed. DNS and load balancers also change infrequently. Yet in a monolithic state, a single network tweak triggers a full plan of your entire application stack.

Separate your foundational, slow-moving infrastructure into its own state and stack:

hcl

# states/networking/main.tf
terraform {
  backend "s3" {
    bucket         = "prod-terraform-state"
    key            = "networking/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name        = "prod-vpc"
    Environment = "production"
  }
}

resource "aws_subnet" "app" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = false

  tags = {
    Name = "prod-app-subnet"
  }
}

output "vpc_id" {
  value = aws_vpc.main.id
}

output "app_subnet_id" {
  value = aws_subnet.app.id
}

With networking isolated, your application teams can run plans and applies multiple times per day without triggering a full network audit. The ops team owns the network state; developers own their service states. Changes happen independently, reducing merge conflicts and coordination overhead.

Split by team ownership

In larger organizations, each team manages different slices of the infrastructure: platform engineering handles the base layer, the database team owns RDS and backups, and application teams deploy their services. A monolithic state forces artificial coupling between these concerns.

Create a state hierarchy that mirrors your org structure:

hcl

# states/platform/main.tf (platform engineering team)
terraform {
  backend "s3" {
    bucket         = "prod-terraform-state"
    key            = "platform/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

resource "aws_security_group" "eks_control_plane" {
  name   = "eks-cp-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.office_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "eks_control_plane_sg_id" {
  value = aws_security_group.eks_control_plane.id
}
hcl

# states/databases/main.tf (database team)
terraform {
  backend "s3" {
    bucket         = "prod-terraform-state"
    key            = "databases/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

variable "eks_control_plane_sg_id" {
  type = string
}

resource "aws_db_subnet_group" "main" {
  name       = "prod-db-subnet-group"
  subnet_ids = var.database_subnets

  tags = {
    Name = "prod-db-subnet-group"
  }
}

resource "aws_rds_cluster" "postgres" {
  cluster_identifier     = "prod-postgres"
  engine                 = "aurora-postgresql"
  engine_version         = "15.2"
  database_name          = "prod"
  master_username        = "postgres"
  master_password        = random_password.db_password.result
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]
  backup_retention_period = 30
  skip_final_snapshot    = false
  final_snapshot_identifier = "prod-postgres-final-snapshot-${formatdate("YYYY-MM-DD-hhmm", timestamp())}"

  tags = {
    Name = "prod-postgres"
  }
}

output "rds_endpoint" {
  value = aws_rds_cluster.postgres.endpoint
}

Each team can work independently, on their own schedule, without stepping on toes. State locking happens at the right granularity. Code review processes stay within team boundaries.

Share values through outputs and data sources

The critical challenge: how do services in one state reference resources created by another state? The answer is outputs and data sources, not cross-state variable references.

When the platform team creates a VPC, they expose its ID as an output. The database team and application teams read that value—either manually, via Terraform Cloud/Enterprise remote state data sources, or through a shared variable store like Parameter Store or Secrets Manager.

For remote state lookups in Terraform Cloud or self-hosted Terraform Enterprise:

hcl

# states/databases/main.tf
data "terraform_remote_state" "networking" {
  backend = "s3"

  config = {
    bucket         = "prod-terraform-state"
    key            = "networking/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

variable "environment" {
  type    = string
  default = "production"
}

resource "aws_security_group" "db" {
  name   = "prod-db-sg"
  vpc_id = data.terraform_remote_state.networking.outputs.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [data.terraform_remote_state.platform.outputs.app_sg_id]
  }
}

This pattern works well, but it introduces a soft coupling: the database state must know where the networking state lives. For truly large organizations, consider an even simpler approach: store outputs in Parameter Store and read them via data sources, decoupling the physical location of state files.

Migrate with moved and import blocks

The most dangerous part of splitting states is the migration itself. Resources get recreated accidentally, state corruption happens, and downtime creeps in. Terraform 1.1+ gives us powerful tools to avoid this.

If you're splitting an existing monolithic state, first identify which resources belong in the new state. Then, in your old state, use the moved block to tell Terraform where they're going:

hcl

# states/old-monolith/main.tf (before split)
moved {
  from = aws_vpc.main
  to   = aws_vpc.main  # This goes to the new networking state
}

moved {
  from = aws_rds_cluster.postgres
  to   = aws_rds_cluster.postgres  # This goes to the new database state
}

Don't actually apply these blocks yet. Instead, use them to generate a state move plan:

hcl

# Manually pull the resource from the old state
terraform state rm aws_vpc.main
terraform state rm aws_rds_cluster.postgres

# In the new state directory, pull them in
terraform import aws_vpc.main vpc-12345678
terraform import aws_rds_cluster.postgres arn:aws:rds:us-east-1:123456789:cluster/prod-postgres

Or, for a cleaner workflow with Terraform Cloud, use state push/pull:

hcl

# Pull the full state
terraform state pull > old-state.json

# Manually edit the state to remove resources destined for other stacks
# (or use jq for this in CI/CD)

# Push the edited state
terraform state push old-state.json

# In the new state directory
terraform state push new-state.json

Test this migration in a staging environment first. Always keep backups of your state files before and after any manual manipulation.

Practical workflow after the split

Once your states are split, establish clear ownership boundaries. A typical workflow:

  1. Platform team maintains the networking state and publishes outputs.
  2. Database team references those outputs, manages their own state, and publishes database connection strings.
  3. Application teams reference both outputs in their service state.
  4. Each team's CI/CD pipeline only touches their state, reducing merge conflicts and plan times.

A sample GitOps structure:

hcl

infrastructure/
├── states/
│   ├── networking/
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── terraform.tfvars
│   ├── platform/
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── terraform.tfvars
│   ├── databases/
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── terraform.tfvars
│   └── applications/
│       ├── service-a/
│       ├── service-b/
│       └── service-c/
└── shared/
    └── variables.tf  # Shared locals and validation

Common mistakes

Over-splitting too early. If you split states when you only have 80 resources, you're adding operational complexity for minimal benefit. Wait until plans consistently exceed two minutes or your team structure genuinely requires isolation.

Creating circular dependencies between states. Service A's state imports from Service B's state, and Service B imports from Service A. This deadlocks your CI/CD. Map dependencies clearly upfront: networking → platform → databases → applications. Make it a DAG.

Forgetting to set up state locking. DynamoDB or a Terraform Cloud lock system is not optional when you split states across a team. Without locking, concurrent applies corrupt state.

Hardcoding values instead of using outputs. If your application state hardcodes the VPC ID instead of reading it from the networking state's output, you've created a maintenance nightmare. Always reference outputs.

Key takeaways

  • Split monolithic states once they exceed 200–300 resources or when plan times consistently exceed two minutes.
  • Organize splits along natural lifecycle boundaries (networking rarely changes) and team ownership lines (each team owns their slice).
  • Always share values between states via outputs and remote state data sources, never hardcoded values.
  • Use terraform moved and terraform import to migrate resources between states safely; test in staging first.
  • Establish clear ownership and dependency mapping: make your state topology a directed acyclic graph (DAG).
  • Set up state locking (DynamoDB + S3 backend, or Terraform Cloud) as a prerequisite to any multi-state setup.
  • Split only when the benefit outweighs the operational overhead; premature splitting adds unnecessary complexity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles