Post

Setting Up CI/CD for Terraform with GitHub Actions

In this article, let us look at how to set up a proper CI/CD pipeline for Terraform using GitHub Actions. If you have been running Terraform from your local machine, you might have noticed it works fine until you need to collaborate with others or want to automate deployments. That is where GitHub Actions comes in handy. We will go through the setup step by step, including the workflow file, state management, and some security considerations.

Why GitHub Actions for Terraform?

When I first started using Terraform, I would run it from my laptop. It was simple enough - write some code, run terraform plan, check the output, then terraform apply. But this approach has problems. First, your local state file becomes the source of truth, which is risky. Second, if someone else needs to make changes, they need your state file or they risk breaking things. Third, there is no review process - you just run commands and hope nothing breaks.

GitHub Actions solves these problems by giving us a consistent environment to run Terraform, proper state management through remote backends, and pull request reviews before any changes are applied.

Prerequisites

Before we get into the workflow, we need a few things set up in GCP:

  1. A service account for Terraform with appropriate permissions
  2. A GCS bucket for storing the Terraform state file
  3. GitHub secrets to store our credentials

For the service account, you want to create a dedicated one rather than using your personal account. Give it the minimum permissions needed - for example, if you are only managing GCS buckets, the Storage Admin role is enough. If you are managing BigQuery resources, add BigQuery Admin. The principle here is least privilege.

Create a GCS bucket for the state file. You can use one bucket for multiple projects by organizing state files in different paths. Call it something like terraform-state-bucket.

Setting Up GitHub Secrets

In your GitHub repository, go to Settings > Secrets and Variables > Actions. We need to add the GCP service account key as a secret. Create a secret named GCP_SA_KEY and paste the entire contents of your service account JSON key file.

Also add GCP_PROJECT_ID and GCP_REGION as secrets or variables. Using variables for non-sensitive data like project ID and region makes them easier to update later.

One thing to note - using JSON key files is not the most secure approach since they are long-lived credentials. In a production setup, you should look into Workload Identity Federation instead, which allows GitHub Actions to authenticate to GCP without long-lived keys. But for getting started, the JSON key approach is simpler to understand.

The GitHub Actions Workflow

Now let us create the workflow file. Create .github/workflows/terraform.yml in your repository:

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
name: 'Terraform CI/CD'

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

env:
  TF_VERSION: '1.7.0'

jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest
    
    permissions:
      contents: read
      pull-requests: write

    steps:
    - name: Checkout
      uses: actions/checkout@v4

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

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

    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@v2

    - name: Terraform Init
      run: terraform init
      env:
        GOOGLE_CREDENTIALS: $

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

    - name: Terraform Plan
      run: terraform plan -input=false
      env:
        GOOGLE_CREDENTIALS: $

    - name: Terraform Apply
      if: github.ref == 'refs/heads/main' && github.event_name == 'push'
      run: terraform apply -auto-approve -input=false
      env:
        GOOGLE_CREDENTIALS: $

Let us break down what this workflow does. It triggers on pushes and pull requests to the main branch. It checks out the code, sets up Terraform, authenticates to GCP using our service account key, initializes Terraform, checks formatting, runs a plan, and applies changes only when merging to main.

The terraform fmt -check step ensures your code follows standard formatting. This catches things like inconsistent indentation or missing newlines. It is a small thing but keeps the codebase clean.

The plan step runs on both pull requests and pushes, but the apply step only runs on pushes to main. This means when someone opens a pull request, they can see what changes would be made before anything is actually deployed.

Configuring the Terraform Backend

Your Terraform code needs to know where to store the state file. Add this to your main.tf or a separate backend.tf:

1
2
3
4
5
6
terraform {
  backend "gcs" {
    bucket = "your-terraform-state-bucket"
    prefix = "my-project/terraform/state"
  }
}

The prefix allows you to store multiple projects’ state in the same bucket. This is useful if you have separate Terraform configurations for different services.

Pull Request Workflow

One of the best parts of this setup is the pull request workflow. When someone opens a PR, the workflow runs terraform plan and shows the output. Team members can review the planned changes before approving. This catches mistakes early and creates an audit trail of who made what changes and when.

For our use case, this is much safer than having multiple people run Terraform from their laptops. Everyone sees the same plan output, and nothing gets applied until the PR is merged.

Limitations and Production Considerations

This setup works for small teams and simple projects, but there are things to consider for production:

  1. State locking: The GCS backend supports state locking, which prevents concurrent runs. Make sure this is enabled to avoid corrupting your state file when multiple PRs merge at once.

  2. Plan output visibility: By default, plan output in GitHub Actions might contain sensitive values. Consider using tools like terraform plan -out=plan.tfplan and storing artifacts, or use a Terraform Cloud/Enterprise setup for better secret handling.

  3. Approval gates: The workflow above auto-applies on merge. You might want to add a manual approval step for production environments, especially for destructive changes.

  4. Drift detection: Consider adding a scheduled workflow that runs terraform plan periodically to detect drift - cases where someone made manual changes outside of Terraform.

  5. Cost management: Running Terraform on every push can add up if you have many resources. Consider using a tool like Infracost to estimate costs in your PR comments.

Comparison: Local vs CI/CD Approach

AspectLocal TerraformGitHub Actions CI/CD
State managementLocal fileRemote GCS backend
CollaborationDifficult (file sharing)Easy (shared state)
Review processNonePull request reviews
Audit trailLimitedFull Git history
SecurityKeys on laptopsSecrets in GitHub
ConsistencyVaries by machineIdentical environment

For anything beyond personal projects, the CI/CD approach is clearly better. The upfront setup time pays off quickly in reduced risk and easier collaboration.

Wrapping Up

Setting up CI/CD for Terraform might seem like overkill for small projects, but it quickly becomes essential as your infrastructure grows or your team expands. The workflow we covered gives you a solid foundation - automated plans on pull requests, controlled applies on merge, and proper state management.

If you are currently running Terraform locally, I would suggest starting with just the plan-on-PR part. Get comfortable reviewing infrastructure changes in pull requests before adding the apply step. And definitely look into Workload Identity Federation instead of JSON keys once you have the basics working.

The goal is not to have the perfect setup on day one, but to have something that makes your infrastructure changes safer and more visible than running commands from your laptop.

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