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:
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!