Skip to main content
Terraform·7 min read

Terraform Project Structure — How to Organize Code for Real Teams

A practical guide to structuring Terraform projects: file layout, modules, environments, remote state, and naming conventions that scale from a single bucket to multi-team infrastructure.

DT

DevOps Engineer & Technical Writer

The Problem

Most Terraform projects start as a single main.tf and grow into a 2,000-line file that nobody wants to touch. Then someone copies the whole folder to make a "staging" environment, the two drift apart, and a change meant for dev gets applied to prod. Bad structure is the root cause of most Terraform pain — not the language.

TERRAFORM PROJECT LAYOUT — ROOT, MODULES, ENVIRONMENTS REUSABLE MODULES modules/network modules/compute modules/database write once, reuse everywhere ENVIRONMENTS (each with its own state) envs/dev main.tf dev.tfvars envs/staging main.tf staging.tfvars envs/prod main.tf prod.tfvars Each environment calls the same modules with different inputs dev state · staging state · prod state isolated — a dev apply can never touch prod

A good structure gives you two things: reuse (write a module once, use it in every environment) and isolation (each environment has its own state, so a mistake in dev can never damage prod).

The Three-Layer Mental Model

Think of Terraform code in three layers:

  • Modules — reusable building blocks (a VPC, an EC2 cluster, an RDS database). They take inputs and produce outputs. They never contain environment-specific values.
  • Environments (root modules) — thin wrappers that call modules with concrete values for dev, staging, and prod. This is where terraform apply is actually run.
  • State — one isolated state file per environment, stored remotely.
  • Here is a layout that scales from a solo project to a large team:

    terraform/
    

    ├── modules/ # reusable, environment-agnostic building blocks

    │ ├── network/

    │ │ ├── main.tf

    │ │ ├── variables.tf

    │ │ └── outputs.tf

    │ ├── compute/

    │ │ ├── main.tf

    │ │ ├── variables.tf

    │ │ └── outputs.tf

    │ └── database/

    │ ├── main.tf

    │ ├── variables.tf

    │ └── outputs.tf

    └── environments/ # one folder per environment = one state

    ├── dev/

    │ ├── main.tf # calls the modules

    │ ├── backend.tf # remote state config for dev

    │ ├── variables.tf

    │ └── terraform.tfvars # dev values

    ├── staging/

    │ └── ... (same files, staging values)

    └── prod/

    └── ... (same files, prod values)

    The golden rule: modules are generic, environments are specific. No hard-coded account IDs, regions, or sizes in a module — those come in as variables.

    What Goes in Each File

    A long-standing convention splits configuration by purpose so files stay predictable:

    FilePurpose
    <code class="inline-code">main.tf</code>The resources (or module calls)
    <code class="inline-code">variables.tf</code>Input variable declarations
    <code class="inline-code">outputs.tf</code>Values to expose to callers or other code
    <code class="inline-code">providers.tf</code>Provider and version constraints
    <code class="inline-code">backend.tf</code>Remote state backend config
    <code class="inline-code">terraform.tfvars</code>Actual values (often git-ignored for secrets)

    Terraform reads all .tf files in a directory together, so this split is purely for humans — it has no effect on behavior.

    A Reusable Module

    A module is just a folder of .tf files with inputs and outputs. Here is a minimal network module:

    # modules/network/variables.tf
    

    variable "cidr_block" {

    type = string

    description = "CIDR for the VPC"

    }

    variable "environment" {

    type = string

    description = "Environment name, used in tags"

    }

    # modules/network/main.tf
    

    resource "aws_vpc" "this" {

    cidr_block = var.cidr_block

    tags = {

    Name = "${var.environment}-vpc"

    Environment = var.environment

    ManagedBy = "Terraform"

    }

    }

    # modules/network/outputs.tf
    

    output "vpc_id" {

    value = aws_vpc.this.id

    }

    Calling the Module from an Environment

    The environment's main.tf wires modules together with concrete values:

    # environments/dev/main.tf
    

    module "network" {

    source = "../../modules/network"

    cidr_block = "10.0.0.0/16"

    environment = "dev"

    }

    module "compute" {

    source = "../../modules/compute"

    vpc_id = module.network.vpc_id # pass one module's output to another

    environment = "dev"

    instance_count = 1

    }

    For prod, the same main.tf lives in environments/prod/ with a bigger instance_count and a different CIDR. The modules do not change — only the inputs do.

    Isolate State Per Environment

    Each environment points to its own remote state key so a terraform apply in dev cannot touch prod:

    # environments/dev/backend.tf
    

    terraform {

    backend "s3" {

    bucket = "mycompany-tf-state"

    key = "dev/terraform.tfstate" # unique key per environment

    region = "ap-south-1"

    }

    }

    # environments/prod/backend.tf
    

    terraform {

    backend "s3" {

    bucket = "mycompany-tf-state"

    key = "prod/terraform.tfstate" # different key = different state

    region = "ap-south-1"

    }

    }

    This separation is the single most important safety property of a Terraform repo.

    Naming and Hygiene Conventions

    • Use lowercase, hyphen-free resource local names that describe the role (this, web, primary) — not the resource type.
    • Tag everything with at least Environment and ManagedBy = "Terraform".
    • Pin provider versions in providers.tf so a teammate's run matches yours.
    • Keep a .gitignore with .tfstate, .tfstate., .terraform/, and .tfvars (if they hold secrets).
    • Run terraform fmt and terraform validate in CI on every pull request.

    Common Mistakes to Avoid

    • Copy-pasting a whole environment folder. The copies drift. Use modules + different inputs instead.
    • Hard-coding values inside modules. That breaks reuse. Everything environment-specific is a variable.
    • One giant state file for everything. A blast radius that includes prod is a disaster waiting to happen. Split state per environment.
    • Committing state or secrets. State can contain sensitive values; never put it in Git.
    • Over-modularizing on day one. Start with a module only when you need the same thing twice. Premature abstraction is its own mess.

    FAQ

    What is a Terraform module?

    A module is a reusable folder of Terraform configuration with defined input variables and outputs. You call it from other configurations with the module block, passing different inputs each time. The root directory where you run terraform apply is itself a module (the "root module").

    Should I use one state file or many?

    Use one state file per environment (and often per major component). Isolated state limits the blast radius of mistakes, speeds up plans, and lets teams work in parallel without lock contention.

    Where should environment-specific values live?

    In the environment's terraform.tfvars and variable defaults — never inside shared modules. Modules stay generic; environments supply the specifics.

    Do I need a separate folder per environment?

    It is the clearest approach for beginners and small-to-medium teams. Larger teams sometimes use Terraform workspaces or tools like Terragrunt, but per-environment folders with isolated remote state are the easiest to reason about and the hardest to get dangerously wrong.