🏗️ 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 thebackend "s3" {}block. Backend configuration must use static strings or be injected duringterraform 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:
Approach A: Single Root Module with Separate .tfvars (Recommended for simple setups)
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"
Approach B: Directory-per-Environment (Recommended for strict isolation)
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
.tfvarsFiles: Never commit actual.tfvarsfiles containing account-specific or confidential parameters to GitHub. Commit aterraform.tfvars.exampletemplate with placeholder values instead.- State File Leakage: Local
.tfstatefiles store the unencrypted values of all declared resources and input variables. Always use a secure remote backend (e.g., encrypted S3) and keep*.tfstatein.gitignore.- Same-Directory Overlap: Terraform reads all
.tffiles in the working directory as one flattened global scope. Declaring the same resource name or variable across two different.tffiles in the same directory will cause duplicate declaration errors.
Thank you for reading. This article is part of the PaperAstro starter template.