Directory Structure #
There’s no hard rule about how you should organize Terraform files — but there are widely accepted conventions proven to help projects stay maintainable as complexity grows. A good structure makes collaboration easier, onboarding faster, and debugging more focused. This section covers file naming conventions, structures for small to large projects, which files to commit, and anti-patterns to avoid.
File Naming Conventions #
Terraform reads all .tf files in a directory together — file order doesn’t matter, but consistent naming helps navigation. Think of this convention as a “table of contents” for your project.
flowchart LR
subgraph "Standard Files"
A["main.tf\nMain resources"]
B["variables.tf\nInput declarations"]
C["outputs.tf\nOutput declarations"]
D["providers.tf\nProvider config"]
end
subgraph "Optional Files"
E["versions.tf\nVersion constraints"]
F["backend.tf\nRemote state config"]
G["locals.tf\nLocal values"]
H["data.tf\nData sources"]
end
subgraph "Per-Component Files"
I["networking.tf\nVPC, subnet, routing"]
J["compute.tf\nEC2, ASG"]
K["database.tf\nRDS, ElastiCache"]
L["iam.tf\nRoles, policies"]
end
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#e3f2fd,stroke:#1565c0
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#e3f2fd,stroke:#1565c0
style I fill:#fff3e0,stroke:#e65100
style J fill:#fff3e0,stroke:#e65100
style K fill:#fff3e0,stroke:#e65100
style L fill:#fff3e0,stroke:#e65100| File | Function | Contents | When to split |
|---|---|---|---|
main.tf | Main resources | All resources or entry point to modules | Always present |
variables.tf | Input declarations | All variable blocks | Always present |
outputs.tf | Output declarations | All output blocks | Always present |
providers.tf | Provider configuration | terraform {} and provider {} blocks | Always present |
backend.tf | Remote state | backend {} inside terraform {} | When using remote state |
locals.tf | Local values | All locals {} blocks | When there are many locals |
data.tf | Data sources | All data blocks | When there are many data sources |
networking.tf | Network components | VPC, subnets, SG, routing | When there are many resources |
compute.tf | Compute components | EC2, ASG, Lambda | When there are many resources |
database.tf | Database components | RDS, ElastiCache, DynamoDB | When there are many resources |
iam.tf | IAM components | Roles, policies, attachments | When there are many resources |
Terraform doesn’t care what file names you use — all .tf files in one directory are merged into a single configuration. This naming convention exists purely to help humans navigate, not for Terraform.Small Project Structure #
For a project with one environment and not too many resources, a single directory is enough. Don’t over-engineer the structure for a simple project — start minimal, refactor as complexity grows.
my-infrastructure/
├── main.tf # Main resources (EC2, VPC, RDS, etc.)
├── variables.tf # Variable declarations
├── outputs.tf # Output values
├── providers.tf # Provider configuration
├── terraform.tfvars # Variable values (don't commit if they contain secrets)
├── backend.tf # Remote state config (optional)
├── .terraform.lock.hcl # Provider lock file (always commit)
└── .gitignore # Files that must not be committed
# providers.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# variables.tf
variable "aws_region" {
description = "AWS region for deploying resources"
type = string
default = "ap-southeast-1"
}
variable "environment" {
description = "Environment name (dev, staging, production)"
type = string
}
Multi-Environment Project Structure #
Projects with several environments (dev, staging, production) need a more organized structure. The most common pattern is separating each environment into its own directory, each with its own state.
flowchart TD
subgraph "Multi-Environment Structure"
ROOT["my-infrastructure/"]
MOD["modules/\nReusable components"]
MOD_VPC["modules/vpc/"]
MOD_WEB["modules/web-server/"]
ENV["environments/"]
DEV["environments/dev/\nConfig + Dev state"]
STG["environments/staging/\nConfig + Staging state"]
PRD["environments/production/\nConfig + Production state"]
end
ROOT --> MOD
ROOT --> ENV
MOD --> MOD_VPC
MOD --> MOD_WEB
ENV --> DEV
ENV --> STG
ENV --> PRD
DEV -->|"module "vpc" { source }"| MOD_VPC
STG -->|"module "vpc" { source }"| MOD_VPC
PRD -->|"module "vpc" { source }"| MOD_VPC
style DEV fill:#e3f2fd,stroke:#1565c0
style STG fill:#fff3e0,stroke:#e65100
style PRD fill:#ffebee,stroke:#c62828
style MOD fill:#e8f5e9,stroke:#2e7d32my-infrastructure/
├── modules/ # Reusable modules
│ ├── vpc/
│ │ ├── main.tf # VPC, subnet, routing resources
│ │ ├── variables.tf # Inputs for the VPC module
│ │ └── outputs.tf # Outputs from the VPC module
│ └── web-server/
│ ├── main.tf # EC2, SG, ALB resources
│ ├── variables.tf # Inputs for the web-server module
│ └── outputs.tf # Outputs from the web-server module
│
├── environments/
│ ├── dev/ # Development environment
│ │ ├── main.tf # Call modules with dev config
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── providers.tf
│ │ ├── terraform.tfvars # Variable values for dev
│ │ └── backend.tf # Remote state for dev
│ │
│ ├── staging/ # Staging environment
│ │ ├── main.tf
│ │ ├── terraform.tfvars # Variable values for staging
│ │ └── ...
│ │
│ └── production/ # Production environment
│ ├── main.tf
│ ├── terraform.tfvars # Variable values for production
│ └── ...
│
└── .terraform.lock.hcl
With this structure, each environment has its own state, and changes in dev don’t affect production. Each environment directory calls the same modules but with different configurations.
# environments/dev/main.tf — calling modules with dev config
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "dev"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}
# environments/production/main.tf — calling the same module
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = "10.1.0.0/16" # Different CIDR
environment = "production"
public_subnets = ["10.1.1.0/24", "10.1.2.0/24"]
}
Which Files to Commit and Which Not #
This is one of the things that confuses new developers the most. Getting it wrong can be fatal — committing the state file leaks every secret; not committing the lock file means everyone can end up with different provider versions.
flowchart TD
A["Files in a Terraform project"] --> B{"Should be committed?"}
B -->|"Yes"| C["*.tf — all configurations"]
B -->|"Yes"| D[".terraform.lock.hcl — provider lock"]
B -->|"Yes"| E["*.tfvars — if WITHOUT secrets"]
B -->|"Yes"| F[".gitignore — exclude rules"]
B -->|"No"| G["terraform.tfstate — contains secrets"]
B -->|"No"| H["terraform.tfstate.backup — state backup"]
B -->|"No"| I[".terraform/ — provider binary cache"]
B -->|"No"| J["*.tfplan — can contain sensitive values"]
B -->|"No"| K["*.tfvars — if they contain secrets"]
style C fill:#e8f5e9,stroke:#2e7d32
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#e8f5e9,stroke:#2e7d32
style F fill:#e8f5e9,stroke:#2e7d32
style G fill:#ffebee,stroke:#c62828
style H fill:#ffebee,stroke:#c62828
style I fill:#ffebee,stroke:#c62828
style J fill:#ffebee,stroke:#c62828
style K fill:#ffebee,stroke:#c62828| File | Commit? | Reason |
|---|---|---|
*.tf | ✅ Yes | Main configuration, must be shared |
.terraform.lock.hcl | ✅ Yes | Ensures consistent provider versions |
*.tfvars (non-secret) | ✅ Yes | Non-sensitive variable values |
.gitignore | ✅ Yes | Exclude rules |
terraform.tfstate | ❌ No | Contains secrets and sensitive data |
terraform.tfstate.backup | ❌ No | Backup of the state file |
.terraform/ | ❌ No | Provider binaries, can be regenerated |
*.tfplan | ❌ No | Can contain sensitive values |
*.tfvars (with secrets) | ❌ No | API keys, passwords, etc. |
Recommended .gitignore
#
# .gitignore for a Terraform project
# Local .terraform directories
**/.terraform/*
# .tfstate files — contain sensitive data
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files that might contain sensitive data
*.tfvars
*.tfvars.json
# Override files (local customizations)
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Saved plan files
*.tfplan
# The lock file is NOT excluded — it must be committed
!.terraform.lock.hcl
# If you have non-secret .tfvars you want to commit
!example.tfvars
If your.gitignoreexcludes all*.tfvars, make sure there’s another mechanism for storing variable values — for example environment variables (TF_VAR_*),.tfvarsstored in a secret manager, or aterraform.tfvars.exampleas a template without real values.
Advanced Structure Patterns #
As projects grow, more specific structures can help manage complexity.
Layer-Based Separation #
infrastructure/
├── layers/
│ ├── networking/ # VPC, subnets, routing — applied first
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── backend.tf
│ │
│ ├── security/ # IAM, KMS, Security Groups
│ │ ├── main.tf
│ │ └── ...
│ │
│ ├── compute/ # EC2, ECS, Lambda
│ │ ├── main.tf
│ │ └── ...
│ │
│ └── data/ # RDS, ElastiCache, S3
│ ├── main.tf
│ └── ...
Team-Based Separation #
infrastructure/
├── platform/ # The platform team manages this
│ ├── vpc/
│ ├── eks-cluster/
│ └── shared-services/
│
├── application-a/ # Team A manages this
│ ├── rds/
│ ├── s3/
│ └── lambda/
│
└── application-b/ # Team B manages this
├── ecs/
└── cloudfront/
Service-Based Separation #
infrastructure/
├── services/
│ ├── api-gateway/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── user-service/
│ │ ├── main.tf
│ │ └── ...
│ ├── payment-service/
│ │ ├── main.tf
│ │ └── ...
│ └── notification-service/
│ ├── main.tf
│ └── ...
│
└── shared/
├── vpc/
├── dns/
└── monitoring/
| Pattern | Best for | Pros | Cons |
|---|---|---|---|
| Single directory | Small projects, 1 person | Simple, easy to understand | Not scalable |
| Multi-environment | Small teams, several envs | Isolation per environment | Configuration duplication |
| Per-layer | Large infrastructure | Clear dependencies, parallelizable work | Complex for small teams |
| Per-team | Large organizations | Clear ownership, team independence | Requires cross-team coordination |
| Per-service | Microservices | High isolation, independent deploys | Many directories and states |
Anti-Patterns to Avoid #
ANTI-PATTERN 1: Everything in one giant file
─────────────────────────────────────────────
# main.tf with 1000+ lines covering VPC, EC2, RDS, IAM, etc.
# → Hard to read, hard to maintain, frequent merge conflicts
# CORRECT: Split by logical grouping
# networking.tf — VPC, subnet, routing
# compute.tf — EC2, auto scaling
# database.tf — RDS, ElastiCache
# iam.tf — Roles, policies
ANTI-PATTERN 2: Hardcoding values inside resources
──────────────────────────────────────────────────
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890" # ✗ hardcoded
instance_type = "t3.micro" # ✗ hardcoded
}
# CORRECT: Use variables
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
}
ANTI-PATTERN 3: No structure for multi-environment
──────────────────────────────────────────────────
# All environments in one directory, distinguished only by variables
# → State gets mixed together, dangerous
# CORRECT: Separate directory per environment
# environments/dev/, environments/staging/, environments/production/
# Each with its own state
ANTI-PATTERN 4: Modules in the same directory as the root config
────────────────────────────────────────────────────────────────
# .
# ├── main.tf # Root config
# ├── variables.tf
# └── my-module/ # ✗ Module in the same root
# ├── main.tf
# CORRECT: Modules in a separate directory
# .
# ├── modules/
# │ └── my-module/
# ├── environments/
# │ └── dev/
# │ └── main.tf # module "x" { source = "../../modules/my-module" }
Summary #
- File conventions:
main.tf,variables.tf,outputs.tf,providers.tf— consistent names make navigation easier for the whole team.- Split by logical grouping for large files —
networking.tf,compute.tf,database.tfare better than one giantmain.tf.- Multi-environment = separate directories — don’t use a single directory for all environments, state will get mixed.
- Commit
.terraform.lock.hcl, don’t commit.terraform/or*.tfstate.- Be careful with
.tfvars— if they contain secrets, don’t commit them. Use environment variables or a secrets manager.- Modules in a
modules/directory for configuration reused across environments.- Choose a structure pattern based on project scale — single directory for small, multi-environment for small teams, per-layer/per-team for large organizations.
- Start simple, refactor as complexity grows — don’t over-engineer at the beginning.