Skip to main content
Terraform · lesson 14 of 17
Terraform intermediate Lesson 14 of 17

Also part of: DevOps GitHub Actions

Terraform CI/CD with GitHub Actions (plan/apply)

A GitHub Actions workflow for Terraform, with the real output of each step it runs: fmt -check, init, validate, plan -detailed-exitcode and a saved plan.

By SkillByExample · Updated

terraform ci-cd github-actions

A Terraform pipeline repeats the same commands you run by hand, with no prompts, and fails on a non-zero exit. This lesson shows the workflow file first, which is configuration. Then come the real outputs of each command it runs, captured locally with Terraform 1.16.5 and hashicorp/random 3.9.1 so they need no cloud account.

The workflow (configuration)

.github/workflows/terraform.yml:

name: terraform

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.16.5
      - run: terraform fmt -check -recursive
      - run: terraform init -input=false
      - run: terraform validate
      - run: terraform plan -input=false -no-color -out=tfplan
      - run: terraform show -no-color tfplan > plan.txt
      - uses: actions/upload-artifact@v4
        with:
          name: plan
          path: plan.txt

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production   # requires an approval configured in repo settings
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.16.5
      - run: terraform init -input=false
      - run: terraform plan -input=false -no-color -out=tfplan
      - run: terraform apply -input=false -no-color tfplan

The pull request job publishes a readable plan for reviewers. The apply job plans again from the merged code and applies that exact saved plan, after someone approves the production environment. A real setup also needs credentials, usually OIDC through aws-actions/configure-aws-credentials or its Azure or GCP equivalent, and a remote backend. Neither appears here because neither can be run without an account. Everything below is what each run: step prints.

Step 1: fmt -check fails the build

The configuration being checked, a root module and one local module:

# main.tf
terraform {
  required_version = "~> 1.16"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

module "naming" {
  source = "./modules/naming"
  env    = "ci"
}

resource "random_pet" "release" {
  prefix = module.naming.prefix
}

output "release" {
  value = random_pet.release.id
}
# modules/naming/main.tf
variable "env" {
  type = string
}

output "prefix" {
  value="shop-${var.env}"
}
$ terraform fmt -check -recursive; echo "exit=$?"
modules/naming/main.tf
exit=3
$ terraform fmt -check -recursive -diff -no-color
modules/naming/main.tf
--- old/modules/naming/main.tf
+++ new/modules/naming/main.tf
@@ -3,5 +3,5 @@
 }
 
 output "prefix" {
-  value="shop-${var.env}"
+  value = "shop-${var.env}"
 }
$ terraform fmt -recursive
modules/naming/main.tf

-check only reports, and exit status 3 fails the step. -recursive matters because without it fmt only looks at the current directory and would never see the module. Add -diff to the CI step so the log shows the fix. The author runs terraform fmt -recursive locally and pushes again.

Steps 2 and 3: init and validate

$ terraform init -input=false -no-color | grep -E "^- |successfully"
- naming in modules/naming
- Finding hashicorp/random versions matching "~> 3.9"...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (signed by HashiCorp)
Terraform has been successfully initialized!
$ terraform validate -no-color
Success! The configuration is valid.

-input=false matters in CI. Without it, a missing variable or backend setting makes Terraform wait for keyboard input that will never come, until the job times out. With it, Terraform fails immediately with an error that names the missing value.

Step 4: a saved plan, and what the exit code says

$ terraform plan -input=false -out=tfplan -detailed-exitcode -no-color; echo "exit=$?"

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # random_pet.release will be created
  + resource "random_pet" "release" {
      + id        = (known after apply)
      + length    = 2
      + prefix    = "shop-ci"
      + separator = "-"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + release = (known after apply)

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"
exit=2
$ terraform show -no-color tfplan > plan.txt && wc -l plan.txt
19 plan.txt

Exit code 2 means “the plan has changes”, and that’s success. GitHub Actions treats any non-zero exit as a failed step, which is why the workflow above leaves out -detailed-exitcode. Add it only to a step that captures the code, for example to skip the apply when it’s 0. plan.txt holds just the plan, without the “Saved the plan” footer, which is the part worth posting on the pull request.

Step 5: apply the saved plan

$ terraform apply -input=false -no-color tfplan
random_pet.release: Creating...
random_pet.release: Creation complete after 0s [id=shop-ci-destined-bobcat]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

release = "shop-ci-destined-bobcat"

Applying a plan file doesn’t ask for confirmation, so the approval has to happen before this step. In the workflow, the environment: production gate is that approval.

Practice

1. After the apply, run the plan again with -detailed-exitcode. What does a scheduled “is production still as declared?” job see?

Solution
$ terraform plan -input=false -detailed-exitcode -no-color > /dev/null; echo "exit=$?"
exit=0

0 means no changes and no drift. A nightly workflow running this command can open an issue whenever it gets 2.

2. CI installs Terraform 1.6.0 because someone copied an old workflow. What happens to this configuration?

Solution
$ terraform version | head -2
Terraform v1.6.0
on linux_amd64
$ terraform init -input=false -no-color

Initializing the backend...
Initializing modules...

Error: Unsupported Terraform Core version

  on main.tf line 2, in terraform:
   2:   required_version = "~> 1.16"

This configuration does not support Terraform version 1.6.0. To proceed,
either choose another supported Terraform version or update this version
constraint. Version constraints are normally set for good reason, so updating
the constraint may lead to other errors or unexpected behavior.

required_version stops the old binary before it touches state. Without that constraint, an old CLI might fail later with a confusing error, or misread state written by a newer version. Keep terraform_version in the workflow and required_version in the code in step.

3. Break the formatting on purpose in main.tf (e.g. env="ci"), and fix it with one command before pushing.

Solution
$ terraform fmt -check -recursive; echo "exit=$?"
main.tf
exit=3
$ terraform fmt -recursive
main.tf
$ terraform fmt -check -recursive; echo "exit=$?"
exit=0

fmt lists only the files it rewrote, and the follow-up -check that exits 0 is exactly what CI will see. Many teams run terraform fmt -recursive in a pre-commit hook so this check never fails on formatting.

Next: provisioners, and why HashiCorp calls them a last resort.

Frequently Asked Questions

Why run terraform plan on pull requests but apply only on main?
A plan has no side effects, so it is safe on every pull request and gives reviewers the exact changes next to the code diff. Apply changes real infrastructure, so it runs only after the change is merged, usually behind a GitHub environment that requires a human approval.
What does terraform fmt -check do in CI?
It changes nothing, lists every file that is not in canonical format, and exits with status 3 if any file needs formatting, which fails the job. Adding -diff prints the exact change so the author can see what to fix, or run terraform fmt -recursive locally.
What does -detailed-exitcode mean for terraform plan?
plan exits 0 when there are no changes, 2 when the plan contains changes, and 1 on an error. A workflow can use it to skip the apply job when nothing changed, or a scheduled job can alert on 2 as drift. Note that GitHub Actions treats any non-zero exit as a failed step unless you capture the code.
Why pin the Terraform version in the workflow?
So CI runs the same Terraform version as the team and as the required_version constraint in the code. If the workflow installs an older Terraform than required_version allows, terraform init fails with 'Unsupported Terraform Core version' before doing anything.
How should GitHub Actions authenticate to a cloud provider for Terraform?
Prefer OpenID Connect: the workflow requests a short-lived token from GitHub and exchanges it for temporary cloud credentials, so no long-lived keys are stored as secrets. AWS, Azure and Google Cloud all support this through their official login actions and an identity provider trust configured once in the cloud account.