Terraform with Monorepo and GitHub Actions: Optimizing CI/CD with Side Effects

2024-11-28

Terraform with Monorepo and GitHub Actions: Optimizing CI/CD with Side Effects

Hello, I’m keke from the SRE team.

At Eukarya Inc., we use Terraform to manage resources such as Google Cloud. Re:Earth is a general-purpose WebGIS platform that not only powers the managed service Re:Earth Cloud but also hosts systems like the Ministry of Land, Infrastructure, Transport and Tourism’s PLATEAU project. Moreover, since it is deployed across multiple environments such as production, development, and testing, and includes other components like Re:Earth CMS, we have many reusable Terraform files.

To improve reproducibility and maintainability, we abstracted these Terraform files using Terraform Modules and manage all Terraform files in a single repository (Monorepo).

Recently, we implemented CI/CD with GitHub Actions to automatically execute terraform plan and terraform apply, allowing developers to start interacting with Terraform and managing resources. Currently, the limited SRE members are still refining this setup, navigating through trial and error.

In this article, I will discuss an issue we encountered during this process, where terraform apply did not work properly after modifying a Terraform Module, and how we resolved it.

Directory Structure in the Monorepo

Before diving into the issue, let me explain the directory structure of our Terraform repository.

Here’s a simplified version of the actual Monorepo structure we use. Each terraform/product/deployment-environment directory corresponds to a Google Cloud project.

.
├─ modules
│  ├─ reearth
│  └─ reearth-cms
└─ terraform
   ├─ cloud
   │  ├─ dev
   │  │  ├─ reearth.tf
   │  │  └─ reearth_cms.tf
   │  └─ prod
   │     ├─ reearth.tf
   │     └─ reearth_cms.tf
   ├─ plateau
   │  ├─ dev
   │  │  ├─ reearth.tf
   │  │  └─ reearth_cms.tf
   │  └─ prod
   │     ├─ reearth.tf
   │     └─ reearth_cms.tf
   └─ ...

For example, the reearth.tf file specifies a Terraform Module as follows. In the production environment, we pin the version using tags, while in the development environment, we allow applying changes to the Module at any time.

# Development environment
module "reearth" {
     source = "../../modules/reearth"
     ...
}

# Production environment
module "reearth" {
     source = "<GITHUB_REPOSITORY_URL>//modules/reearth?ref=v0.1.0"
     ...
}

By maintaining these Terraform Modules, we can apply consistent changes to resources deployed across various environments, reducing operational burden. This setup also ensures unified implementation and naming conventions for security and best practices.

CI/CD Using GitHub Environments and changed-files

Next, let me explain the CI/CD workflow for Terraform. Using GitHub Actions, we execute terraform plan on pull requests (PRs) and post the results as comments on the PRs.

name: Terraform Plan
on:
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    permissions:
      contents: read # To checkout
    outputs:
      terraform-modified: ${{ steps.terraform-changed-files.outputs.changed }}
      terraform-modified-files: ${{ steps.terraform-changed-files.outputs.changed_files }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: changed-files
        uses: tj-actions/changed-files@v44
        with:
            dir_names_max_depth: 3
            files: terraform/**/*.{hcl,tf,yaml}
            matrix: true

  plan:
    if: needs.changes.outputs.terraform-modified == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 30 # mins. Default is 360 mins.
    needs:
      - changes
    permissions:
      contents: read # To checkout
      id-token: write # To authenticate with GCP using OIDC
      pull-requests: write # To comment on PRs
    defaults:
      run:
        working-directory: ${{ matrix.working-directory }}
    environment: ${{ matrix.working-directory }}-plan
    strategy:
      fail-fast: false
      matrix:
        working-directory: ${{ fromJson(needs.changes.outputs.terraform-modified-files) }}
    steps:
      - uses: actions/checkout@v4

      - uses: google-github-actions/auth@v2
        with:
          service_account: ${{ secrets.GOOGLE_CLOUD_SERVICE_ACCOUNT_EMAIL }}
          workload_identity_provider: ${{ secrets.GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER }}
      
      - run: tfcmt -var "target:${{ matrix.working-directory }}" plan -- terraform plan
terraform plan results are posted as comments, allowing quick review of changes.
terraform plan results are posted as comments, allowing quick review of changes.

Terraform plan results are posted as comments, allowing quick review of changes.

After the PR is merged into the main branch, a similar workflow runs terraform apply.

We use the tj-actions/changed-files GitHub Action to detect which directories (environments) have changes. Combined with GitHub Environments, this setup ensures Terraform executes with the appropriate credentials for each environment (e.g., Google Cloud service accounts).

This approach optimizes GitHub Actions usage and reduces waiting time while isolating credentials per environment. It also allows us to support virtually unlimited environments by simply adding directories without modifying YAML (workflow) files—highly flexible and efficient.

Issue: Module Changes Not Applied Automatically

As mentioned earlier, we heavily use Terraform Modules with relative paths in the source. However, GitHub Actions only detects changes in specific environments and doesn’t trigger Terraform when only a Module is updated, leaving dependent environments unchanged.

├─ modules
│  ├─ reearth <- Changes here...
│  └─ ...
└─ terraform
   ├─ cloud
   │  └─ dev
   │     └─ reearth.tf <- ...won’t trigger Terraform execution here
   └─ ...

This creates a problem for developers who want to modify a Module and apply it to the development environment within a single PR. Instead, they must create two separate PRs—one for modifying the Module and another for applying it. This is cumbersome, especially when debugging or fixing bugs, as it requires repeating the two-step process.

Moreover, since we use relative paths, developers are forced to make dummy changes like adding blank lines to trigger GitHub Actions.

module "reearth" {
     source = "../../modules/reearth"
     ....
}
+ # Forcing a change to trigger GitHub Actions...

To avoid this, we needed a mechanism to propagate Module changes to dependent environments.

Defining Dependencies as Side Effects

To handle Module changes as "side effects," we implemented the GitHub Action KeisukeYamashita/side-effect-changed-files.

This action allows us to define which environments (directories) depend on which Modules via an external YAML file or inline configuration.

- uses: tj-actions/changed-files@v44
  id: raw-changed-files
  files: **/*.{hcl,tf,yaml}

- name: Get changed files
  id: changed-files
  uses: KeisukeYamashita/side-effect-changed-files@v1
  with: 
    bypass: terraform/**/*.{hcl,tf,yaml} # Each environment is changed as usual and nothing is done.
    dir_names: true # Grouping by directory
    files: ${{ steps.raw-changed-files.outputs.files }}
    mapping: |
      # If there are changes in reearth module,
      # Terraform will be executed in cloud and plateau development environments
      terraform/{cloud,plateau}/dev/*:
        - modules/reearth/*.tf
     matrix: true

This configuration treats changes in the reearth Module as if changes occurred in the cloud and plateau environments, triggering Terraform execution.

.
├─ modules
│  ├─ reearth <- If there are changes...
│  └─ reearth-cms
└─ terraform
   ├─ cloud
   │  └─ dev <- Triggers Terraform execution here as a side effect
   │     ├─ reearth.tf
   │     └─ reearth_cms.tf
   ├─ plateau
   │  └─ dev <- Also triggers Terraform execution here
   │     ├─ reearth.tf
   │     └─ reearth_cms.tf
   └─ ...

Although maintaining these mappings requires effort, the frequency of adding or removing Modules or environments is low, so the overhead is minimal. If a mapping is missed, Terraform simply doesn’t run, making it a safe and noticeable issue for developers.

This setup enables developers to modify Modules and apply changes to the development environment within a single PR, preventing configuration drift and adhering to GitOps principles (e.g., the main branch reflects the latest resource state). Additionally, developers receive quicker feedback on plan failures, improving the development cycle.

Conclusion

In this article, we introduced how to manage Terraform utilizing Monorepo and GitHub Actions. In particular, we implemented a mechanism to effectively reflect Module changes to each environment, thereby improving development efficiency and preventing configuration drifts. This initiative has enabled more secure and efficient infrastructure management, leading to increased productivity of the development team. We will continue to make ongoing improvements to achieve better DevOps practices.

Japanese

Eukaryaでは様々な職種で積極的にエンジニア採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!

Eukarya 採用ページ

Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!

Eukarya Careers

Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。

Eukarya Webサイト / ➔ note / ➔ GitHub

Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.

Eukarya Official Page / ➔ Medium / ➔ GitHub