🏗️ Terraform Project Structure & Best Practices

Published on


Terraform automatically merges and loads all *.tf files present in the execution root directory. Decomposing a monolithic main.tf into specialized, logically isolated files improves maintainability, simplifies code reviews, and prevents configuration drift.


1. Standard Root Module Layout

For a standard root module, organize files by their operational responsibility:

my-terraform-project/
├── backend.tf              # Remote state storage configuration (e.g., S3 + DynamoDB)
├── providers.tf            # Provider declarations, versions, and configurations
├── versions.tf             # Required Terraform engine and provider version constraints
├── variables.tf            # Input variable declarations and type constraints
├── locals.tf               # Local computed values and expression helpers
├── main.tf                 # Core resource definitions (VPC, EC2, S3, etc.)
├── outputs.tf              # Provisioned output exports (IDs, ARNs, endpoints)
├── terraform.tfvars.example# Template for environment-specific input values
├── .gitignore              # Ignores state, lock files, and secrets from VCS
└── README.md               # Architecture documentation and deployment usage

2. File-by-File Breakdown & Configurations

providers.tf & versions.tf

Declare provider credentials, regions, and pinned version constraints.

# providers.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

backend.tf

Configures remote state management and state locking mechanisms.

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "environments/dev/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-state-locks"
    encrypt        = true
  }
}

[!NOTE] Variables (var.*) cannot be used inside the backend "s3" {} block. Backend configuration must use static strings or be injected during terraform init -backend-config=....


variables.tf & locals.tf

Separate input parameters from computed intermediate calculations.

# variables.tf
variable "aws_region" {
  type        = string
  description = "AWS deployment region"
  default     = "us-east-1"
}

variable "environment" {
  type        = string
  description = "Environment identifier (dev/stage/prod)"
  default     = "dev"
}
# locals.tf
locals {
  name_prefix = "${var.environment}-app"
  
  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

main.tf & outputs.tf

Keep main.tf dedicated to resource definitions and wire outputs in outputs.tf.

# main.tf
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-vpc"
  })
}
# outputs.tf
output "vpc_id" {
  description = "ID of the provisioned VPC"
  value       = aws_vpc.main.id
}

3. Essential .gitignore Template

Prevent sensitive state data, local plugin caches, and secret credentials from leaking into Version Control Systems (VCS):

# Local .terraform working directory (plugins, modules)
.terraform/
.terraform.lock.hcl

# Local state files and state backups (contain plain-text secrets)
*.tfstate
*.tfstate.*
*.tfstate.backup

# Crash logs and debug output
crash.log
crash.*.log

# Actual variable definition files with secrets / custom env values
terraform.tfvars
terraform.tfvars.json
*.auto.tfvars
*.auto.tfvars.json

# Override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json

4. Multi-Environment Workflows

When scaling across environments (dev, stage, prod), choose between two primary directory layouts:

Reuse identical .tf files and pass distinct variable files per environment:

# Apply dev environment
terraform plan -var-file="environments/dev.tfvars"

# Apply prod environment
terraform plan -var-file="environments/prod.tfvars"

Isolate state files and configurations entirely into separate folders using shared modules:

environments/
├── dev/
│   ├── main.tf        # Invokes ../../modules/*
│   ├── backend.tf     # Dev state key
│   └── terraform.tfvars
└── prod/
    ├── main.tf        # Invokes ../../modules/*
    ├── backend.tf     # Prod state key
    └── terraform.tfvars
modules/
├── networking/
└── compute/

5. Common Gotchas

[!WARNING]

  • Committing .tfvars Files: Never commit actual .tfvars files containing account-specific or confidential parameters to GitHub. Commit a terraform.tfvars.example template with placeholder values instead.
  • State File Leakage: Local .tfstate files store the unencrypted values of all declared resources and input variables. Always use a secure remote backend (e.g., encrypted S3) and keep *.tfstate in .gitignore.
  • Same-Directory Overlap: Terraform reads all .tf files in the working directory as one flattened global scope. Declaring the same resource name or variable across two different .tf files in the same directory will cause duplicate declaration errors.

Thank you for reading. This article is part of the PaperAstro starter template.