Terraform Project Structure for AWS: Best Practices and Examples
Terraform project structure is how you organise your .tf files, folders, modules, and environment configurations so your infrastructure code stays readable, safe to change, and easy to work on with a team. If you are new to Terraform on AWS, start there first. This page assumes you can already write and run basic Terraform configurations and want to move from a single file to something that works for a real team.
By the end you will understand what to put in each file, why environments need separate state, when to extract a reusable module, what to avoid when working in a team, and how this structure connects to CI/CD pipelines and secret management.
The patterns here are used by AWS teams managing anything from a handful of services to dozens of environments. The core structure scales: three EC2 instances or a full multi-account setup follow the same principles.
What Terraform project structure means in plain English
When you write Terraform, you create files that describe AWS resources. By default, you could put everything in a single main.tf. That works for ten minutes of learning. It breaks down as soon as you add more resources, a second team member, or a second environment like staging.
Project structure answers: where does each piece of Terraform code live, and why? It covers five things:
- Files — splitting code by purpose: resources in
main.tf, variables invariables.tf, outputs inoutputs.tf - Folders — grouping related environments (
environments/dev/,environments/prod/) and reusable patterns (modules/vpc/) - Modules — reusable blocks of Terraform config that work like functions: pass in inputs, the module creates resources, get outputs back
- Environments — separate copies of your configuration for dev, staging, and prod, each with their own variable values and isolated state file
- State — the file Terraform uses to track what it has built. Each environment needs its own state stored remotely so the whole team shares one source of truth
Terraform reads all .tf files in a directory together and merges them before execution. Splitting across files is for human readability, not a technical requirement. The conventions below are team conventions, not Terraform rules. Breaking them costs you clarity and safety.
Why project structure matters in AWS teams
A single flat file is fine for learning. It becomes a liability the moment real infrastructure is at stake. Here is what breaks without good structure:
- Maintainability. When every resource type has a predictable home (
networking.tffor VPC config,compute.tffor EC2), any team member can find and change the right file without reading everything else. - Collaboration. Multiple people working on the same flat
main.tfcause merge conflicts constantly. Splitting by concern reduces overlap. - Reviewability. A pull request that only touches
modules/rds/is much easier to review than one that changes a monolithic file containing all infrastructure. - Safer changes. Running
terraform planfromenvironments/dev/can only affect dev resources. There is no mechanism for accidentally touching prod. - Environment isolation. Separate directories with separate state files mean dev, staging, and production are completely independent. A botched dev deployment cannot corrupt prod state.
None of this requires a complex setup. The standard layout is four files in a flat directory for simple projects, and a two-level folder structure for anything used in production.
Recommended Terraform folder structure for AWS
This is the layout used by most AWS teams managing more than one environment. It is a copy-ready starting point: every file listed has a defined job.
my-aws-project/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── versions.tf
│ ├── ecs-service/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── versions.tf
│ └── rds/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── versions.tf
└── environments/
├── dev/
│ ├── main.tf # Module calls and root resources
│ ├── variables.tf # Input variable declarations
│ ├── outputs.tf # Values to expose after apply
│ ├── versions.tf # required_version + required_providers
│ ├── providers.tf # AWS provider config + default_tags
│ ├── data.tf # Data source lookups
│ ├── locals.tf # Computed/derived values
│ ├── backend.tf # Dev-specific S3 backend config
│ └── terraform.tfvars # Dev-specific variable values
├── staging/
│ └── ... # Same structure as dev
└── prod/
└── ... # Same structure as devThe modules/ directory contains reusable infrastructure patterns that are never run directly, only called from environments. The environments/ directories are the root configurations your team actually runs. Each environment is completely self-contained: its own state, its own variable values, its own backend pointing to a different S3 key.
What belongs in each file
main.tf — resource blocks and module calls. For larger environments, split by concern: networking.tf, compute.tf, database.tf. Consistency within the project matters more than any specific naming.
variables.tf — variable blocks with descriptions and types. Leave sensitive variables without defaults so Terraform requires them explicitly rather than silently using a wrong placeholder.
outputs.tf — output blocks exposing values after apply. Useful for load balancer DNS names, subnet IDs, and any value another Terraform configuration might read via remote state.
versions.tf — the terraform {} block with required_version and required_providers. Version pinning lives here, separate from runtime configuration.
providers.tf — the provider {} block with region and default_tags. Keeping this separate from versions.tf means you can scan version constraints at a glance without reading through configuration details. If you are managing IAM with Terraform, provider aliases and assumed-role ARNs live here too.
data.tf — data source lookups for existing AWS resources: the latest Amazon Linux AMI, a shared VPC, a certificate from ACM. Isolating data sources makes the difference between “create” and “look up” explicit.
locals.tf — computed values derived from variables or data sources. Build a name prefix once and reference it everywhere:
# locals.tf
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}backend.tf — the backend “s3” {} block. Each environment has a different key so state files are isolated. See Terraform State Management in AWS for the full backend setup with DynamoDB locking.
terraform.tfvars — actual variable values for this environment: instance sizes, replica counts, image tags. Add *.tfvars to .gitignore if any values are sensitive. Never commit database passwords or API keys here.
Small project vs team project vs production project
Not every project needs the full layout. Here is a practical guide to when each layer of structure is worth introducing:
| Project type | Folder layout | Modules? | State | When to use |
|---|---|---|---|---|
| Solo learning | Single flat directory | No | Local | First steps, throwaway experiments |
| Small team, one env | Flat + standard files | Optional | Remote S3 | One or two people, single environment |
| Multi-environment | environments/dev/ + environments/prod/ | Optional | Remote S3 per env | Any project reaching real users |
| Production team | modules/ + environments/ | Yes | Remote S3 per env + DynamoDB lock | Multiple engineers, repeated patterns, CI/CD |
The most common mistake is jumping straight to a fully modular layout before you understand your infrastructure patterns. Build flat first. Extract to modules when you genuinely copy-paste a resource block more than once. Introduce environment directories when you add a second environment, not in anticipation of one.
A premature module that encapsulates one resource behind twenty variables is harder to understand than just writing the resource directly. Structure should follow the project, not lead it.
How Terraform project structure works
Environment directories and state isolation
Each environment directory is an independent Terraform root configuration. It calls shared modules with environment-specific inputs and has its own backend.tf pointing to a separate S3 key. A plan or apply run from environments/dev/ reads and writes only the dev state file. It is physically impossible for it to affect prod.
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}Environment-specific values
Each environment has its own terraform.tfvars. Dev uses small, cost-effective instances. Prod uses production-grade sizes with deletion protection enabled. The module code does not change; only the values differ.
# environments/dev/terraform.tfvars
environment = "dev"
instance_type = "t3.micro"
db_instance_class = "db.t3.micro"
min_capacity = 1
max_capacity = 2
enable_deletion_protection = false# environments/prod/terraform.tfvars
environment = "prod"
instance_type = "m5.large"
db_instance_class = "db.r6g.xlarge"
min_capacity = 3
max_capacity = 10
enable_deletion_protection = trueReusable modules
A module is like a recipe
The recipe stays the same whether you are cooking for two or twenty. The kitchen (environment) controls the quantities. Dev passes in small sizes; prod passes in large ones. When you improve the recipe, every kitchen benefits on the next cook.
Modules in modules/ define infrastructure patterns without environment-specific values. They describe how an ECS service is structured, how a VPC is laid out, how an RDS instance is configured. Every environment calls the same module with different inputs. If you need to add a CloudWatch alarm to every ECS service, you update the module once and every environment gets the change on the next apply.
# environments/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
project = local.name_prefix
environment = var.environment
cidr_block = "10.0.0.0/16"
}
module "api_service" {
source = "../../modules/ecs-service"
service_name = "${local.name_prefix}-api"
container_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest"
cpu = 256
memory = 512
desired_count = var.min_capacity
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
module "worker_service" {
source = "../../modules/ecs-service"
service_name = "${local.name_prefix}-worker"
container_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/worker:latest"
cpu = 512
memory = 1024
desired_count = 1
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}Both services call the same module with different inputs. When the module changes, both services get the update. This is why you understand what should and should not differ between dev, staging, and production environments before deciding what goes in modules vs tfvars.
Default tags
Set default_tags in providers.tf so every AWS resource is tagged automatically, with no per-resource tags block required:
# environments/dev/providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}Data sources for existing resources
Not everything needs to be created by Terraform. A shared VPC managed by another team, or the latest Amazon Linux 2023 AMI, can be referenced using data sources. Data sources fetch information from AWS without creating anything:
# data.tf
data "aws_vpc" "shared" {
tags = {
Name = "shared-vpc-prod"
}
}
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}Separate directories vs Terraform workspaces
Terraform workspaces let you maintain multiple state files from a single directory by switching between named workspaces (terraform workspace select prod). They look simpler at first. The problem: if you forget to switch workspace before running a plan or apply, you operate on whatever workspace is currently active. That might be prod. There is no warning.
With separate directories, the environment is encoded in the working directory. Running terraform apply from environments/dev/ cannot affect prod because the backend and variable values are completely different. There is no workspace to forget. This is why separate directories are the standard for managing persistent environments in CI/CD.
Workspaces are like a shared filing drawer with Post-it labels
Separate directories are dedicated filing cabinets, one per environment. With the drawer, you can grab the wrong folder because the separation is only a label. With separate cabinets, the physical structure prevents it. The effort is the same; the safety margin is not.
| Separate directories | Workspaces | |
|---|---|---|
| Environment identified by | Working directory | Workspace name in shell state |
| Risk of wrong-environment operation | Low: directory acts as a guard | Higher: easy to forget switching |
| Per-environment backend | Yes: each has own backend.tf | Same backend, different key suffix |
| Different provider config per env | Yes: edit providers.tf per env | Harder: shared providers.tf |
| State isolation | Complete (different S3 keys or buckets) | Shared bucket, different state keys |
| Safe for persistent environments | Yes | Use with caution |
| Good for ephemeral environments | Overkill | Yes |
| Team clarity | Explicit and auditable | Can be confusing across the team |
Recommendation: use separate directories for dev, staging, and prod. Use workspaces only for genuinely ephemeral environments, for example a temporary environment spun up per pull request and torn down when merged.
When to use this structure
The pattern described on this page is the right fit when:
- You manage two or more persistent environments (dev and prod, or dev/staging/prod)
- More than one person makes infrastructure changes
- Terraform runs from a CI/CD pipeline rather than a developer’s laptop
- You have infrastructure patterns repeated across environments or services
- You need to enforce that changes pass dev and staging before reaching prod
- You are building a portfolio project and want to demonstrate professional-grade structure
If you are learning Terraform for the first time, working on a personal side project with one environment, or managing a handful of resources that will never grow, skip this structure. Start flat: a single directory, the standard files, no modules. Extract structure only when the repetition is real.
Common beginner mistakes
- One giant main.tf. A 500-line main.tf is impossible to navigate and review in a pull request. Split by concern (networking.tf, compute.tf, database.tf) or extract repeated patterns into modules. The split costs nothing because Terraform merges files automatically.
- Creating modules too early. Modules add indirection. If you only have one environment and are still iterating on the infrastructure design, skip modules and keep everything flat. Extract to a module when you are genuinely repeating a pattern across environments or projects.
- Mixing environments in one root module. Using a single
terraform.tfvarswith avar.environmentflag to switch between dev and prod is fragile. Use separate directories. A one-character typo in a variable value should not be capable of touching production. - Weak naming and missing tags. Resources named
my-bucketbecome unidentifiable in billing reports and during incidents. Use{project}-{environment}-{purpose}as a prefix and setdefault_tagsin the provider block so every resource is tagged without per-resource tag blocks. - Committing secrets or sensitive tfvars. Database passwords, API keys, and tokens must never live in a committed
terraform.tfvarsfile. Add*.tfvarsto.gitignoreand store sensitive values in AWS Secrets Manager or SSM Parameter Store. See Secrets in CI/CD Pipelines for how to inject them at runtime. - No remote state from the start. Local state works for learning. The moment a second team member joins or a CI/CD pipeline runs Terraform, local state causes conflicts and lost updates. Set up an S3 backend with DynamoDB locking before the project grows, not after.
- Structure that does not fit the CI/CD pipeline. If your pipeline applies environments by switching workspaces, it is easy to misconfigure. Design your folder structure with the pipeline in mind. One directory per environment maps cleanly to one pipeline stage per environment, with no workspace selection step to get wrong.
- No descriptions on variables. A variable without a
descriptionforces every reader to trace all usage to understand what it controls. Write one sentence per variable. It takes ten seconds and saves ten minutes for the next person on the team.
How this connects to remote state, IAM, secrets, and CI/CD
Project structure is not just about file organisation. It directly affects how safely your team operates Terraform in production.
Remote state and state isolation
Each environment directory having its own backend.tf means a completely separate state file per environment. Two engineers can run terraform plan in dev and prod simultaneously without conflicting. A corrupted dev state file has no effect on prod. See Terraform State Management in AWS for the full S3 and DynamoDB locking setup.
IAM and least privilege per environment
Separate environment directories make it straightforward to apply different IAM permissions per environment. Your CI/CD pipeline can assume an IAM role with write access to dev resources but read-only access to prod, enforcing a manual approval gate before any production change. Managing IAM with Terraform covers how to define those roles as Terraform code.
Secrets injection
Structure determines where your pipeline injects secrets. With environment-scoped directories, secrets (database passwords, API keys) can be injected as environment variables at CI/CD runtime, one value per pipeline stage, rather than committed to a shared tfvars file. Reference them in Terraform using data sources:
# data.tf
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "${local.name_prefix}/db-password"
}Database passwords, API keys, and tokens must never live in a committed terraform.tfvars or .tf file. Add *.tfvars to .gitignore and store sensitive values in AWS Secrets Manager or SSM Parameter Store. See Secrets in CI/CD Pipelines for how to inject them safely at runtime.
Policy as code
A clean project structure makes it practical to run automated policy checks as part of the pipeline. Tools like Checkov or OPA can scan specific environment directories as part of a plan step, blocking non-compliant changes before they reach apply. Policy as Code explains how to add these guardrails without slowing down the pipeline.
CI/CD pipeline safety
When each environment is its own directory, the pipeline is straightforward: one stage per environment, each pointing to its directory. The pipeline cannot accidentally apply dev variable values to prod because the directory path is hard-coded in the pipeline config. A typical pipeline runs:
terraform fmt -check # Fail if formatting is off
terraform validate # Check syntax and types
terraform plan -out=tfplan # Preview changes, save plan to file
terraform apply tfplan # Apply the saved plan exactlyAlways run terraform apply tfplan on the file saved during the plan step, not a fresh terraform apply. A fresh apply re-evaluates the plan, which can produce different results if the environment changed between review and execution. The saved plan is what was reviewed and approved.
Running apply on a saved plan file means the apply step executes exactly what was reviewed, with no drift between what was approved and what runs. See Managing Environments in CI/CD for the full pipeline setup.
Summary
- The standard file layout separates concerns: resources in
main.tf, variables invariables.tf, outputs inoutputs.tf, version pinning inversions.tf, provider config inproviders.tf, computed values inlocals.tf. - Separate environment directories (
environments/dev/,environments/staging/,environments/prod/) are safer than workspaces for persistent environments, each with its own backend, tfvars, and isolated state file. - Modules are reusable patterns: create them when you copy-paste a resource block, not before.
- Use
default_tagsin the provider block to apply consistent tags to every resource without per-resource tag blocks. - Data sources let you reference existing AWS resources (shared VPCs, AMIs) without importing them into state.
- Workspaces are useful for ephemeral environments (per-PR), not for persistent dev/staging/prod separation.
- Structure directly affects state isolation, per-environment IAM permissions, secrets injection, and CI/CD pipeline safety.
- Never commit secrets to tfvars files. Store them in AWS Secrets Manager or SSM Parameter Store and reference them with data sources.
Frequently asked questions
When should I create a Terraform module?
Create a module when you find yourself writing the same infrastructure block more than once across different environments or projects. A good rule of thumb: if you copy-paste a resource block, extract it into a module. Common candidates are VPC configurations, ECS service definitions, and RDS instances with standard settings.
Should I use Terraform workspaces or separate directories for environments?
Separate directories (environments/dev/, environments/prod/) are generally safer and more explicit than workspaces. With workspaces, a typo in the workspace name or a missed workspace switch can apply changes to the wrong environment. Separate directories make it obvious which environment you are operating on and allow different backend configurations per environment.
What goes in versions.tf vs providers.tf?
versions.tf contains the terraform{} block with required_version and required_providers. That is the version pinning. providers.tf contains provider{} blocks with runtime configuration like region and default tags. Keeping them separate means you can see version constraints at a glance without scrolling through configuration details.
Should each environment have its own backend?
Yes. Each environment directory should have its own backend.tf pointing to a separate S3 key or bucket. This gives you complete state isolation: a corrupted dev state file cannot affect prod, and running terraform apply in the wrong directory cannot touch infrastructure in a different environment.
How large should a Terraform root module become before I split it?
A rough guideline: when your root module has more than 50–80 resources, or when a terraform plan output becomes hard to review in a single pass, consider splitting into child modules or separate root modules. The goal is not a specific number; it is keeping the blast radius of any single plan small enough that a reviewer can confidently assess the changes.