技術(tech)

Implementing Terraform CI/CD with GitHub Actions

Introduction

When managing infrastructure with Terraform, setting up a secure and efficient CI/CD pipeline can feel overwhelming.
In this article, I’ll explain how to build a CI/CD pipeline for Terraform using GitHub Actions.

Prerequisites

This article is a continuation of the following post:

Terraform Template for AWS: Infrastructure as Code (IaC) Best PracticesIntroduction Managing infrastructure resources with code is a common...

Please refer to it if you need the foundational context.

Additionally, the pipeline discussed here assumes the following:

  • AWS resources are being managed.
  • Two environments are configured: stg (staging) and prd (production).
  • Plans are automatically executed, but applies are manually triggered via GitHub Actions.

Overview

The complete code for this pipeline is available in the following repository:
https://github.com/gonkunkun/terraform-template/tree/main/.github/workflows

About the Plan Phase

The Plan phase works as follows:

  • Plans are automatically executed when a pull request (PR) is created or updated.
  • Plans are run concurrently for both the stg and prd environments.
  • If there are no code changes, the plan execution is skipped (to save credits).
  • The results of the Plan are posted as comments in the PR.

About the Apply Phase

For Continuous Deployment (CD), we faced constraints due to GitHub’s free-tier limitations.

Specifically:

  • Branch protection rules cannot be enforced.
  • Approval steps (like manual approval before the Apply step) cannot be included in GitHub Actions.

Given these constraints, the Apply phase was implemented with some compromises:

  • Applies are executed based on the state of the main branch (this is more of an operational guideline).
  • A manual trigger specifies the target environment (stg or prd) for the Apply.
    • After triggering, the pipeline runs in the following sequence: Plan → Wait for a few seconds (for review) → Apply.
    • Since we couldn’t insert an automated approval step, the manual trigger serves as a workaround.
    • While pseudo-approval modules exist, they consume credits while waiting for approval, which is impractical for cost efficiency.

Details

There is quite a bit of redundant code, so there’s certainly room for improvement in organizing and streamlining the setup.

Prerequisites

Store the credentials for the Terraform user in the GitHub repository.

Fetching credentials dynamically for each execution might be a better practice, but for simplicity, we’re using this easier approach here.

Plan

name: Terraform Plan

on:
  pull_request:
    types:
      - opened
      - synchronize
    branches:
      - '*'

permissions:
  contents: read
  pull-requests: write

jobs:
  check_changed_dirs:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix_dirs }}
    steps:
      - run: echo "fetch_depth=$(( commits + 1 ))" >> $GITHUB_ENV
        env:
          commits: ${{ github.event.pull_request.commits }}

      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: ${{ env.fetch_depth }}

      - name: Set Matrix
        id: set-matrix
        run: |
          git fetch origin ${{ github.base_ref }}
          changed_files=$(git diff --name-only origin/${{ github.base_ref }})
          terraform_dirs=$(echo "$changed_files" | grep -E '^[^/]+/.*.tf$' | awk -F/ '{print $1}' | sort -u || echo "")
          if [ -z "$terraform_dirs" ]; then
            echo "No Terraform directories changed."
          else
            echo "Changed directories: $terraform_dirs"
          fi
          echo "matrix_dirs=$(echo $terraform_dirs | jq -R -s -c 'split("n") | map(select(. != ""))')" >> "$GITHUB_OUTPUT"
        shell: bash

  plan:
    name: Plan
    runs-on: ubuntu-latest
    needs: check_changed_dirs
    if: ${{ needs.check_changed_dirs.outputs.matrix != '[]' && needs.check_changed_dirs.outputs.matrix != '' }}
    strategy:
      fail-fast: false
      matrix:
        dir: ${{ fromJson(needs.check_changed_dirs.outputs.matrix) }}
        env: [stg, prd]

    steps:
      # Setup
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: "ap-northeast-1"

      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.dir }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      # Cache Terraform plugins
      - name: Cache Terraform plugin directory
        uses: actions/cache@v3
        with:
          path: .terraform
          key: terraform-plugin-${{ runner.os }}-${{ hashFiles('**/*.tf*') }}

      - name: Run Terraform Format Check
        run: terraform fmt -recursive -check

      # Run Terraform Init
      - name: Terraform Init
        run: |
          terraform init -backend-config="envs/${{ matrix.env }}/backend.hcl"
        working-directory: ${{ matrix.dir }}

      - name: Run Terraform Validate Check
        run: terraform validate
        working-directory: ${{ matrix.dir }}

      # Run Terraform Plan
      - name: Terraform Plan
        run: |
          terraform plan -var="env=${{ matrix.env }}" -out=tfplan-${{ matrix.dir }}-${{ matrix.env }}.out
        working-directory: ${{ matrix.dir }}

      # Post results to PR
      - name: Post Plan Results to PR
        uses: borchero/terraform-plan-comment@v2
        with:
          token: ${{ github.token }}
          header: "terraform-plan Dir: ${{ matrix.dir }} (${{ matrix.env }})"
          planfile: tfplan-${{ matrix.dir }}-${{ matrix.env }}.out
          working-directory: ${{ matrix.dir }}

ref: https://github.com/gonkunkun/terraform-template/blob/main/.github/workflows/terraform-plan.yml

Posting the results of the plan command is explained in detail in the following figure.

https://gonkunblog.com/en/terraform-ci-result-comment/

Apply

name: Terraform Apply (manual)

on:
  push:
    branches:
      - main
  workflow_dispatch:
    inputs:
      environment:
        description: 'Select the environment (e.g., stg, prd)'
        required: true
        type: choice
        options:
          - stg
          - prd
        default: stg

permissions:
  contents: read
  pull-requests: write

jobs:
  pre-plan:
    name: Pre-Plan
    runs-on: ubuntu-latest
    # needs: check_terraform_dirs
    if: github.event_name != 'workflow_dispatch'
    strategy:
      fail-fast: false
      matrix:
        dir: ["workloads"]
        env: [stg, prd]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.base_ref }}

      - name: Setup AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: "ap-northeast-1"

      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.dir }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      # Cache Terraform plugins
      - name: Cache Terraform plugin directory
        uses: actions/cache@v3
        with:
          path: .terraform
          key: terraform-plugin-${{ runner.os }}-${{ hashFiles('**/*.tf*') }}

      # Run Terraform Init
      - name: Terraform Init
        run: |
          terraform init -backend-config="envs/${{ matrix.env }}/backend.hcl"
        working-directory: ${{ matrix.dir }}

      # Run Terraform Plan
      - name: Terraform Plan
        run: |
          terraform plan -var="env=${{ matrix.env }}"
        working-directory: ${{ matrix.dir }}

  apply:
    name: Apply to specific env
    runs-on: ubuntu-latest
    # needs: check_terraform_dirs
    if: github.event_name == 'workflow_dispatch'
    strategy:
      matrix:
        dir: ["workloads"]
        env: ["${{ github.event.inputs.environment }}"]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.ref_name }}

      - name: Ensure Main is Up-to-Date
        run: |
          git fetch origin ${{ github.ref_name }}
          git diff --quiet origin/${{ github.ref_name }} || (echo "Main has diverged. Please rebase your branch." && exit 1)

      - name: Setup AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: "ap-northeast-1"

      - name: Get Terraform version
        id: terraform-version
        uses: bigwheel/get-terraform-version-action@v1.2.0
        with:
          path: ${{ matrix.dir }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ steps.terraform-version.outputs.terraform-version }}

      # Run Terraform Init
      - name: Terraform Init - ${{ matrix.env }}
        run: |
          terraform init -backend-config="envs/${{ matrix.env }}/backend.hcl"
        working-directory: ${{ matrix.dir }}

      # Run Terraform Plan
      - name: Terraform Plan
        if: github.event_name == 'workflow_dispatch'
        run: |
          terraform plan -var="env=${{ matrix.env }}"
          sleep 6
        working-directory: ${{ matrix.dir }}

      # Run Terraform Apply
      - name: Terraform Apply - ${{ matrix.env }}
        if: github.event_name == 'workflow_dispatch'
        run: |
          terraform apply -var="env=${{ matrix.env }}" -auto-approve
        working-directory: ${{ matrix.dir }}

ref: https://github.com/gonkunkun/terraform-template/blob/main/.github/workflows/terraform-apply.yml

Run Workflows Manually from GitHub Actions.

Conclusion

Thank you for reading through to the end.
Wishing you a great Terraform life!