Guides/TerraformTerraform/Terraform in CI/CD and Team Workflows

Terraform in CI/CD and Team Workflows

Run Terraform from a pipeline, not laptops: plan on pull requests, apply the reviewed plan on merge, OIDC for credentials, remote state with locking, and prod approvals.


Running Terraform from your laptop works right up until a second person joins, and then it quietly falls apart. Two people apply at once and race on the same state. Someone applies uncommitted local changes and nobody can tell what actually shipped. A plan looked fine on one machine and did something different on another because of a stale provider version. The fix is to stop treating Terraform as a personal command and start treating it as a pipeline: every change flows through pull requests, gets a plan you can read and review, and applies from a controlled environment with credentials nobody holds in their hands. This guide builds that workflow, with a concrete GitHub Actions example you can adapt.

Why laptops do not scale

A single engineer running terraform apply locally has three invisible dependencies: their local state (or their access to remote state), their local credentials, and their local working directory. None of that is reviewable, and all of it drifts.

  • No review. A local apply changes production the instant you hit enter. There is no diff for a teammate to look at, no record of what was proposed versus what ran, and no chance to catch a destructive change before it happens.
  • No consistency. Provider versions, Terraform versions, and even environment variables differ between machines. The same code can produce different plans, which means "it worked for me" is a real and dangerous phrase in infrastructure.
  • No audit trail. When something breaks at 2am, "who changed what, when, and why" has no answer if changes come from laptops. There is no commit, no reviewer, no log tying the change to a person and a reason.
  • Credential sprawl. Every engineer who can apply needs long-lived cloud admin credentials on their machine. That is a large attack surface and an offboarding headache.

A pipeline solves all four at once. It runs from one consistent environment, forces every change through code review, records every plan and apply, and holds credentials centrally (or better, holds none at all - more on OIDC below). The rest of this guide is the pattern that gets you there.

The core pattern: plan on PR, apply on merge

The workflow that has become standard is simple to state and worth internalizing before you look at any YAML:

  1. On a pull request, run terraform fmt -check, terraform validate, and terraform plan. Post the plan output where reviewers can read it (a PR comment). Nothing changes in the real world - this is the proposal.
  2. A human reviews the plan alongside the code diff. The plan is the important artifact: code review tells you the intent, the plan tells you the actual effect ("this will destroy the database" is visible in the plan even when the code looks innocent).
  3. On merge to main, run terraform apply. For anything that matters, gate the apply behind a manual approval so a person confirms right before it runs.

The subtle-but-critical detail: save the plan as an artifact and apply exactly that plan. When you run terraform plan -out=tfplan, Terraform writes a binary plan file. On apply, you feed that file back in with terraform apply tfplan instead of letting apply compute a fresh plan. This guarantees that what got reviewed is what runs. If you re-plan at apply time, the world may have changed between review and merge (someone else applied, a resource drifted, a data source now returns something new) and you could apply something nobody looked at. Plan once, review that plan, apply that same plan.

This is the whole game. Everything else - OIDC, remote state, approval gates - is machinery that makes this pattern safe and reproducible.

Credentials: OIDC instead of long-lived secrets

The old way to give CI access to your cloud was to store an access key and secret as pipeline secrets. Those keys are long-lived, powerful, and sitting in a settings page - exactly the kind of credential that leaks in a log or a compromised action.

The modern way is OIDC (OpenID Connect). Your CI provider (GitHub Actions, GitLab, etc.) can present a short-lived, signed identity token that says "I am this repo, this branch, this workflow." Your cloud provider trusts that token issuer and exchanges it for temporary credentials scoped to a specific role. No secret is ever stored in the pipeline; the credential lives for minutes and is bound to the exact workflow that requested it.

In practice you set this up once per cloud:

  • AWS - create an IAM OIDC identity provider for GitHub's token issuer, then an IAM role whose trust policy allows your specific repo/branch to assume it. The pipeline uses aws-actions/configure-aws-credentials with role-to-assume and no keys.
  • GCP - configure Workload Identity Federation with a pool and provider trusting the GitHub issuer, mapped to a service account.
  • Azure - register federated credentials on an app registration, then use azure/login with client-id, tenant-id, and no client secret.

The payoff: there are no cloud keys to rotate, leak, or offboard. Scope the role tightly (the pipeline should only touch what it manages) and you have turned "every engineer holds admin keys" into "one narrowly-scoped role, assumable only by one workflow, for a few minutes at a time."

State: remote backend with locking

If the pipeline is the only place that applies, the pipeline needs shared, locked state. Local state (a terraform.tfstate file on disk) cannot be shared and cannot be locked, so a pipeline must use a remote backend.

Two things matter here:

  • Remote storage so state lives in one place every run reads and writes - an S3 bucket, a GCS bucket, an Azure storage container, or Terraform Cloud. Never commit state to git; it contains resource IDs and often secrets.
  • State locking so two runs cannot apply at once. When one run holds the lock, a second run waits or fails fast instead of racing and corrupting state. S3 backends use DynamoDB (or S3 native locking) for this; GCS and Terraform Cloud lock automatically.

Even with everything else perfect, skipping the lock reintroduces the exact race condition a pipeline was supposed to eliminate - two merges applying concurrently. For the full backend setup (bucket, lock table, encryption, and workspace layout) see the Remote state and backends guide; here it is enough to know the pipeline points at a locked remote backend and never at local state.

Protecting production with required approvals

Not every apply deserves the same trust. A change to a dev sandbox can apply automatically on merge; a change to production should not touch anything until a human confirms. The tool for this in GitHub Actions is environments with required reviewers.

You define a production environment in the repo settings and mark it as requiring approval from a named set of reviewers. Any job that declares environment: production pauses before it runs and waits for one of those reviewers to click approve. The job that produced the plan and posted it for review can run freely; the job that applies to prod sits behind the gate. This gives you a clean split: automated up to the point of real-world change, human-confirmed for the change itself.

The same environment feature can also restrict which branches may deploy (only main), hold environment-specific secrets, and enforce a wait timer. Combined with the plan-and-apply-the-saved-plan pattern, the approver is confirming a specific, reviewed plan, not signing a blank check.

A GitHub Actions workflow

Here is a compact workflow that ties it together: plan on pull requests, apply the saved plan on merge, credentials via OIDC, and a production approval gate. Adapt the cloud login step to your provider.

name: terraform

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

permissions:
  id-token: write      # required for OIDC
  contents: read
  pull-requests: write # to post the plan comment

defaults:
  run:
    working-directory: infra

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

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/terraform-ci
          aws-region: eu-west-1        # no stored keys - OIDC

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.5     # pin it, so every run is identical

      - run: terraform fmt -check
      - run: terraform init
      - run: terraform validate

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

      # Upload the plan so the apply job runs EXACTLY this plan
      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: infra/tfplan

      - name: Comment plan on PR
        if: github.event_name == 'pull_request'
        run: terraform show -no-color tfplan > plan.txt
        # a marketplace action then posts plan.txt as a PR comment

  apply:
    needs: plan
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production   # <- pauses here for a required reviewer
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/terraform-ci
          aws-region: eu-west-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.5

      - run: terraform init

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

      # Apply the SAVED plan - no fresh plan, no surprises
      - run: terraform apply -input=false tfplan

A few things worth calling out in that file. The permissions block grants id-token: write, which is what lets the workflow request an OIDC token - without it, OIDC login fails. The Terraform version is pinned so plan and apply run identically every time. The plan job uploads tfplan and the apply job downloads and applies that same file, so the approver in the production environment is confirming the exact plan that was reviewed. And the apply job only runs on a push to main, never on a pull request, so proposing a change can never accidentally enact it.

One caveat with saved plan files: they are tied to the state and provider versions at plan time, so if a lot changes between plan and apply the apply can reject the stale plan. That is a feature, not a bug - it is Terraform refusing to run something that no longer matches reality. Re-run the plan, re-review, re-apply.

When to reach for specialized tools

The GitHub Actions pattern above is enough for most teams, and it keeps everything in one place you already understand. But as the number of workspaces, teams, and environments grows, purpose-built Terraform automation can carry weight that raw CI does not:

  • Atlantis - an open-source, self-hosted service that listens to your pull requests and runs plan and apply as PR comments (atlantis plan, atlantis apply). It handles locking across PRs and keeps the whole workflow in the PR thread. Popular when you want the pipeline pattern without hand-writing it per repo.
  • Terraform Cloud / HCP Terraform - HashiCorp's managed offering: remote state, remote runs, a plan/apply UI with approvals, policy enforcement (Sentinel/OPA), and a private module registry, without you running any of it.
  • Spacelift - a managed platform with policy-as-code, drift detection, stack dependencies, and multi-IaC support, aimed at larger estates that outgrow a single CI pipeline.

The decision is about scale and governance, not capability. A handful of workspaces run happily on the GitHub Actions pattern here. Dozens of stacks with multiple teams, policy requirements, and drift concerns are where a dedicated tool starts paying for itself. Start with the pipeline; adopt a platform when the pipeline starts creaking.

The shape of it

A Terraform pipeline is one idea with supporting machinery: never let infrastructure change from a laptop, always let it change through a reviewed, recorded, controlled run. Pull requests run fmt, validate, and plan, and post that plan for a human to read. Merge to main applies - and it applies the saved plan, so what shipped is what was reviewed. Credentials come from OIDC, so no long-lived keys sit in a settings page. State lives in a locked remote backend, so two runs can never race. Production sits behind a required approval, so a person confirms the real change. Get that loop in place and infrastructure changes stop being a private act on someone's machine and become a reviewable, auditable, repeatable part of your delivery. Pair this with solid testing and validation in the same pipeline and most infrastructure mistakes are caught before they ever reach production.