# Terraform CI/CD: Plan on PR, Apply on Merge

The pipeline ran `terraform apply` against a saved plan file. What it didn't account for: another engineer had merged a change to the same module forty minutes earlier. The plan was generated before that merge. By the time the pipeline applied it, the plan was already describing a state of the world that no longer existed.

The apply partially succeeded. Some resources were updated correctly. Others conflicted with the changes already applied. The state file ended up describing infrastructure that didn't match what was actually running — the worst possible outcome, because Terraform's ability to manage your infrastructure depends entirely on state accuracy.

The fix is straightforward once you understand it: never apply a plan that was generated in a different pipeline run than the one doing the apply. Plan and apply in the same job, with no gap between them where state can change.

* * *

## The Core Workflow

The pattern is simple:

*   **On pull request:** run `terraform plan`, post the output as a PR comment so reviewers see exactly what will change
    
*   **On merge to main:** run `terraform plan` again (fresh), then `terraform apply` immediately in the same job
    

The second plan on merge is what prevents the stale plan problem. Yes, you run plan twice. That's the correct behaviour — the PR plan is for review, the merge plan is for safety.

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

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

permissions:
  contents: read
  id-token: write        # Workload Identity Federation
  pull-requests: write   # post plan output as PR comment

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra

    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.TERRAFORM_SA }}

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

      - name: Terraform Init
        run: terraform init

      - name: Select Environment
        run: |
          ENV=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}
          echo "TF_VAR_FILE=environments/${ENV}.tfvars" >> $GITHUB_ENV

      - name: Terraform Plan
        id: plan
        run: terraform plan -var-file=${{ env.TF_VAR_FILE }} -no-color
        continue-on-error: true   # don't fail the job on plan errors yet

      - name: Post Plan to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### Terraform Plan
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            *Plan outcome: ${{ steps.plan.outcome }}*`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });

      - name: Fail on Plan Error
        if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -var-file=${{ env.TF_VAR_FILE }} -auto-approve
```

The `continue-on-error: true` on the plan step lets the pipeline post the error output as a PR comment before failing — without it, a failed plan kills the job before the comment step runs, and reviewers see nothing useful.

* * *

## Workload Identity Federation

The workflow above uses Workload Identity Federation instead of a service account key file. This matters: a key file stored in GitHub Secrets is a credential that exists permanently and needs to be rotated. WIF issues short-lived tokens — no long-lived credential, nothing to rotate, nothing to accidentally leak.

Setup is a one-time Terraform config:

```hcl
# infra/modules/wif/main.tf
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-actions"
  display_name              = "GitHub Actions"
}

resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

resource "google_service_account_iam_member" "github_wif" {
  service_account_id = google_service_account.terraform.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/your-org/your-repo"
}
```

Once provisioned, the `google-github-actions/auth` step handles token exchange automatically.

* * *

## The Terraform Service Account

The service account running `terraform apply` needs enough permissions to provision whatever you're managing. Avoid `roles/owner` or `roles/editor` — they're too broad and create unnecessary blast radius if the credentials are ever misused.

Create a dedicated Terraform service account with the specific roles it needs:

```hcl
resource "google_service_account" "terraform" {
  account_id   = "terraform-ci"
  display_name = "Terraform CI Service Account"
}

locals {
  terraform_roles = [
    "roles/run.admin",
    "roles/pubsub.admin",
    "roles/cloudsql.admin",
    "roles/secretmanager.admin",
    "roles/iam.serviceAccountAdmin",
    "roles/iam.workloadIdentityPoolAdmin",
    "roles/resourcemanager.projectIamAdmin",
  ]
}

resource "google_project_iam_member" "terraform_roles" {
  for_each = toset(local.terraform_roles)
  project  = var.gcp_project_id
  role     = each.value
  member   = "serviceAccount:${google_service_account.terraform.email}"
}
```

* * *

## GitLab CI

The same pattern translates directly to GitLab CI. The main difference is authentication — GitLab uses ID tokens for WIF instead of GitHub's OIDC:

```yaml
# .gitlab-ci.yml
variables:
  TF_VAR_FILE: "environments/${CI_ENVIRONMENT_NAME}.tfvars"

terraform:plan:
  stage: plan
  script:
    - terraform init
    - terraform plan -var-file=$TF_VAR_FILE -no-color | tee plan.txt
  artifacts:
    reports:
      terraform: plan.txt
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

terraform:apply:
  stage: apply
  script:
    - terraform init
    - terraform plan -var-file=$TF_VAR_FILE   # fresh plan — not the saved artifact
    - terraform apply -var-file=$TF_VAR_FILE -auto-approve
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
```

Note again: the apply job runs a fresh plan, not the saved artifact from the plan job. Same principle as GitHub Actions — the artifact is for review, not for applying.

* * *

## Branch Protection Rules

The pipeline is only as safe as your branch protection. Without protection on `main`, an engineer can push directly and trigger an apply without a plan review. Configure at minimum:

*   Require pull request reviews before merging
    
*   Require status checks to pass (the plan job) before merging
    
*   Restrict who can push directly to `main`
    

In GitHub this is under Settings → Branches → Branch protection rules. In GitLab it's under Settings → Repository → Protected branches.

* * *

## The Rule

Plan and apply in the same pipeline run. Never apply a plan that was generated in a different run. Post plan output on PRs so reviewers see infrastructure changes alongside code changes.

The rest is configuration — which service account, which secrets mechanism, which CI platform. The principle doesn't change.
