Project Structure

Organize Terraform code for maintainable, scalable infrastructure

8 min read

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:

FilePurpose
main.tfPrimary resources or module calls
variables.tfInput variable declarations
outputs.tfOutput declarations
providers.tfProvider configuration
versions.tfTerraform and provider versions
backend.tfBackend configuration
data.tfData sources
locals.tfLocal values
<resource-type>.tfResources 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!