Terraform and OpenTofu Project Structure: Recommended Layouts
Organize the code so that it can grow #
The right structure depends on the size of the project, but the principles are the same: small state files, reusable modules, explicit environments and consistent names. Start simple and split when something hurts.
Level 1: a single configuration #
For a small project or a tutorial:
infra/
main.tf # resources
variables.tf # inputs
outputs.tf # outputs
providers.tf # provider configuration
versions.tf # required_version and required_providers
terraform.tfvars # values
README.md
Terraform reads every .tf file of the directory, so you can split main.tf by topic (network.tf, compute.tf, database.tf) when it gets long. Pin the versions:
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}Commit .terraform.lock.hcl so everyone uses the same provider builds, and ignore .terraform/, *.tfstate and *.tfplan.
Level 2: modules and environments #
When you need the same infrastructure in several environments:
infra/
modules/
network/
main.tf
variables.tf
outputs.tf
app/
database/
environments/
dev/
main.tf # module "network" { source = "../../modules/network" ... }
backend.tf
terraform.tfvars
pro/
main.tf
backend.tf
terraform.tfvars
Each environment has its own backend and state. See workspaces vs directories for the alternatives and Terraform modules to write them.
Level 3: split by layer and lifecycle #
A single state for everything makes every plan slow and every mistake dangerous. Split by how often things change and who owns them:
infra/
live/
pro/
eu-west-1/
00-account/ # IAM, organizations, KMS
10-network/ # VPC, subnets, routing
20-data/ # RDS, S3
30-apps/ # ECS, EC2, load balancers
Lower layers change rarely and are read by upper layers with data sources or SSM. Terragrunt automates the dependencies between layers.
Modules: local, versioned and shared #
- Keep modules small and focused on one thing (a network, a database), with clear inputs and outputs.
- In a monorepo, reference them with a relative path. When many teams consume them, publish them in a Git repository or a registry and pin the version:
module "network" {
source = "git::https://github.com/my-org/terraform-modules.git//network?ref=v1.4.0"
}- Do not put provider configurations inside reusable modules: pass them from the caller.
- See share Terraform projects.
Conventions #
- Names with snake_case, resources named
thisor by role when there is only one of a kind. - Follow a naming and tagging convention.
- Document every variable and output with
description. - Run
fmt,validate, lint and security scan automatically in CI. - Add a README per module and example configurations in
examples/, which also serve as tests.
Signs that you must split the state #
plantakes more than a few minutes.- Different teams need to change different parts at the same time and wait for the lock.
- A small change could break something critical, such as the network.
- The blast radius of one wrong
applyis bigger than you can accept.
For the complete list of recommendations read Terraform best practices.