Back to Blog
3 min read

Best Practices for Structuring Terraform Repositories

Learn how to structure your Terraform repositories effectively for scalability, maintainability, and collaboration in production environments.

Terraform Infrastructure as Code DevOps Best Practices

When it comes to infrastructure as code, my key recommendation is to break your infrastructure down into small, manageable, and reusable modules. This approach fosters consistency, simplifies maintenance, and minimises code duplication.

For the structure of a Terraform/Open Tofu/infrastructure as code repository, I suggest the following setup. My preferred approach allows for easy customisation between environments while keeping management overhead low.

In this example:

  • env/account: deploys shared resources for the account, IAM, maybe your OIDC providers etc.
  • env/project1: deploys vpc module
  • env/project2: deploys resources module that may reference project 1

This structure allows for small blast radius, allows for smaller plan reviews and if you’re using CICD you can just plan/apply changed projects. There is a slight chicken and egg in that project2 needs project1 deployed first but this is a one time thing.

.
├── .gitignore
├── .gitlab
│   ├── ci
│   │   ├── environments.yml
│   │   ├── jobs.yml
│   │   └── pipeline
│   │       ├── nonprod-account.yml
│   │       ├── nonprod-project1.yml
│   │       ├── nonprod-project2.yml
│   │       ├── prod-account.yml
│   │       ├── prod-project1.yml
│   │       └── prod-project2.yml
│   └── scripts
│       └── assume-role.sh
├── .gitlab-ci.yml
├── CODEOWNERS
├── README.md
├── environments
│   ├── nonprod
│   │   ├── account
│   │   │   ├── backend.tf
│   │   │   └── main.tf
│   │   ├── project1
│   │   │   ├── backend.tf
│   │   │   ├── data.tf
│   │   │   ├── main.tf
│   │   │   └── variables.tf
│   │   └── project2
│   │       ├── backend.tf
│   │       ├── main.tf
│   │       ├── outputs.tf
│   │       └── routes.tf
│   └── prod
│       ├── account
│       │   ├── backend.tf
│       │   └── main.tf
│       ├── project1
│       │   ├── backend.tf
│       │   ├── data.tf
│       │   ├── main.tf
│       │   └── variables.tf
│       └── project2
│           ├── backend.tf
│           ├── main.tf
│           ├── outputs.tf
│           └── routes.tf
└── modules
   ├── networking
   │  ├── firewall.tf
   │  └── vpc.tf
   └── resources
      ├── ec2.tf
      └── s3.tf

This approach offers less flexibility than option one, as each environment now relies on the same code, with only variable changes allowed.

.
├── .gitignore
├── .gitlab
│   ├── ci
│   │   ├── environments.yml
│   │   ├── jobs.yml
│   │   └── pipeline
│   │       ├── dev.yml
│   │       ├── stage.yml
│   │       ├── prod.yml
│   └── scripts
│       └── assume-role.sh
├── .gitlab-ci.yml
├── CODEOWNERS
├── README.md
├── cfn
│   ├── gitlab-openid-role.yml
│   └── terraform-state.yml
├── environments
│  ├── main.tf
│  ├── dev.tfbackend
│  ├── dev.tfvars
│  └── stage.tfbackend
│  ├── stage.tfvars
│  ├── prod.tfbackend
│  └── prod.tfvars
└── modules
   ├── networking
   │  ├── firewall.tf
   │  └── vpc.tf
   └── resources
      ├── ec2.tf
      └── s3.tf

While this guarantees consistency across environments, it introduces management overhead. You’ll need to handle discrepancies between version control and the cloud as you roll out changes sequentially—first to the dev environment, then to staging, and finally to production.

This approach is more complex as engineers will need to provide the backend and environment configure in local testing / CICD scripts, therefore this is the least best option.

terraform init -backend-config=./${ENVIRONMENT}.tfbackend
terraform plan -var-file=./${ENVIRONMENT}.tfvars
terraform apply -var-file=./${ENVIRONMENT}.tfvars