Skip to content

Manage multiple Terraform projects in monorepo

Published: | 8 min read

Table of contents

Open Table of contents

Intro

There are many ways to organize and manage Terraform projects. Some setups require use of additional tools to manage variables, environments/stages, deployments and many more aspects of said projects. By additional I mean anything additional to Terraform itself, git and CI/CD tooling.

Let’s take a look at one possible way to organize and manage a monorepo setup, which will contain multiple projects and Terraform modules, with deployments spanning across multiple targets such as AWS accounts or Azure subscriptions.

The presented setup is geared towards an inhouse setup, a consultancy working with multiple clients may wish to keep Terraform modules separate for cross-utiliziation between different client projects.

Assumptions & requirements

In order to help understand the setup, let me walk through a few assumptions and requirements:

  1. The setup must support management of multiple Terraform projects (1 project = 1 state) and versioned Terraform modules which are used in projects.
  2. Modules must be versioned with semantic versioning.
  3. No additional tools will be used.
  4. Git branches will not be used to manage different deployments.
  5. Because of the previous, git main branch must always contain the source code for all deployed projects, in all live environments.
  6. Deployments to development environments can be done from developer’s machine to enable development of the projects and modules.
  7. Deployments to any other environment must be done through CI/CD. Developers will have only read access to these environments.
  8. GitHub will be used to host the repository, and GitHub Actions will be used to run CI/CD pipelines. This means that the pipeline definitions are managed right alongside with the Terraform code.
  9. Terraform state is managed in shared, but deployment target / environment specific place. In case of AWS in S3 bucket with DynamoDB table for locking, and in case of Azure in Storage Account. Each AWS account / Azure subscription will have its own state store.
  10. Terraform plans must be reviewed and approved before deployments to live environments other than development can be done.

Why git branched are not used to manage deployments? If the project deployments to live environments are governed by git branches, it becomes extremely difficult to see, compare and understand what is deployed and where at any given time.

The main design principle of this approach is to keep things as simple as possible.

Repository structure

Before I will go through the development and deployment workflows, let’s see how the repository is organized:

terraform-monorepo> tree -L 4 -a
.
├── .github
│   ├── actions
│   │   ├── deploy
│   │   │   └── action.yml
│   │   └── prepare-plan
│   │       └── action.yml
│   └── workflows
│       ├── deploy-main.yml
│       └── pr-to-main.yml
├── .gitignore
├── LICENSE
├── README.md
├── docs
│   ├── whats-in-here.txt
│   └── workflow.jpg
├── live
│   ├── development
│   │   ├── api
│   │   │   ├── environment.auto.tfvars
│   │   │   ├── main.tf
│   │   │   ├── outputs.tf
│   │   │   ├── providers.tf
│   │   │   └── variables.tf
│   │   ├── data_processor
│   │   │   ├── user-data.sh
│   │   │   ├── environment.auto.tfvars
│   │   │   ├── main.tf
│   │   │   ├── outputs.tf
│   │   │   └── variables.tf
│   │   └── tf_state
│   │       ├── environment.auto.tfvars
│   │       ├── main.tf
│   │       ├── outputs.tf
│   │       └── variables.tf
│   ├── production
│   │   ├── api
│   │   │   └── [omitted-for-brevity]
│   │   ├── data_processor
│   │   │   └── [omitted-for-brevity]
│   │   └── tf_state
│   │       └── [omitted-for-brevity]
│   └── staging
│       ├── api
│       │   └── [omitted-for-brevity]
│       ├── data_processor
│       │   └── [omitted-for-brevity]
│       └── tf_state
│           └── [omitted-for-brevity]
└── modules
    ├── api_layer
    │   ├── v1.0.0
    │   │   └── [omitted-for-brevity]
    │   └── v1.1.0
    │       ├── main.tf
    │       ├── outputs.tf
    │       └── variables.tf
    ├── processing_cluster
    │   ├── v1.0.0
    │   │   └── [omitted-for-brevity]
    │   ├── v1.0.1
    │   │   └── [omitted-for-brevity]
    │   └── v1.2.0
    │       ├── main.tf
    │       ├── outputs.tf
    │       └── variables.tf
    └── vpc
        └── v1.0.0
            ├── main.tf
            ├── outputs.tf
            └── variables.tf

Whoa, that’s a lot of directories! Let’s break it down bit by bit, and start with root-level directories:

Let’s look into modules next:

And finally the live directory:

Project development workflow

How does the development and deployment workflow look? The idea is to keep things as simple as possible, and as stated earlier, all live environments must be in main branch after each development cycle.

project development workflow diagram

  1. Develop project locally in a stage and project specific subdirectory, in issue-nn / dev branch.
  2. While developing, run terraform plan and terraform apply against the dev environment, since that will be the only one you as a developer will have write access.
  3. When you are confident, that your solution works, add/update GitHub Actions workflow definition. The definition file is shared between all projects. The code duplication in workflows can be minimized by using composite actions to share CI/CD code between projects.
  4. Commit, and push your changes to GitHub to your development branch, e.g. issue-nn or dev. Create a pull request to main branch.
  5. This PR will trigger CI pipeline pr-to-main.yml which will validate the source code and run terraform plan for all projects. The plans’ outputs are written as comments to the PR. Again, changes should be found only for the projects, that you changed! You did not change dev, test and prod at the same time, did you?
  6. Review the plans, and if all looks good, merge the changes to branch main. If they do not look as expected, iterate back to step 1.
  7. When the changes are merged to branch main, CD pipeline deploy-main.yml will run terraform apply for all projects.
  8. After the changes are in branch main and successfully deployed, delete the development branch.

Module development workflow

Module development workflow is very similar compared to project development.

module developlemen workflow diagram

  1. Developer will create a new subdirectory for the new module version and a new development project where the module can be tested while in development. This dev project is deleted, when the module version is ready.
  2. While developing, run terraform plan and terraform apply against the dev environment, since that will be the only one you as a developer will have write access.
  3. When you are confident, that your module works, commit the changes and push your changes to GitHub to your development branch, e.g. issue-nn or dev. Create a pull request to main branch.
  4. Even though CI pipeline will run, no changes should be found at this stage, since only module code was changed.
  5. Review the PR comments, that this indeed is the case.
  6. Since the same CD pipeline will be run as with project deployments, terraform apply will be run for all projects, but without any changes.
  7. After the changes are in branch main the development branch can be deleted.

Pros and cons

There are many benefits to the presented setup:

There are also some things, which are less than optimal:

Conclusion

While a monorepo setup is not perfect, in my opinion it offers far more benefits over individual project repositories. As a new developer a monorepo can feel overwhelming, but this can be helped a lot by focusing on one project (subdirectory) at a time, ignoring rest of the codebase.