Article

Structuring Terraform Projects: Modules, Stacks, and Environments Explained

by Gary Worthington, More Than Monkeys

When you start small with Terraform, it is tempting to throw everything into a single main.tf. That works for a one-off experiment. But as soon as you have multiple environments, shared components, or teams working in parallel, structure matters.

Poorly structured Terraform leads to brittle pipelines, unsafe deployments, and painful scaling later. Good structure gives you composability, clear boundaries, and predictable automation.

This article covers how Terraform decides what to run, and how to structure projects across files, modules, stacks, and environments.

How Terraform Picks Files

A common question from engineers new to Terraform is: “Which files does Terraform use?”

The answer: Terraform loads all .tf files in a directory.

  • Filenames don’t matter — main.tf, network.tf, iam.tf are all read together.
  • .tf.json files are also valid and are merged into the same configuration.
  • Variable values are loaded from *.tfvars and *.auto.tfvars if present.
  • When you run terraform plan or terraform apply, Terraform treats the directory as a single unit.

This means you can split resources across multiple files for readability without worrying about import order. Terraform handles parsing and merging internally.

Modules: Encapsulation and Reuse

Modules are the building blocks for clean Terraform. A module is just a directory of .tf files that can be called from another configuration.

Example: you define an S3 bucket module:

# modules/s3_bucket/main.tf
variable "bucket_name" {}
variable "versioning" { default = true }

resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
versioning {
enabled = var.versioning
}
}
output "bucket_id" {
value = aws_s3_bucket.this.id
}

Then use it from your stack:

module "app_bucket" {
source = "../modules/s3_bucket"
bucket_name = "my-app-${var.environment}"
}

Why use modules?

  • Reuse patterns across projects.
  • Abstract away low-level details.
  • Enforce consistency (naming, tagging, policies).

Good practice: publish common modules in a private registry or repo so teams can consume them with version control.

Stacks: Logical Groupings of Resources

While modules are about reuse, stacks are about separation of concerns. A stack is a set of related resources deployed and managed together in a single state file.

Examples of stacks:

  • networking (VPC, subnets, routing, security groups)
  • app (API Gateway, Lambdas, DynamoDB)
  • observability (CloudWatch, alarms, dashboards)

Splitting into stacks gives you:

  • Smaller blast radius (changing the app stack doesn’t risk networking).
  • Faster plans and applies.
  • Clearer ownership (different teams can own different stacks).

In practice, each stack lives in its own directory with its own backend configuration.

Environments as Variables

Rather than duplicating directories for dev, staging, and prod, it’s cleaner to define the environment as a variable.

Example

variable "environment" {
description = "Environment to deploy into (dev, staging, prod)"
type = string
}

module "api" {
source = "../modules/api_gateway_lambda"
env = var.environment
logging = var.environment == "prod"
}

Run:

terraform apply -var="environment=dev"
terraform apply -var="environment=staging"
terraform apply -var="environment=prod"

This pattern gives you:

  • Consistent infrastructure across environments.
  • Small differences controlled via variables (e.g. instance sizes, logging).
  • No code duplication across directories.

Worked Example: Different Configs Per Environment

Let’s say your Lambda stack needs:

  • dev: smallest instance size, logging off
  • staging: medium instance size, logging on
  • prod: larger instance size, logging on

You can model this with a variable map:

variable "environment" {
description = "Environment name"
type = string
}

locals {
config = {
dev = {
instance_size = "t3.micro"
logging = false
}
staging = {
instance_size = "t3.small"
logging = true
}
prod = {
instance_size = "t3.large"
logging = true
}
}
}

resource "aws_instance" "example" {
ami = "ami-123456"
instance_type = local.config[var.environment].instance_size
tags = {
Name = "example-${var.environment}"
}
}

module "api" {
source = "../modules/api_gateway_lambda"
env = var.environment
logging = local.config[var.environment].logging
}

Now:

  • terraform apply -var="environment=dev" → t3.micro, no logging
  • terraform apply -var="environment=staging" → t3.small, logging enabled
  • terraform apply -var="environment=prod" → t3.large, logging enabled

Backend State Management per Environment

Variables define how resources differ across environments, but you also need to keep the state files isolated.

The most common approach is to use S3 + DynamoDB (for locking) with environment-specific keys:

terraform {
backend "s3" {
bucket = "my-terraform-states"
key = "app/${var.environment}.tfstate"
region = "eu-west-1"
dynamodb_table = "terraform-locks"
}
}

This ensures:

  • dev state goes in app/dev.tfstate
  • staging state goes in app/staging.tfstate
  • prod state goes in app/prod.tfstate

Each environment has its own isolated state and locking, preventing accidental cross-environment contamination.

Best practices:

  • Use consistent naming (stack/environment.tfstate).
  • Enable DynamoDB locking to avoid race conditions in CI/CD.
  • Never share one state file across multiple environments.

Workspaces: Do You Need Them?

Terraform workspaces allow you to keep multiple state files for the same configuration. In theory, you could run:

terraform workspace select dev
terraform apply
terraform workspace select prod
terraform apply

But in practice:

  • Workspaces are better suited to short-lived variations (e.g. feature branches).
  • For long-lived environments, use variables + backend keys instead.

Rule of thumb: use variables + backend configuration for environments, use workspaces sparingly.

Putting It Together

A well-structured Terraform repo often looks like this:

.
├── modules/
│ ├── s3_bucket/
│ ├── lambda_function/
│ └── api_gateway/
└── stacks/
├── networking/
│ ├── main.tf
│ ├── variables.tf
│ └── backend.tf
└── app/
├── main.tf
├── variables.tf
└── backend.tf
  • Modules: reusable building blocks.
  • Stacks: logical groups of resources (networking, app).
  • Environments: defined via variables (var.environment = dev|staging|prod) and isolated via backend config.

Guidelines for Scaling

  • Use modules for reuse — capture patterns once, consume them everywhere.
  • Use stacks for separation — networking, app, observability should live independently.
  • Use variables for environments — dev, staging, prod are input values.
  • Use backend state separation — each environment should have its own S3 key and DynamoDB lock.
  • Keep stacks thin at the root — they should just wire modules together.
  • Document the structure — future maintainers should know what lives where.

Final Thoughts

Terraform will happily run everything in one directory, but as your projects grow that approach becomes a liability.

Use modules to encapsulate, stacks to separate concerns, and variables + backend config to define and isolate environments. Workspaces still have a role, but explicit environments with separate state files make long-lived infrastructure clearer and safer to manage.

This discipline pays off when you are managing dozens of services and multiple environments, giving you reproducibility without chaos.

Gary Worthington is a software engineer, delivery consultant, and agile coach who helps teams move fast, learn faster, and scale when it matters. He writes about modern engineering, product thinking, and helping teams ship things that matter.

Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software — from tech strategy and agile delivery to product validation and team development.

Visit morethanmonkeys.co.uk to learn how we can help you build better, faster.

Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance