One Giant Config? Please Don’t.
You don’t build a company’s infrastructure in a single Terraform directory. Not because Terraform can’t handle it — because people can’t. Different teams work on different layers. Networking engineers shouldn’t be reviewing compute changes. The database team shouldn’t be able to accidentally destroy a VPC because they ran terraform apply in the wrong directory.
The question isn’t whether to split your Terraform code — it’s how.
Monorepo vs. Polyrepo
Two schools of thought. Both work. The right choice depends on your organization,
Monorepo — One repository, many directories:
terraform-infra/
├── infra-network/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── backend.tf
├── infra-security/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── backend.tf
├── infra-compute/
│ └── ...
├── infra-storage/
│ └── ...
├── infra-database/
│ └── ...
└── modules/
├── vpc/
├── ecs-service/
└── rds-cluster/
Why it works:
- One clone, everything visible. You can read anyone’s config.
- Shared modules live alongside the consumers — easy to iterate.
- Single CI/CD config. One pipeline definition, path-based triggers.
- Need to see how the networking team set up that VPC peering? Open the file.
Where it hurts:
- Everyone can see (and potentially modify) everything.
- A CI/CD change affects all teams.
- One misconfigured pipeline step can accidentally plan against the wrong directory.
Polyrepo — One repository per team or domain:
terraform-network/ ← Network team owns this repo
terraform-security/ ← Security team owns this repo
terraform-compute/ ← Compute team owns this repo
terraform-storage/ ← Storage team owns this repo
terraform-modules/ ← Platform team owns this repo
Why it works:
- Hard repository-level access boundaries. Can’t push to a repo you don’t own.
- Each team controls their own CI/CD pipeline independently.
- Smaller repos, faster clones.
Where it hurts:
- Sharing modules requires publishing them (registry or Git tags).
- Cross-team visibility means navigating multiple repos.
- More CI/CD pipelines to maintain.
What I’ve seen in practice: most organizations start monorepo. As teams grow past 3-4, individual teams break out into their own repos. The modules/ directory almost always becomes its own repo publishing to a private module registry. It’s a natural evolution, not a failure of planning.
Regardless of which you pick: each team’s infrastructure MUST have its own state file. Never share a single state file across teams. State files are the true ownership boundary — whoever applies that state owns those resources.
The Directory-Per-Team Pattern
This is the most common structure I encounter in production. Each team owns a directory (in a monorepo) or repository (in a polyrepo). Each directory is a self-contained Terraform root module.
infra-network/
├── main.tf # VPCs, subnets, route tables, NAT gateways
├── variables.tf # region, environment, CIDR ranges
├── outputs.tf # vpc_id, subnet_ids, route_table_ids
├── backend.tf # S3 backend: s3://company-tf-state/network/prod/terraform.tfstate
├── providers.tf # AWS provider, version constraints
├── terraform.tfvars # Environment-specific values (or .auto.tfvars)
└── versions.tf # required_version, required_providers
Each team’s directory:
- Has its own
backend.tfpointing to its own state file key - Produces
outputs.tfvalues that other teams consume viaterraform_remote_state - Is applied independently —
cd infra-network && terraform apply
The ownership map:
| Directory | Owning Team | What They Manage |
|---|---|---|
infra-network/ | Network Engineering | VPCs, subnets, route tables, NAT, VPN, peering, DNS |
infra-security/ | Security / IAM | IAM roles, policies, KMS keys, security groups, WAF |
infra-compute/ | Platform / SRE | ECS clusters, ASGs, launch templates, load balancers |
infra-storage/ | Storage / Data | S3 buckets, EFS, backup vaults |
infra-database/ | DBA / Data | RDS instances, ElastiCache, DynamoDB tables |
modules/ | Platform Engineering | Shared reusable modules |
State File Boundaries — The Real Ownership Line
Code ownership matters. But state file boundaries are the real walls. If two teams share a state file, they can clobber each other’s resources. If each team has its own state file, they physically cannot.
# infra-network/backend.tf
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "network/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
# infra-compute/backend.tf
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "compute/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Same S3 bucket, different keys. Separate state files. Separate lock entries. The network team’s terraform apply never touches compute state. Ever.
Use a naming convention for state keys: <team>/<environment>/terraform.tfstate. When you list the S3 bucket, ownership is immediately obvious. You’ll thank yourself when debugging at midnight.
Cross-Team Data Sharing
Teams are isolated but not independent. The compute team needs the VPC ID from networking. The database team needs subnet IDs. How do they share data without sharing state?
Option 1: terraform_remote_state (covered in Article 25)
# In infra-compute/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "company-terraform-state"
key = "network/prod/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_ecs_service" "api" {
# ...
network_configuration {
subnets = data.terraform_remote_state.network.outputs.private_subnet_ids
}
}
Option 2: Data sources with tags (looser coupling)
# In infra-compute/main.tf
data "aws_vpc" "main" {
tags = { ManagedBy = "terraform-network", Environment = "prod" }
}
data "aws_subnets" "private" {
filter {
name = "vpc-id"
values = [data.aws_vpc.main.id]
}
tags = { Tier = "private" }
}
| Approach | Best For |
|---|---|
terraform_remote_state | Same org, tight coordination, many outputs to share |
| Data sources + tags | Cross-org, loose coupling, teams deploying independently |
If you use terraform_remote_state, the consuming team needs read access to the producing team’s state file. This means cross-team IAM permissions on the state bucket. Set this up early — it’s a common blocker during initial setup that nobody thinks about until the compute team’s first terraform plan fails with an access denied error.
CODEOWNERS: Who Approves What
In a monorepo, branch protection alone isn’t enough. You need file-level review enforcement.
# .github/CODEOWNERS (GitHub)
# Network team must approve all network changes
/infra-network/ @network-team
# Security team must approve IAM and security group changes
/infra-security/ @security-team
# Compute team owns their workloads
/infra-compute/ @platform-team
# Storage team owns buckets and data stores
/infra-storage/ @storage-team
# Module changes require platform engineering review
/modules/ @platform-engineering
# Any changes to CI/CD config require SRE approval
/.github/ @sre-team
/Makefile @sre-team
With branch protection rules requiring CODEOWNERS approval, a network engineer can’t merge compute changes without the compute team’s sign-off. A junior engineer can’t modify IAM policies without the security team reviewing.
In a polyrepo, CODEOWNERS is less critical because repo-level access handles the same problem. But even there, I use CODEOWNERS for sensitive paths — backend.tf changes always require a senior reviewer. One wrong character in a state key and you’re in for a bad day.
Environment Management Within a Team
Each team’s code needs to deploy to multiple environments: dev, staging, prod. Three patterns:
Pattern 1: Separate .tfvars files (most common)
infra-network/
├── main.tf
├── variables.tf
├── outputs.tf
├── backend.tf
├── envs/
│ ├── dev.tfvars
│ ├── staging.tfvars
│ └── prod.tfvars
# Deploy to dev
terraform workspace select dev
terraform apply -var-file=envs/dev.tfvars
# Deploy to prod
terraform workspace select prod
terraform apply -var-file=envs/prod.tfvars
Pattern 2: Separate directories per environment
infra-network/
├── dev/
│ ├── main.tf # calls the same module
│ ├── backend.tf # separate state
│ └── terraform.tfvars
├── staging/
│ └── ...
├── prod/
│ └── ...
└── modules/
└── network/ # shared module
Pattern 3: HCP Terraform workspaces per environment
Each environment is a separate HCP Terraform workspace with its own variables. Same VCS repo, different workspace configurations.
Pattern 1 for small teams. Pattern 2 for larger teams where environments diverge significantly. Pattern 3 if you’re on HCP Terraform. Simple as that.
Design Scenario: Four Teams, One Platform
Context: A company has four infrastructure teams: Networking, Security, Compute, and Data. Monorepo with separate directories. Here’s the setup.
Step 1: Create the state bucket (done once, by platform engineering)
# bootstrap/main.tf — Run once manually by a senior engineer
resource "aws_s3_bucket" "terraform_state" {
bucket = "acme-terraform-state"
lifecycle { prevent_destroy = true }
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Step 2: Each team creates their directory with their own backend config
# infra-network/backend.tf
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "network/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
# infra-security/backend.tf
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "security/prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Step 3: Network team produces outputs, compute team consumes them
# infra-network/outputs.tf
output "vpc_id" { value = aws_vpc.main.id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
# infra-compute/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-terraform-state"
key = "network/prod/terraform.tfstate"
region = "us-east-1"
}
}
Step 4: CODEOWNERS enforces review boundaries
/infra-network/ @acme/network-eng
/infra-security/ @acme/security-eng
/infra-compute/ @acme/platform-eng
/infra-database/ @acme/data-eng
/modules/ @acme/platform-eng
Result: Four teams, one repo, four independent state files, four independent deploy pipelines, one set of shared modules. Each team works in parallel without stepping on each other. I’ve set up this exact pattern at two companies. It works.
Key Takeaways
A mistake I see repeatedly: putting all environments (dev, staging, prod) in the same state file. This means terraform destroy in dev could wipe prod if someone targets the wrong workspace. Separate state per environment, always. No exceptions.
Shared modules in a monorepo are convenient but dangerous. If someone modifies a module that 4 teams depend on, all 4 break if the change is incompatible. Version your modules, even in a monorepo. Use Git tags and source from tags for production use, not relative paths.
The golden rule: one team, one state file, one apply. If two people can run terraform apply on the same state file concurrently, your boundaries are wrong. State locking prevents corruption, but it doesn’t prevent the confusion of two teams both claiming ownership of the same resources.

