Project Structure
Organize Terraform code for maintainable, scalable infrastructure
Project Structure
In the previous tutorial, we learned about modules ā reusable infrastructure components. Now let's talk about the big picture: how to organize your entire project.
Your Terraform project starts as one file. Then two. Then it's a mess of 47 resources in a single main.tf and you want to throw your laptop out the window. Let's talk about how to organize code so you don't hate yourself in six months.
Single File (Don't)
project/
āāā main.tf # 500 lines of everything
Works for learning. Doesn't work for teams or real projects. It's like keeping every document you own in one giant folder on your desktop.
Standard Layout
"What's the bare minimum organization?"
project/
āāā main.tf # Resources
āāā variables.tf # Input variables
āāā outputs.tf # Outputs
āāā providers.tf # Provider configuration
āāā versions.tf # Version constraints
āāā terraform.tfvars # Variable values
āāā data.tf # Data sources
This is the minimum. Split by file type. Already 10x better than one giant file.
Split by Resource Type
"What if I have a lot of resources?"
Group them by what they are:
project/
āāā providers.tf
āāā versions.tf
āāā variables.tf
āāā outputs.tf
āāā terraform.tfvars
āāā vpc.tf # VPC, subnets, routing
āāā security.tf # Security groups, NACLs
āāā compute.tf # EC2, ASG, Launch templates
āāā database.tf # RDS, ElastiCache
āāā storage.tf # S3 buckets
āāā iam.tf # IAM roles, policies
āāā dns.tf # Route53
Better. Related resources stay together. When you need to find the security group config, you know exactly where to look.
Environment Separation
"How do I manage dev, staging, and prod?"
This is the million-dollar question. Several approaches.
Directory Per Environment
terraform/
āāā modules/
ā āāā vpc/
ā āāā compute/
ā āāā database/
āāā environments/
ā āāā dev/
ā ā āāā main.tf
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā ā āāā providers.tf
ā ā āāā terraform.tfvars
ā āāā staging/
ā ā āāā main.tf
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā ā āāā providers.tf
ā ā āāā terraform.tfvars
ā āāā prod/
ā āāā main.tf
ā āāā variables.tf
ā āāā outputs.tf
ā āāā providers.tf
ā āāā terraform.tfvars
Each environment is isolated. Different state files. Can have different resources. No chance of accidentally destroying prod when you meant to destroy dev. Sleep well at night.
Shared Configuration
terraform/
āāā modules/
ā āāā ...
āāā environments/
ā āāā _shared/ # Shared config
ā ā āāā common.tf # Symlinked or copied
ā āāā dev/
ā āāā staging/
ā āāā prod/
Or use a module:
# environments/dev/main.tf
module "infrastructure" {
source = "../../modules/app-stack"
environment = "dev"
# ... env-specific values
}
tfvars Files
Per Environment
project/
āāā main.tf
āāā variables.tf
āāā outputs.tf
āāā terraform.tfvars # Defaults
āāā dev.tfvars # Dev overrides
āāā staging.tfvars # Staging overrides
āāā prod.tfvars # Prod overrides
Usage:
# Apply with specific tfvars
terraform apply -var-file="dev.tfvars"
terraform apply -var-file="prod.tfvars"
terraform.tfvars
# terraform.tfvars (auto-loaded)
project = "myapp"
region = "us-west-2"
Environment-Specific
# dev.tfvars
environment = "dev"
instance_type = "t2.micro"
min_size = 1
max_size = 2
enable_cdn = false
# prod.tfvars
environment = "prod"
instance_type = "t2.large"
min_size = 3
max_size = 10
enable_cdn = true
Real-World Project Structure
terraform/
āāā README.md
āāā .gitignore
āāā .terraform.lock.hcl # Provider lock file
ā
āāā modules/
ā āāā networking/
ā ā āāā vpc/
ā ā āāā security-groups/
ā ā āāā load-balancer/
ā āāā compute/
ā ā āāā ec2/
ā ā āāā ecs/
ā ā āāā lambda/
ā āāā data/
ā ā āāā rds/
ā ā āāā elasticache/
ā ā āāā s3/
ā āāā monitoring/
ā āāā cloudwatch/
ā āāā sns/
ā
āāā environments/
ā āāā dev/
ā ā āāā backend.tf
ā ā āāā main.tf
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā ā āāā terraform.tfvars
ā āāā staging/
ā ā āāā ...
ā āāā prod/
ā āāā ...
ā
āāā scripts/
āāā init.sh
āāā deploy.sh
Backend Configuration Per Environment
environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "dev/terraform.tfstate"
region = "us-west-2"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/terraform.tfstate" # Different key!
region = "us-west-2"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Configuration Files
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5"
}
}
}
providers.tf
provider "aws" {
region = var.region
default_tags {
tags = {
Environment = var.environment
Project = var.project
ManagedBy = "terraform"
}
}
}
DRY with modules
Don't Repeat Yourself
# environments/dev/main.tf
module "app" {
source = "../../modules/app-stack"
environment = "dev"
vpc_cidr = "10.0.0.0/16"
instance_type = "t2.micro"
instance_count = 1
}
# environments/prod/main.tf
module "app" {
source = "../../modules/app-stack"
environment = "prod"
vpc_cidr = "10.1.0.0/16"
instance_type = "t2.large"
instance_count = 3
}
Same module, different inputs. Environment differences are just variables. DRY as a bone.
Multi-Account Structure
"Our company has separate AWS accounts for each environment."
Smart! That's the best practice:
terraform/
āāā modules/
ā āāā ...
āāā accounts/
ā āāā shared-services/ # Logging, networking
ā ā āāā backend.tf
ā ā āāā main.tf
ā āāā development/
ā ā āāā backend.tf
ā ā āāā main.tf
ā āāā staging/
ā ā āāā backend.tf
ā ā āāā main.tf
ā āāā production/
ā āāā backend.tf
ā āāā main.tf
With assume role:
# accounts/production/providers.tf
provider "aws" {
region = "us-west-2"
assume_role {
role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformRole"
}
}
Monorepo vs Multi-Repo
"One big repo or many small ones?"
The eternal debate.
Monorepo
infrastructure/
āāā terraform/
ā āāā modules/
ā āāā environments/
āāā kubernetes/
āāā ansible/
āāā scripts/
Pros: Everything in one place, easier refactoring Cons: Large repo, everyone needs access
Multi-Repo
# terraform-modules (repo)
modules/
āāā vpc/
āāā ecs/
āāā rds/
# app-infrastructure (repo)
environments/
āāā dev/
āāā staging/
āāā prod/
Pros: Better access control, smaller repos Cons: Harder to coordinate changes
.gitignore
Don't forget this. Please. Committing state files is how secrets end up on GitHub.
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files, which are likely to contain sensitive data
*.tfvars
*.tfvars.json
# But allow example tfvars
!*.example.tfvars
# Ignore override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ignore CLI configuration files
.terraformrc
terraform.rc
Locals for Organization
"My variables and tags are scattered everywhere."
Locals are your best friend for keeping things organized.
Group Related Values
# locals.tf
locals {
# Naming
name_prefix = "${var.project}-${var.environment}"
# Tags applied to everything
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
Owner = var.owner
}
# Network configuration
network = {
vpc_cidr = "10.0.0.0/16"
azs = ["us-west-2a", "us-west-2b", "us-west-2c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
}
# Environment-specific config
env_config = {
dev = {
instance_type = "t2.micro"
min_size = 1
max_size = 2
}
staging = {
instance_type = "t2.small"
min_size = 2
max_size = 4
}
prod = {
instance_type = "t2.medium"
min_size = 3
max_size = 10
}
}
# Current environment config
config = local.env_config[var.environment]
}
Usage
resource "aws_instance" "web" {
instance_type = local.config.instance_type
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web"
})
}
File Naming Conventions
Consistency matters. Here's the industry standard:
| File | Purpose |
|---|---|
main.tf | Primary resources or module calls |
variables.tf | Input variable declarations |
outputs.tf | Output declarations |
providers.tf | Provider configuration |
versions.tf | Terraform and provider versions |
backend.tf | Backend configuration |
data.tf | Data sources |
locals.tf | Local values |
<resource-type>.tf | Resources grouped by type (e.g., vpc.tf, ec2.tf) |
Practical Example
my-saas-app/
āāā README.md
āāā .gitignore
ā
āāā modules/
ā āāā api/
ā ā āāā main.tf # ECS service, task definition
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā ā āāā iam.tf
ā āāā database/
ā ā āāā main.tf # RDS instance
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā āāā cdn/
ā āāā main.tf # CloudFront
ā āāā variables.tf
ā āāā outputs.tf
ā
āāā environments/
ā āāā prod/
ā āāā backend.tf
ā āāā providers.tf
ā āāā versions.tf
ā āāā main.tf # Module composition
ā āāā variables.tf
ā āāā outputs.tf
ā āāā terraform.tfvars
environments/prod/main.tf
module "networking" {
source = "../../modules/networking"
environment = var.environment
vpc_cidr = var.vpc_cidr
}
module "database" {
source = "../../modules/database"
environment = var.environment
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
instance_class = var.db_instance_class
}
module "api" {
source = "../../modules/api"
environment = var.environment
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
database_url = module.database.connection_string
desired_count = var.api_desired_count
}
module "cdn" {
source = "../../modules/cdn"
environment = var.environment
api_origin = module.api.lb_dns_name
domain_name = var.domain_name
certificate_arn = var.certificate_arn
}
What's Next?
Your projects are now organized like a professional. You learned:
- How to go from one-file chaos to structured sanity
- Environment separation strategies (directories, tfvars, modules)
- Multi-account patterns for enterprise setups
- File naming conventions everyone can follow
Now let's understand resource dependencies ā how Terraform knows what to create first, and what to do when it gets confused. Let's go!