CI/CD Pipelines

Automate Terraform deployments with GitHub Actions and other CI tools

10 min read

CI/CD Pipelines

In the previous tutorial, we learned how to debug Terraform like a pro. Now let's level up to the final boss: automating everything.

Running Terraform from your laptop doesn't scale. Teams need automated pipelines with reviews, approvals, and audit trails. Let's set up proper automation so you can deploy infrastructure with confidence.

Why Automate?

"Can't I just keep running terraform apply from my machine?"

Sure, if you like chaos. Here's why automation wins:

  • Consistency — Same process every time, no "oops I forgot to init"
  • Review — PRs show exactly what will change
  • Audit — Track who changed what, when
  • Safety — No accidental applies from the wrong directory
  • Speed — Automatic deploys on merge

GitOps Workflow

The basic idea is beautifully simple:

1. Create feature branch
2. Make Terraform changes
3. Open PR → Pipeline runs terraform plan
4. Review plan output in PR
5. Approve and merge → Pipeline runs terraform apply

GitHub Actions

"Where do I start?"

GitHub Actions is the most popular choice. Let's build up from simple to production-ready.

Basic Workflow

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  terraform:
    runs-on: ubuntu-latest
    
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.6.0"

      - name: Terraform Init
        run: terraform init

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

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan -out=tfplan

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve tfplan

Plan Output in PR

"Can I see the plan output right in the PR?"

Absolutely! This is where it gets really cool:

# .github/workflows/terraform.yml
name: Terraform

on:
  pull_request:
    branches: [main]

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color
        continue-on-error: true

      - name: Comment Plan on PR
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### Terraform Plan 📖
            
            \`\`\`hcl
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            
            *Pushed by: @${{ github.actor }}*`;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });

      - name: Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

Multi-Environment Pipeline

"What about dev, staging, and prod?"

Use a matrix strategy to plan across all environments:

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  plan:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, prod]
    
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        working-directory: environments/${{ matrix.environment }}
        run: terraform init

      - name: Terraform Plan
        working-directory: environments/${{ matrix.environment }}
        run: terraform plan -out=tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets[format('AWS_ACCESS_KEY_ID_{0}', matrix.environment)] }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets[format('AWS_SECRET_ACCESS_KEY_{0}', matrix.environment)] }}

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ matrix.environment }}
          path: environments/${{ matrix.environment }}/tfplan

  apply:
    needs: plan
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production  # Requires approval
    
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3

      - uses: actions/download-artifact@v4
        with:
          name: tfplan-prod
          path: environments/prod

      - name: Terraform Init
        working-directory: environments/prod
        run: terraform init

      - name: Terraform Apply
        working-directory: environments/prod
        run: terraform apply -auto-approve tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_prod }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_prod }}

GitHub Environments

"How do I require approval before deploying to production?"

GitHub Environments to the rescue:

  1. Go to repo Settings → Environments
  2. Create production environment
  3. Add required reviewers
  4. Set deployment branch rules

Now nobody can apply to prod without approval. How cool is that?

jobs:
  apply:
    environment: production  # Waits for approval
    steps:
      - run: terraform apply

OIDC Authentication (Recommended)

"Storing AWS credentials as GitHub secrets feels wrong."

It is. Use OIDC instead — no long-lived credentials needed.

AWS Setup

# Create OIDC provider in AWS
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
  
  client_id_list = ["sts.amazonaws.com"]
  
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# Role for GitHub Actions
resource "aws_iam_role" "github_actions" {
  name = "github-actions-terraform"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRoleWithWebIdentity"
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:*"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "github_actions" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"  # Scope down!
}

GitHub Actions with OIDC

jobs:
  terraform:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
          aws-region: us-west-2

      - uses: hashicorp/setup-terraform@v3

      - run: terraform init
      - run: terraform apply -auto-approve

No secrets needed! Credentials are short-lived and scoped. This is the way.

Terraform Cloud

"Is there a managed service for all this?"

HashiCorp's Terraform Cloud handles state, plans, applies, and approvals for you:

Setup

# main.tf
terraform {
  cloud {
    organization = "your-org"

    workspaces {
      name = "my-app-prod"
    }
  }
}

GitHub Actions with Terraform Cloud

jobs:
  terraform:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        run: terraform plan
        # Plan runs in Terraform Cloud

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve
        # Apply runs in Terraform Cloud

VCS Integration

Or skip GitHub Actions entirely and connect Terraform Cloud directly to GitHub:

  1. Create workspace in Terraform Cloud
  2. Connect to GitHub repo
  3. Configure auto-apply or manual approval

PRs automatically show plan. Merge triggers apply. Zero pipeline code needed!

GitLab CI

"What if I'm using GitLab?"

GitLab CI works great too:

# .gitlab-ci.yml
stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: ${CI_PROJECT_DIR}

image:
  name: hashicorp/terraform:1.6
  entrypoint: [""]

cache:
  paths:
    - ${TF_ROOT}/.terraform

before_script:
  - cd ${TF_ROOT}
  - terraform init

validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check

plan:
  stage: plan
  script:
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - tfplan

apply:
  stage: apply
  script:
    - terraform apply -auto-approve tfplan
  dependencies:
    - plan
  when: manual
  only:
    - main

Atlantis

"What if I want something self-hosted that works through PR comments?"

Atlantis is a self-hosted PR automation tool — you just type atlantis plan in a comment and it runs:

Deployment

# docker-compose.yml
version: '3'
services:
  atlantis:
    image: ghcr.io/runatlantis/atlantis:latest
    ports:
      - "4141:4141"
    environment:
      - ATLANTIS_GH_USER=atlantis-bot
      - ATLANTIS_GH_TOKEN=${GITHUB_TOKEN}
      - ATLANTIS_GH_WEBHOOK_SECRET=${WEBHOOK_SECRET}
      - ATLANTIS_REPO_ALLOWLIST=github.com/your-org/*
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}

Usage

In PR comments (it's like magic):

atlantis plan         # Run terraform plan
atlantis apply        # Run terraform apply (after approval)
atlantis plan -d .    # Plan specific directory

Configuration

# atlantis.yaml (in repo root)
version: 3
projects:
  - name: production
    dir: environments/prod
    workflow: default
    autoplan:
      enabled: true
      when_modified:
        - "*.tf"
        - "../modules/**/*.tf"
    apply_requirements:
      - approved
      - mergeable

  - name: development
    dir: environments/dev
    workflow: default
    autoplan:
      enabled: true

Security Best Practices

"How do I make sure this is actually secure?"

Great question. Automation without security is just fast chaos.

Least Privilege

Give CI only the permissions it needs — nothing more:

# Minimal permissions for CI
data "aws_iam_policy_document" "terraform_ci" {
  statement {
    actions = [
      "ec2:*",
      "rds:*",
      "s3:*",
      # Only what Terraform needs
    ]
    resources = ["*"]
  }

  # State bucket access
  statement {
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:DeleteObject"
    ]
    resources = ["arn:aws:s3:::my-terraform-state/*"]
  }

  statement {
    actions = [
      "dynamodb:GetItem",
      "dynamodb:PutItem",
      "dynamodb:DeleteItem"
    ]
    resources = ["arn:aws:dynamodb:*:*:table/terraform-locks"]
  }
}

Secret Management

"Where do I put my database passwords?"

Never in code. Use secrets management:

# Use GitHub secrets
env:
  TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}

# Or fetch from vault
- name: Get Secrets
  uses: hashicorp/vault-action@v2
  with:
    url: https://vault.example.com
    method: github
    secrets: |
      secret/data/terraform db_password | TF_VAR_db_password

Branch Protection

Non-negotiable:

  • Require PR reviews
  • Require status checks (plan must pass)
  • Prevent direct pushes to main

Seriously, turn these on right now if you haven't already.

Pipeline Best Practices

Here are the patterns that'll keep you out of trouble.

1. Always Plan First

Never apply without saving a plan first:

- name: Plan
  run: terraform plan -out=tfplan

- name: Apply
  run: terraform apply tfplan  # Use saved plan

2. Lock State During CI

"What if two pipelines run at the same time?"

Bad things. Use concurrency groups:

# Prevent concurrent runs
concurrency:
  group: terraform-${{ github.ref }}
  cancel-in-progress: false

3. Fail on Drift

- name: Check for Drift
  run: |
    terraform plan -detailed-exitcode
    # Exit code 2 = changes detected

4. Notify on Failure

- name: Notify Slack
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    channel-id: 'deployments'
    slack-message: 'Terraform failed: ${{ github.event.pull_request.html_url }}'

5. Cost Estimation

"Can I see how much this will cost before I deploy?"

Infracost shows cost estimates right in your PR:

- name: Infracost
  uses: infracost/actions/setup@v2

- name: Cost Estimate
  run: |
    infracost breakdown --path=. --format=json --out-file=infracost.json
    infracost comment github --path=infracost.json \
      --repo=$GITHUB_REPOSITORY \
      --pull-request=${{ github.event.pull_request.number }}

Complete Production Pipeline

Here's the whole enchilada — validation, security scanning, planning with PR comments, and gated production apply:

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read
  pull-requests: write
  id-token: write

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      
      - run: terraform fmt -check -recursive
      - run: terraform init -backend=false
      - run: terraform validate

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bridgecrewio/checkov-action@master
        with:
          directory: .
          framework: terraform

  plan:
    needs: [validate, security]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-west-2

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        continue-on-error: true

      - name: Comment Plan
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const plan = `${{ steps.plan.outputs.stdout }}`;
            const truncated = plan.length > 65000 
              ? plan.substring(0, 65000) + '\n\n... (truncated)'
              : plan;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `### Terraform Plan\n\`\`\`hcl\n${truncated}\n\`\`\``
            });

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: tfplan

      - name: Check Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

  apply:
    needs: plan
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-west-2

      - uses: hashicorp/setup-terraform@v3

      - uses: actions/download-artifact@v4
        with:
          name: tfplan

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        run: terraform apply -auto-approve tfplan

Congratulations! 🎉

You did it! You've completed the entire Terraform tutorial series. That's 15 tutorials from "what is Terraform?" to fully automated CI/CD pipelines.

Here's everything you've learned along the way:

  • Writing Terraform configurations from scratch
  • Mastering HCL syntax and expressions
  • Managing state safely (remote backends, locking, the works)
  • Creating reusable modules
  • Handling secrets without getting your company on the news
  • Managing multiple environments
  • Importing existing resources
  • Testing and validating infrastructure
  • Debugging when things go sideways
  • Automating everything with CI/CD

You went from zero to production-ready. That's seriously impressive.

Now go build something awesome! 🚀