Text size
Tried & Tested6 min read

How I structure Terraform projects

Modules, environments and state, organised so the next person can understand them.

How I structure Terraform projects
AI-generated editorial illustration

Every Terraform project starts clean. One folder, a main.tf, a few resources. Then a second environment arrives. Then a few more services, a shared network, a second region. Two years later, there's a 3,000-line main.tf, a state file nobody dares to touch, and a plan that takes ten minutes.

Structure makes the difference between infrastructure code that grows smoothly and code that becomes everyone's least favourite part of the repo. This is the layout I keep using. (Everything here works for OpenTofu too.)

The principles

  1. Small state files. Split infrastructure into independent pieces, each with its own state. Smaller blast radius, faster plans, fewer people blocking each other.
  2. Separate by lifecycle. Things that change rarely (networks, accounts) shouldn't share state with things that change daily (app resources).
  3. Environments look the same. Staging and production should use the same code with different inputs, not copied folders that slowly drift apart.
  4. Reusable logic lives in modules.

A layout that works

infrastructure/
  modules/
    network/
    eks-cluster/
    rds-postgres/
  live/
    staging/
      network/
        main.tf
        backend.tf
        terraform.tfvars
      cluster/
      databases/
    production/
      network/
      cluster/
      databases/
  • modules/ contains reusable building blocks with no environment-specific values.
  • live/ contains the actual deployments. Each folder is one state file, calling modules with environment-specific inputs.
  • Each live folder is small and focused: the network, the cluster, the databases.

Later components can read outputs from earlier ones with terraform_remote_state or, better, by looking up resources with data sources or a shared parameter store.

Folders vs workspaces

Terraform has workspaces, which let one configuration have multiple states. It's tempting to use them for environments: terraform workspace select production.

I prefer separate folders for environments:

  • It's visible in the code which environment you're working on.
  • Production can have its own backend, its own permissions and its own pipeline.
  • Accidentally applying to the wrong workspace is a classic and painful mistake.

Workspaces are still handy for making many identical copies of the same thing, like a stack for each customer or for preview environments.

If you have to run a command to find out which environment you're about to change, the structure is working against you.

Remote state and locking

Never keep state on a laptop. Use a remote backend with locking:

terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "production/network/terraform.tfstate"
    region       = "eu-central-1"
    encrypt      = true
    use_lockfile = true
  }
}

The S3 backend now supports native locking with a lock file, so the separate DynamoDB table is no longer required. Azure Storage and Google Cloud Storage backends lock natively too.

Protect the state bucket: versioning on, encryption on, access limited to the people and pipelines that need it. State often contains secrets.

Terragrunt or Stacks?

The folder layout above has one annoying side effect: lots of repeated boilerplate. Every folder needs its backend config and provider config.

Tools that help:

  • Terragrunt (from Gruntwork) keeps configurations DRY: backend and provider settings are defined once and inherited, and you can run commands across many folders with dependencies between them. Very popular for exactly this layout.
  • Terraform Stacks (HashiCorp, with HCP Terraform) is the official answer for orchestrating many components and deployments together.
  • OpenTofu's early variable evaluation removes some of the need for wrappers, since variables can now be used in backend configuration.
  • Infrastructure automation platforms (Spacelift, env0, Scalr, HCP Terraform) manage stacks and dependencies in their own way.

For a small team, plain folders with a bit of repetition are fine. Once you have dozens of components, Terragrunt or a platform pays off.

Naming and tags

Small things that save a lot of confusion:

  • Consistent resource names: <env>-<service>-<purpose>, like prod-shop-db.
  • Default tags in the provider block, so every resource gets them automatically:
provider "aws" {
  region = "eu-central-1"
  default_tags {
    tags = {
      environment = "production"
      owner       = "team-platform"
      managed_by  = "terraform"
      repository  = "acme/infrastructure"
    }
  }
}
  • Pin provider and module versions, and commit the .terraform.lock.hcl file.
  • Format and validate in CI (terraform fmt -check, terraform validate, tflint).

So, what's the most important thing?

Keep state files small and separated by lifecycle and environment. Almost every other problem, slow plans, scary applies, merge conflicts, people blocking each other, gets easier once that's in place.

My take: structure your Terraform for the person who joins next year. If they can find their way around, you did it right.

— Desly