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.
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:
terraform apply is actually run.Recommended Folder Layout
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:
| File | Purpose |
|---|---|
| <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
EnvironmentandManagedBy = "Terraform". - Pin provider versions in
providers.tfso a teammate's run matches yours. - Keep a
.gitignorewith.tfstate,.tfstate.,.terraform/, and.tfvars(if they hold secrets). - Run
terraform fmtandterraform validatein 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.