Repository Structure & Team Ownership: Organizing Terraform at Scale

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.tf pointing to its own state file key
  • Produces outputs.tf values that other teams consume via terraform_remote_state
  • Is applied independently — cd infra-network && terraform apply

The ownership map:

DirectoryOwning TeamWhat They Manage
infra-network/Network EngineeringVPCs, subnets, route tables, NAT, VPN, peering, DNS
infra-security/Security / IAMIAM roles, policies, KMS keys, security groups, WAF
infra-compute/Platform / SREECS clusters, ASGs, launch templates, load balancers
infra-storage/Storage / DataS3 buckets, EFS, backup vaults
infra-database/DBA / DataRDS instances, ElastiCache, DynamoDB tables
modules/Platform EngineeringShared 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" }
}
ApproachBest For
terraform_remote_stateSame org, tight coordination, many outputs to share
Data sources + tagsCross-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.