CI/CD for Terraform with GitHub Actions
A comprehensive guide to building a production-ready CI/CD pipeline for Terraform using GitHub Actions. Covers OIDC authentication, workflow setup, pull request checks, and automated deployment strategies.
What You'll Learn
- Why you should automate your Terraform workflows.
- How to set up secure, passwordless authentication between GitHub and AWS using OIDC.
- How to build a GitHub Actions workflow that runs `terraform plan` on every pull request.
- How to create a separate workflow to automatically run `terraform apply` after merging.
- How to manage multiple environments (e.g., staging and production) securely.
📋 Prerequisites
- A Terraform project with an S3 backend and DynamoDB table already configured.
- Your project code hosted in a GitHub repository.
- Permissions to create IAM roles in your AWS account.
🏷️ Topics Covered
💡 From Manual `apply` to Automated Infrastructure
Running `terraform apply` from your laptop works for personal projects, but for a team, it's risky and inefficient. A CI/CD pipeline is the professional standard. It automates your workflow, enforces peer review through pull requests, and creates a reliable, repeatable process for deploying infrastructure changes safely.
Why Automate Terraform with a CI/CD Pipeline?
Automating your Infrastructure as Code (IaC) is one of the most impactful DevOps practices. It provides a single, controlled path for all infrastructure changes, offering four key benefits:
Consistency & Repeatability
The pipeline runs the same commands in the same environment every time, eliminating "it works on my machine" issues and ensuring consistent deployments.
Collaboration & Peer Review
By running `terraform plan` in pull requests, your team can review infrastructure changes before they are applied, catching errors and enforcing best practices.
Speed & Agility
Once a change is approved and merged, the pipeline automatically deploys it. This removes manual bottlenecks and allows you to deliver infrastructure changes faster.
Enhanced Security
Using passwordless OIDC roles and GitHub's secret management, you can avoid storing long-lived AWS keys and limit who can make production changes.
Prerequisites: Setting Up Secure AWS & GitHub Integration
Before writing the workflow, we need to establish a secure trust relationship between your GitHub repository and your AWS account. We will use OIDC, which allows GitHub Actions to assume an IAM role without needing static AWS access keys.
Step 1: Create the AWS IAM OIDC Provider
In your AWS account, go to IAM → Identity providers. If you don't already have one for GitHub, create a new provider with the following settings:
- Provider URL: `https://token.actions.githubusercontent.com`
- Audience: `sts.amazonaws.com`
Step 2: Create the IAM Role for GitHub Actions
Next, create an IAM role that your GitHub workflow will assume. When creating the role, select "Web identity," choose the GitHub identity provider you just created, and enter your repository name (e.g., `my-org/my-repo`).
Attach two policies to this role:
- A policy with the permissions needed to access your S3 backend and DynamoDB table (see the previous guide for an example).
- A policy with the permissions Terraform needs to manage your actual AWS resources (e.g., create S3 buckets, EC2 instances, etc.).
Step 3: Add the Role ARN to GitHub Secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions. Create a new repository secret with the following:
- Name: `AWS_ROLE_TO_ASSUME`
- Value: The ARN of the IAM role you just created (e.g., `arn:aws:iam::123456789012:role/GitHubActionsTerraformRole`).
Building the `plan` Workflow for Pull Requests
Our first workflow will trigger on every pull request to the `main` branch. It will run `terraform plan` and post the output as a comment on the PR for review.
Create a file at `.github/workflows/terraform-plan.yml` in your repository.
🔄 YAML: `.github/workflows/terraform-plan.yml`
name: 'Terraform Plan'
on:
pull_request:
branches:
- main
paths:
- '**.tf'
- '**.tfvars'
permissions:
id-token: write # Required for OIDC authentication
contents: read # Required to check out the code
pull-requests: write # Required to post the plan comment
jobs:
terraform:
name: 'Terraform'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: us-east-1 # Or your desired region
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
id: init
run: terraform init
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Update Pull Request with Plan
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
env:
PLAN: "${{ steps.plan.outputs.stdout }}"
with:
script: |
const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
#### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
Validation Output
\`\`\`\n
${{ steps.validate.outputs.stdout }}
\`\`\`
#### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
Show Plan
\`\`\`terraform\n
${process.env.PLAN}
\`\`\`
*Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
}) Building the `apply` Workflow for Merges
Our second workflow will trigger automatically when a pull request is merged into the `main` branch. It will run `terraform apply` to deploy the changes.
Create a file at `.github/workflows/terraform-apply.yml`.
🚀 YAML: `.github/workflows/terraform-apply.yml`
name: 'Terraform Apply'
on:
push:
branches:
- main
paths:
- '**.tf'
- '**.tfvars'
permissions:
id-token: write
contents: read
jobs:
terraform:
name: 'Terraform'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: us-east-1 # Or your desired region
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve Managing Staging and Production Environments
A single `apply` workflow is great, but real-world projects have multiple environments. We can extend our pattern using different branches and GitHub Environments for protection.
Step 1: Use a Git Branching Strategy
A common strategy is to have a `staging` branch and a `main` branch. Merging to `staging` deploys to your staging AWS account, while merging to `main` deploys to production.
Step 2: Use GitHub Environments
In your GitHub repository, go to Settings → Environments. Create two environments: `staging` and `production`. Environments allow you to:
- Set Protection Rules: Require a specific person or team to approve any deployment to that environment.
- Use Environment-Specific Secrets: You can create a secret (like `AWS_ROLE_TO_ASSUME`) that has a different value for each environment.
Step 3: Update the `apply` Workflow
Modify your `terraform-apply.yml` to be environment-aware.
🚀 YAML: `terraform-apply.yml` (Multi-Environment)
name: 'Terraform Apply'
on:
push:
branches:
- main
- staging
paths:
- '**.tf'
- '**.tfvars'
jobs:
deploy:
runs-on: ubuntu-latest
# Set the environment based on the branch name
environment:
name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
# The AWS_ROLE_TO_ASSUME secret will now come from the
# specific GitHub Environment (staging or production)
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
- name: Select Terraform Workspace
run: terraform workspace select ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
- name: Terraform Apply
run: terraform apply -auto-approve