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 in variables.tf, outputs in outputs.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.tf for VPC config, compute.tf for EC2), any team member can find and change the right file without reading everything else.
  • Collaboration. Multiple people working on the same flat main.tf cause 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 plan from environments/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 dev

The 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.tfresource 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.tfvariable blocks with descriptions and types. Leave sensitive variables without defaults so Terraform requires them explicitly rather than silently using a wrong placeholder.

outputs.tfoutput 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.tfdata 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 typeFolder layoutModules?StateWhen to use
Solo learningSingle flat directoryNoLocalFirst steps, throwaway experiments
Small team, one envFlat + standard filesOptionalRemote S3One or two people, single environment
Multi-environmentenvironments/dev/ + environments/prod/OptionalRemote S3 per envAny project reaching real users
Production teammodules/ + environments/YesRemote S3 per env + DynamoDB lockMultiple 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 = true

Reusable modules

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.

Separate directoriesWorkspaces
Environment identified byWorking directoryWorkspace name in shell state
Risk of wrong-environment operationLow: directory acts as a guardHigher: easy to forget switching
Per-environment backendYes: each has own backend.tfSame backend, different key suffix
Different provider config per envYes: edit providers.tf per envHarder: shared providers.tf
State isolationComplete (different S3 keys or buckets)Shared bucket, different state keys
Safe for persistent environmentsYesUse with caution
Good for ephemeral environmentsOverkillYes
Team clarityExplicit and auditableCan 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

When this is overkill

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

  1. 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.
  2. 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.
  3. Mixing environments in one root module. Using a single terraform.tfvars with a var.environment flag 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.
  4. Weak naming and missing tags. Resources named my-bucket become unidentifiable in billing reports and during incidents. Use {project}-{environment}-{purpose} as a prefix and set default_tags in the provider block so every resource is tagged without per-resource tag blocks.
  5. Committing secrets or sensitive tfvars. Database passwords, API keys, and tokens must never live in a committed terraform.tfvars 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 at runtime.
  6. 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.
  7. 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.
  8. No descriptions on variables. A variable without a description forces 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"
}
Never commit secrets to git

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 exactly
Apply the saved plan, not a fresh one

Always 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.

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.

Last verified: 25 April 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.