Post

CI/CD for Terraform with GitHub Actions: A Practical Guide

In this article, let us go through setting up a CI/CD pipeline for Terraform using GitHub Actions. If you have been running Terraform from your local machine so far, this will help you move to a workflow where changes go through pull requests, get planned automatically, and are applied only after review.

We will use GCP as the cloud provider, but the concepts apply to AWS or Azure with minimal changes. The goal is not to give you a copy-paste template but to walk through what actually matters when setting this up for a team, including the things that can go wrong.

Why Bother with CI/CD for Terraform?

You might wonder why you need a pipeline at all when terraform apply from your terminal works just fine. Here is the short answer: it works fine until it does not.

Running LocallyRunning via CI/CD
State file on someone’s laptopState file in a remote backend (GCS, S3)
No record of who applied what and whenEvery change is tied to a PR and a commit
Credentials sitting as JSON files on diskSecrets managed through GitHub, short-lived where possible
Easy to apply from the wrong branchPlan runs on the PR branch, apply from main only
Manual terraform fmt and validationLinting and validation run on every push

None of this is groundbreaking, but when you are on a team of three or more, the local approach breaks down quickly. Someone will forget to push their state file, or apply a change they did not mean to, and suddenly you are debugging infrastructure at 10 PM.

What Our Pipeline Looks Like

Here is the flow we are aiming for:

  1. Developer creates a feature branch, makes Terraform changes, and opens a PR.
  2. GitHub Actions runs terraform fmt, terraform validate, and terraform plan on the PR branch.
  3. The plan output is posted as a comment on the PR.
  4. After review and merge to main, another workflow runs terraform apply.

This keeps the review step human. Nobody wants Terraform applying changes without someone at least glancing at the plan output.

1. Prerequisites

Before setting up the workflows, you need a few things in place.

GCS Bucket for Remote State

Create a GCS bucket to hold your Terraform state. Do not use the same bucket you use for application data. Keep it separate and lock down access.

1
gsutil mb gs://myproject-terraform-state

Enable object versioning on this bucket. If someone corrupts the state file, you will want to be able to roll back.

1
gsutil versioning set on gs://myproject-terraform-state

Service Account for GitHub Actions

Create a dedicated service account that GitHub Actions will use. Do not reuse the service account you use from your local machine. Give it just the permissions it needs — Storage Admin if you are managing GCS buckets, or more if your Terraform modules touch other services.

Create a JSON key for this service account and store it as a GitHub secret called GCP_SA_KEY. Also store your GCP project ID as GCP_PROJECT_ID.

1
2
3
# These go in: Settings → Secrets and variables → Actions → New repository secret
GCP_SA_KEY       # The entire contents of the JSON key file
GCP_PROJECT_ID   # e.g., my-gcp-project-12345

A word of caution: long-lived JSON keys are not ideal for production. For a real setup, look into Workload Identity Federation, which lets GitHub Actions authenticate to GCP without storing a key at all. But JSON keys are the simpler starting point, and that is what we will use here.

2. The Terraform Configuration

Let us assume a simple Terraform module that creates a GCS bucket. Your main.tf might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
terraform {
  required_version = ">= 1.5"

  backend "gcs" {
    bucket = "myproject-terraform-state"
    prefix = "terraform/state"
  }

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

resource "google_storage_bucket" "data_bucket" {
  name          = "${var.project_id}-data-lake"
  location      = var.region
  force_destroy = false

  versioning {
    enabled = true
  }

  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age = 90
    }
  }
}

Pay attention to the backend "gcs" block. When Terraform runs in CI/CD, this tells it to use the remote bucket for state instead of a local file. Without this, every CI run would start from scratch, and you would have no idea what resources already exist.

3. The Plan Workflow (Runs on PR)

This workflow triggers when a pull request is opened or updated. It runs terraform plan and posts the output as a comment.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# .github/workflows/terraform-plan.yml
name: Terraform Plan

on:
  pull_request:
    branches:
      - main
    paths:
      - "terraform/**"

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

    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          credentials_json: $

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7"

      - name: Terraform fmt
        run: terraform fmt -check -recursive

      - name: Terraform init
        run: terraform init

      - name: Terraform validate
        run: terraform validate

      - name: Terraform plan
        id: plan
        run: |
          terraform plan -no-color -out=tfplan \
            -var="project_id=$" \
            -var="region=australia-southeast1"
          echo 'PLAN_OUTPUT<<EOF' >> $GITHUB_ENV
          terraform show -no-color tfplan >> $GITHUB_ENV
          echo 'EOF' >> $GITHUB_ENV

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            const output = process.env.PLAN_OUTPUT || 'Plan produced no output';
            const body = `## Terraform Plan\n\n\`\`\`hcl\n${output}\n\`\`\``;
            github.rest.issues.createComment({
              ...context.repo,
              issue_number: context.issue.number,
              body
            });

A few things to note here. The paths filter means this only runs when files under terraform/ change — no point running Terraform for a README edit. The -no-color flag keeps the plan output clean in the PR comment. And the plan file tfplan is created but not saved as an artifact in this simple version, since we only need the human-readable output.

4. The Apply Workflow (Runs on Merge)

Once the PR is merged to main, we apply the changes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# .github/workflows/terraform-apply.yml
name: Terraform Apply

on:
  push:
    branches:
      - main
    paths:
      - "terraform/**"

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

    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          credentials_json: $

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7"

      - name: Terraform init
        run: terraform init

      - name: Terraform apply
        run: |
          terraform apply -auto-approve \
            -var="project_id=$" \
            -var="region=australia-southeast1"

The -auto-approve flag is needed because there is no human to type “yes” in CI. This is safe only because the plan already ran and was reviewed during the PR stage. Do not skip the plan step and go straight to auto-approve on feature branches.

5. Locking State with a GCS Backend

When you have multiple developers or multiple CI runs happening, you can hit a situation where two terraform apply commands try to modify state at the same time. GCS backends handle this with a lock file, but you need to make sure your bucket configuration supports it.

If you see an error like Error locking state, it means another process holds the lock. This is actually a good thing — it is preventing a race condition. Wait a moment and retry. If the lock is stuck because a previous CI run crashed, you can manually remove the lock file from the GCS bucket:

1
gsutil rm gs://myproject-terraform-state/terraform/state/default.tflock

Only do this if you are certain no legitimate Terraform process is running.

6. Things to Be Careful About

There are a few things that catch people when they move Terraform to CI/CD for the first time.

Sensitive outputs in PR comments. If your Terraform outputs include secrets or connection strings, they will appear in the PR comment for anyone with repository access to see. Mark sensitive outputs explicitly:

1
2
3
4
output "db_password" {
  value     = google_sql_user.db_user.password
  sensitive = true
}

Plan and apply drift. It is possible that between the plan running on the PR and the apply running on merge, something changes in the actual infrastructure. If someone manually deleted a resource from the console, the apply will fail. For most small teams, this is rare enough that handling it manually is fine. In larger setups, you might want to re-run the plan on merge before applying.

Workspace confusion. If you use Terraform workspaces (dev, staging, prod), make sure your GitHub Actions workflows know which workspace they are targeting. Pass it as an environment variable or use separate branches per environment. Do not accidentally apply dev changes to production.

Cost of CI minutes. terraform plan on a large codebase can take five to ten minutes. If you are on a free GitHub Actions plan, this adds up. Cache the .terraform directory and the provider plugins between runs to speed things up.

7. What Changes in a Production Setup

The workflow we just built works for a small team getting started, but there are a few things you would upgrade for a production environment.

First, replace the JSON service account key with Workload Identity Federation. This removes the risk of a long-lived credential sitting in GitHub secrets.

Second, add a terraform plan step that outputs the plan as a binary artifact, and have the apply workflow download and use that exact plan file. This guarantees that what you reviewed is exactly what gets applied.

Third, consider using a tool like infracost in your plan workflow to estimate the cost impact of your infrastructure changes. It posts a cost estimate alongside the plan in the PR comment, which can catch expensive mistakes before they hit your billing account.

Finally, if you have multiple environments, use a matrix strategy or separate workflows per environment, and protect your production branch with GitHub branch protection rules. Require the plan check to pass before merging, and limit who can approve PRs that touch production infrastructure.

Wrapping Up

Moving Terraform from your laptop to GitHub Actions is one of those things that feels like overhead until you do it, and then you wonder why you did not do it earlier. The benefits — consistent runs, an audit trail, automated checks — compound as your team and infrastructure grow.

Start with the simple two-workflow setup we covered here. Get comfortable with it, then add the production improvements one at a time. You do not need to build the perfect pipeline on day one.

This post is licensed under CC BY 4.0 by the author.