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:
- It shows a plan on every pull request, before anything changes.
- It applies only after the change is merged.
- It stores no long-lived cloud keys.
- It runs one apply at a time.
- It asks a person to approve changes to production.
- 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: productionconnects the job to a GitHub environment. Add required reviewers to that environment, and the job waits until one of them approves.concurrencymakes 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.hclfile, 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.