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 Locally | Running via CI/CD |
|---|---|
| State file on someone’s laptop | State file in a remote backend (GCS, S3) |
| No record of who applied what and when | Every change is tied to a PR and a commit |
| Credentials sitting as JSON files on disk | Secrets managed through GitHub, short-lived where possible |
| Easy to apply from the wrong branch | Plan runs on the PR branch, apply from main only |
Manual terraform fmt and validation | Linting 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:
- Developer creates a feature branch, makes Terraform changes, and opens a PR.
- GitHub Actions runs
terraform fmt,terraform validate, andterraform planon the PR branch. - The plan output is posted as a comment on the PR.
- After review and merge to
main, another workflow runsterraform 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.

