intermediate 25 min read cicd-integration Updated: 2025-08-24

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.

📋 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

terraform github actions tutorial 2025ci cd pipeline for terraformgithub actions terraform workflowhow to automate terraform with github actionsterraform plan on pull requestterraform apply on merge to mainaws oidc for github actions terraformsecure terraform deployment pipelinegithub actions environments terraformautomated terraform validationterraform infrastructure automationdevops terraform pipeline

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

  1. A policy with the permissions needed to access your S3 backend and DynamoDB table (see the previous guide for an example).
  2. 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