Skip to content
ProQu
All articles

Insights4 min read

Terraform CI/CD with GitHub Actions, step by step

A pipeline that plans on every pull request and applies on merge: remote state, OIDC in place of stored keys, approvals and drift detection.

What the pipeline should do

Running Terraform from a laptop works for one person. It stops working when a team shares the infrastructure. Nobody knows who applied what, and two people can change the same thing at once.

A good pipeline does six things:

  1. It shows a plan on every pull request, before anything changes.
  2. It applies only after the change is merged.
  3. It stores no long-lived cloud keys.
  4. It runs one apply at a time.
  5. It asks a person to approve changes to production.
  6. It tells you when the real infrastructure no longer matches the code.

The examples use AWS. The same pattern works on Azure and Google Cloud, and with OpenTofu in place of Terraform.

Before you start

Put the state in a shared, locked place. The state file is Terraform’s record of what exists. Keep it in a storage bucket with versioning turned on, and turn on state locking, so two runs cannot write at the same time.

Use OIDC, not stored keys. With OpenID Connect, GitHub proves to your cloud which repository and branch a job runs from. The cloud then hands out credentials that last for that one job. There is no access key to leak or to rotate.

Create two roles. The plan role can only read. The apply role can change things, and it can be assumed only from the main branch. A pull request from a branch can never get write access.

The plan workflow

This workflow runs on every pull request that touches the infra folder.

name: terraform-plan

on:
  pull_request:
    paths: ["infra/**"]

permissions:
  contents: read
  id-token: write # needed for OIDC

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
          aws-region: eu-central-1
      - uses: hashicorp/setup-terraform@v4
      - run: terraform fmt -check -recursive
      - run: terraform init -input=false
      - run: terraform validate
      - run: terraform plan -input=false -no-color

The reviewer reads two things: the code change and the plan. The plan is the part that shows what will really happen. Many teams add a step that posts the plan as a comment on the pull request, so nobody has to open the job log.

The apply workflow

This workflow runs when a change reaches the main branch.

name: terraform-apply

on:
  push:
    branches: [main]
    paths: ["infra/**"]

permissions:
  contents: read
  id-token: write

concurrency:
  group: terraform-production
  cancel-in-progress: false

jobs:
  apply:
    runs-on: ubuntu-latest
    environment: production
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-apply
          aws-region: eu-central-1
      - uses: hashicorp/setup-terraform@v4
      - run: terraform init -input=false
      - run: terraform apply -input=false -auto-approve

Two lines do most of the safety work:

  • environment: production connects the job to a GitHub environment. Add required reviewers to that environment, and the job waits until one of them approves.
  • concurrency makes sure only one apply runs at a time. A second run waits for the first.

One thing to know: the apply makes a new plan at the moment it runs. If something changed between the review and the merge, the result can differ from the plan that was reviewed. Require branches to be up to date before merging, and keep the time between review and merge short.

Detect drift

Drift is a difference between the code and the real infrastructure. It usually comes from a change that someone made by hand in the cloud console.

Run a plan on a schedule, for example every night, with one extra flag:

terraform plan -detailed-exitcode

The exit code tells you the result: 0 means no changes, 2 means the infrastructure has drifted, and 1 means an error. Send an alert on 2, and decide whether to put the change into the code or to undo it.

Rules that keep it working

  • Protect the main branch. No direct pushes, and at least one review on every change.
  • Pin your versions. Set required_version, and commit the .terraform.lock.hcl file, so every run uses the same provider versions.
  • Keep secrets out of the repository. Use the secret manager of your cloud, and let Terraform read from it.
  • Split the state. One state per environment, and separate states for parts that change at different speeds. A small state plans faster and limits the damage of a mistake.
  • Do not apply from a laptop. Once the pipeline exists, it is the only way in.

If you are new to these tools, start with Terraform and Ansible: what each one is for. The Data Engineering & Operations page describes how we set this up with a team.

Want to talk about this?

Tell us what you are working on. We reply within one working day.

Talk to us