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
FileFunctionContentsWhen to split
main.tfMain resourcesAll resources or entry point to modulesAlways present
variables.tfInput declarationsAll variable blocksAlways present
outputs.tfOutput declarationsAll output blocksAlways present
providers.tfProvider configurationterraform {} and provider {} blocksAlways present
backend.tfRemote statebackend {} inside terraform {}When using remote state
locals.tfLocal valuesAll locals {} blocksWhen there are many locals
data.tfData sourcesAll data blocksWhen there are many data sources
networking.tfNetwork componentsVPC, subnets, SG, routingWhen there are many resources
compute.tfCompute componentsEC2, ASG, LambdaWhen there are many resources
database.tfDatabase componentsRDS, ElastiCache, DynamoDBWhen there are many resources
iam.tfIAM componentsRoles, policies, attachmentsWhen 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:#2e7d32
my-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
FileCommit?Reason
*.tf✅ YesMain configuration, must be shared
.terraform.lock.hcl✅ YesEnsures consistent provider versions
*.tfvars (non-secret)✅ YesNon-sensitive variable values
.gitignore✅ YesExclude rules
terraform.tfstate❌ NoContains secrets and sensitive data
terraform.tfstate.backup❌ NoBackup of the state file
.terraform/❌ NoProvider binaries, can be regenerated
*.tfplan❌ NoCan contain sensitive values
*.tfvars (with secrets)❌ NoAPI keys, passwords, etc.
# .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 .gitignore excludes all *.tfvars, make sure there’s another mechanism for storing variable values — for example environment variables (TF_VAR_*), .tfvars stored in a secret manager, or a terraform.tfvars.example as 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/
PatternBest forProsCons
Single directorySmall projects, 1 personSimple, easy to understandNot scalable
Multi-environmentSmall teams, several envsIsolation per environmentConfiguration duplication
Per-layerLarge infrastructureClear dependencies, parallelizable workComplex for small teams
Per-teamLarge organizationsClear ownership, team independenceRequires cross-team coordination
Per-serviceMicroservicesHigh isolation, independent deploysMany 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.tf are better than one giant main.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.

← Previous: CLI   Next: Init →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact