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

Structuring a Terraform repository: a step-by-step guide

Layouts that scale with teams.

Expona Admin9 min read

Introduction

After running Terraform in production long enough, you'll notice that a flat directory structure stops working the moment your team scales beyond a single engineer or your infrastructure spans more than a handful of resources. Structuring a Terraform repository: a step-by-step guide is one of those foundational decisions that either saves your team months of refactoring pain or costs you exactly that when you skip it. We've seen teams push through with monolithic configurations only to hit merge conflicts, state lock timeouts, and unclear ownership—all preventable with a thoughtful repository layout.

The challenge isn't just organizing files; it's creating a structure that lets teams work in parallel, keeps blast radius small when things go wrong, and makes onboarding straightforward. This guide walks through a battle-tested layout that works across small teams and scales to hundreds of engineers managing infrastructure across multiple cloud regions and environments.

The core principle: separate by concern, not by cloud provider

Before diving into folder structure, the mental model matters. The wrong instinct is to organize by AWS account, GCP project, or Azure subscription. That creates friction when a single logical service needs resources across multiple clouds or when teams own components that span infrastructure layers. Instead, organize around three axes: the infrastructure abstraction (modules), the deployment target (live configurations), and the ownership boundary (who can change what).

Think of modules as your internal SDK. The live folder is where you consume that SDK for each specific environment and component. Separate Terraform states act as the isolation mechanism that prevents one team's deletion from taking down another's infrastructure.

Modules folder — Reusable modules with versions

A modules folder contains the building blocks: VPC, security groups, databases, Kubernetes clusters, Lambda functions, anything you'll provision more than once or want to standardize across environments. Each module is self-contained, with its own variables, outputs, and documentation.

hcl
modules/
├── vpc/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── README.md
├── rds_postgres/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── versions.tf
├── eks_cluster/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── versions.tf
└── security_group/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf

The rule we follow: modules should have no hard-coded values. Everything is a variable. A VPC module doesn't decide CIDR blocks; the caller does. This forces module authors to think about flexibility upfront and makes modules genuinely reusable across staging, production, and even different products.

Versioning matters for modules, especially in larger teams. We use semantic versioning with git tags:

hcl
module "vpc" {
  source = "git::https://github.com/our-org/terraform-modules.git//vpc?ref=v2.1.0"

  cidr_block = var.vpc_cidr
  name       = var.environment
}

This way, if someone refactors the VPC module, existing configurations stay pinned to a known version until the team consciously upgrades. Without versioning, a module change silently breaks production when someone runs terraform apply against an older live configuration.

For teams just starting out, modules can live in the same repository as live configurations. Use a directory marker to distinguish them clearly. As you grow, consider moving modules to a separate repository—the decoupling is worth the extra git clone.

Live folder — Root configurations per environment and component

The live folder contains the actual Terraform root modules you apply to your cloud accounts. Organize it by environment first, then by component within that environment:

hcl
live/
├── staging/
│   ├── us-east-1/
│   │   ├── vpc/
│   │   │   ├── main.tf
│   │   │   ├── terraform.tfvars
│   │   │   └── backend.tf
│   │   ├── rds/
│   │   │   ├── main.tf
│   │   │   ├── terraform.tfvars
│   │   │   └── backend.tf
│   │   └── eks/
│   │       ├── main.tf
│   │       ├── terraform.tfvars
│   │       └── backend.tf
├── production/
│   ├── us-east-1/
│   ├── eu-west-1/
│   └── ap-southeast-1/
└── _global/
    ├── iam/
    └── dns/

Why split by region? Because it maps to your deployment topology. Why split by component? So the network team can work on VPCs without waiting for the database team to finish their schema refactoring. Each component gets its own state file (more on this below).

A typical live component looks minimal:

hcl
// live/production/us-east-1/vpc/main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

module "vpc" {
  source = "git::https://github.com/our-org/terraform-modules.git//vpc?ref=v2.1.0"

  cidr_block = var.vpc_cidr
  name       = "production-vpc"
  tags       = var.tags
}

And a terraform.tfvars file captures environment-specific values:

hcl
// live/production/us-east-1/vpc/terraform.tfvars
vpc_cidr = "10.0.0.0/16"
tags = {
  Environment = "production"
  Team        = "platform"
  Terraform   = "true"
}

This structure makes it obvious: you can run terraform plan in staging/us-east-1/vpc without touching production. Developers onboarding know exactly where to look for the network configuration for a given environment and region.

Separate states — Network, data and services in different states

One monolithic state file is a ticking bomb. If someone accidentally runs terraform destroy in the wrong directory, or if a resource takes longer than expected to provision and a CI pipeline times out mid-apply, you're rebuilding production from scratch. Splitting state by logical concern—network, databases, applications—limits the blast radius.

Each component gets its own backend configuration:

hcl
// live/production/us-east-1/vpc/backend.tf
terraform {
  backend "s3" {
    bucket         = "our-terraform-state-prod"
    key            = "us-east-1/vpc/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

The dynamodb_table reference enables state locking, which prevents two engineers from running apply simultaneously on the same component. Without locking, concurrent operations can corrupt state.

Here's a practical scenario: the platform team is refactoring the VPC module while the application team deploys a new EKS cluster. With separate states, these happen in parallel. The EKS apply doesn't stall waiting for VPC. If the VPC refactoring fails, it only rolls back the VPC—the EKS cluster remains untouched.

How fine-grained should you go? The rule: if two teams might want to deploy at the same time, they need different states. A network team and a database team? Different states. A database team and an application team? Probably different states unless they're tightly coordinated. Within a single component like "all RDS instances in one region," one state is fine.

Be cautious about outputs though. When you split state, a component in one state might need to reference a resource in another state. Use terraform_remote_state to read outputs from other states:

hcl
// live/production/us-east-1/eks/main.tf
data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "our-terraform-state-prod"
    key    = "us-east-1/vpc/terraform.tfstate"
    region = "us-east-1"
  }
}

module "eks" {
  source = "git::https://github.com/our-org/terraform-modules.git//eks?ref=v1.2.0"

  vpc_id             = data.terraform_remote_state.vpc.outputs.vpc_id
  subnet_ids         = data.terraform_remote_state.vpc.outputs.private_subnet_ids
}

This creates a dependency chain: EKS depends on VPC state. It's explicit, visible in code, and testable. Just don't create circular dependencies—if VPC tries to read EKS outputs and EKS reads VPC outputs, you're stuck.

Ownership — CODEOWNERS for sensitive folders

Repository-level access control isn't granular enough. You need code-level ownership. Create a CODEOWNERS file at your repository root:

hcl
// .github/CODEOWNERS
# Network infrastructure
live/*/*/vpc/                    @platform-team
live/*/*/security-group/         @platform-team
modules/vpc/                     @platform-team

# Databases
live/*/*/rds/                    @data-team
live/*/*/elasticache/            @data-team
modules/rds_*                    @data-team

# IAM and access control (most sensitive)
live/_global/iam/                @security-team
modules/iam_*                    @security-team

# Kubernetes
live/*/*/eks/                    @platform-team
modules/eks_*                    @platform-team

Most Git platforms (GitHub, GitLab, Bitbucket) enforce CODEOWNERS during pull requests. A change to IAM can't merge without approval from the security team, even if someone has push access to main. This scales with your organization: each team owns their domain and can iterate independently while maintaining guardrails.

Pair CODEOWNERS with branch protection rules that require pull request reviews and passing CI checks before merging. The combination ensures that infrastructure changes are auditable and deliberate.

Directory structure template

Here's a complete starting template for a mid-sized team:

hcl
terraform-repo/
├── README.md
├── .gitignore
├── .github/
│   └── CODEOWNERS
├── modules/
│   ├── vpc/
│   ├── security_group/
│   ├── rds_postgres/
│   ├── eks_cluster/
│   └── lambda_function/
├── live/
│   ├── staging/
│   │   ├── us-east-1/
│   │   │   ├── vpc/
│   │   │   ├── rds/
│   │   │   └── eks/
│   │   └── eu-west-1/
│   ├── production/
│   │   ├── us-east-1/
│   │   ├── eu-west-1/
│   │   └── ap-southeast-1/
│   └── _global/
│       ├── iam/
│       └── route53/
├── tests/
│   ├── modules_test.go
│   └── integration_test.go
└── docs/
    ├── architecture.md
    └── runbook.md

The tests and docs folders are easy to overlook. We recommend adding terratest for integration testing (especially for modules) and documenting your infrastructure decisions. Future you will be grateful.

Common mistakes

  • Mixing variable definitions and hard-coded values. If production needs different values than staging, use terraform.tfvars or environment variables. Hard-coding "prod-db-instance-5xl" in main.tf means you're editing code to deploy to different environments. That's a sign of poor structure.
  • One state file to rule them all. It's simpler until it isn't. A single state file sharing the network, databases, and applications means any terraform apply touches everything. It's also a merge conflict nightmare for large teams.
  • Modules that are too opinionated. A module shouldn't force decisions. If your VPC module hardcodes "public and private subnets in 3 AZs," it won't work for teams who need a simpler topology. Expose everything as variables and document the defaults.
  • Forgetting to version modules.strong> Without pinned versions, a module refactor silently changes all consumers. Always use git tags or Terraform module registry versions. This is especially important as teams grow and module changes happen asynchronously.
  • No documentation in CODEOWNERS. If the data team sees live/*/*/rds/ @data-team with no context, they might not understand why. Add comments explaining what each team owns and why.
  • Assuming terraform_remote_state will scale forever. It works well for modest dependency chains (A depends on B depends on C). Beyond three levels, consider a data source or API layer. The complexity of managing cross-stack dependencies grows quickly.

Key takeaways

  • Organize by abstraction layer (modules) and deployment target (live), not by cloud provider or account. This forces thinking about reusability early.
  • Split state by ownership boundary and deployment team, not by resource type. Network and databases in different states let teams deploy independently.
  • Version your modules using git tags or registry versions. Unversioned modules are a silent breaking change waiting to happen.
  • Use CODEOWNERS to enforce team ownership at the code level. Pair it with branch protection and CI checks for audit trails on infrastructure changes.
  • Start simple—modules, live by environment and region, one state per component—then refine as your team and infrastructure grow. Premature complexity creates its own friction.
  • Document why decisions were made, especially in README files for modules and architecture decisions in your docs folder. Structure means nothing if only the original author understands it.
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