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.
Option 1: My recommended project structure
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
Option 2: Less recommended option
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
├── 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 / CI/CD scripts, therefor this is the least best option.
terraform init -backend-config=./${ENVIRONMENT}.tfbackend
terraform plan -var-file=./${ENVIRONMENT}.tfvars
terraform apply -var-file=./${ENVIRONMENT}.tfvars