Terraform CI/CD Pipeline with GitHub Actions

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
hashicorp/local 2.9.0
git 2.53.0
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user
Scope Non-interactive Terraform with TF_INPUT and TF_IN_AUTOMATION, repository layout for CI, fmt -check and validate gates, saved plan files and -detailed-exitcode, plan artifact sensitivity, applying a reviewed plan, state locking between concurrent runs, and a complete GitHub Actions workflow. Does not cover Jenkins, GitLab CI, or CircleCI pipelines.
Related guides Terraform remote state and backends
Terraform state locking
Terraform test framework
Terraform sensitive data
Terraform Associate certification course

Running terraform apply from a laptop works right up to the day two people run it at once, or someone applies a plan nobody reviewed, or a colleague's provider version quietly differs from yours. A CI/CD pipeline fixes those problems by making one machine the only thing that touches your infrastructure, and by putting a review gate in front of it.

This lesson builds that pipeline. You will run Terraform the way a runner runs it — no prompts, no terminal, exit codes that mean something — and then wire those commands into a GitHub Actions workflow that plans on every pull request and applies only after a merge.

A few of the results below contradict advice you will find elsewhere, and they are the interesting part. TF_IN_AUTOMATION does far less than its name suggests, terraform fmt -check exits 3 rather than 1, and editing your configuration does not invalidate a saved plan the way most people expect.

Work in ~/terraform-labs/terraform-cicd/ on the Terraform lab environment on Ubuntu. The lab uses terraform_data and hashicorp/local so nothing here needs a cloud account or a credit card.

IMPORTANT
What was tested where. Every terraform command and its output on this page was executed on the Ubuntu lab VM, in the order shown. The workflow at the end was then pushed to a throwaway GitHub repository and run for real on GitHub-hosted runners — a pull request to check the plan-only path, and a merge to check the apply path. Where GitHub behaviour is quoted below, it comes from those runs. Those verification runs used the lab configuration with local state, so the remote backend and the trimmed configuration shown later are illustrative rather than something those GitHub runs exercised. The drift behaviour that motivates the trimmed configuration was reproduced on the lab VM.

How a Terraform CI/CD pipeline is shaped

Before any YAML, it helps to see the shape you are building. Terraform automation splits into two halves that run under different rules: a preview half that anyone can trigger, and a writing half that almost nobody should be able to trigger directly.

text
Pull request opened or updated
        ├── terraform fmt -check      does the code match canonical style?
        ├── terraform validate        is the configuration internally consistent?
        └── terraform plan            speculative preview of expected changes
                └── review the expected changes on the diff
Merge to main
        ├── terraform init            same providers, from the same lock file
        ├── terraform plan -out       a FRESH saved plan from the merged commit
        ├── approval gate             review this plan, the executable one
        └── terraform apply tfplan    apply exactly that saved plan

The right column is the question each stage answers. The critical detail is that the plan appears twice, and they are not the same plan. The pull request produces a speculative preview to inform review. After the merge, the pipeline computes a brand new plan from the merged commit, and that second plan is the one saved, transferred, and applied.

Conflating the two is the most common design error in Terraform pipelines, and it is worth being precise about why they differ. The merged commit may not exist in any form the pull request could have planned against — it can include other merges that landed first, and the state may have moved on since the preview ran. A preview tells reviewers what to expect. It is not an executable artifact, and treating it as one means approving something other than what runs.

That distinction decides where approval belongs. If your organisation requires a human to sign off on the exact changes that will execute, the sign-off has to happen on the merge-run plan, not on the pull request. The pull request review is for the code; the approval gate is for the plan.

The preview half still needs enough access to read state and query providers, so it is cheaper and lower-risk than the apply half rather than free of credentials entirely. The apply half needs credentials that can change infrastructure, so it gets a branch restriction, an approval gate, and a lock.


Run Terraform without a terminal

Terraform is built for a human at a keyboard. It asks questions when something is missing, and a pipeline has no one to answer them. A job that hits a prompt does not fail — it hangs until the runner times out, which is a far more annoying way to find out something was wrong.

Start with the lab configuration. versions.tf pins Terraform and the provider so that every machine resolves the same versions.

hcl
terraform {
  required_version = ">= 1.12.0"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

main.tf defines a sensitive variable with no default, which is exactly the shape that catches pipelines out, plus three cheap resources to plan against.

hcl
variable "environment" {
  type    = string
  default = "dev"
}

variable "api_token" {
  type      = string
  sensitive = true
}

resource "terraform_data" "release" {
  input = "${var.environment}-release"
}

resource "terraform_data" "credential" {
  input = var.api_token
}

resource "local_file" "manifest" {
  filename = "${path.module}/manifest.txt"
  content  = "environment=${var.environment}\n"
}

output "release_id" {
  value = terraform_data.release.id
}

Initialize the directory with input disabled, which is how every automated init should look:

bash
terraform init -input=false -no-color

Sample output:

output
Initializing the backend...

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Reusing previous version of hashicorp/local from the dependency lock file
- Using previously-installed hashicorp/local v2.9.0


Terraform has been successfully initialized!

Provider selection came from the lock file rather than a fresh resolution, which is the behaviour you want in CI — the runner installs what your repository already agreed on.

Now see what the missing variable does. TF_INPUT=0 is the environment variable form of -input=false, and setting it once covers every command in the job:

bash
TF_INPUT=0 terraform plan -no-color

Sample output:

output
Error: No value for required variable

  on main.tf line 6:
   6: variable "api_token" {

The root module input variable "api_token" is not set, and has no default
value. Use a -var or -var-file command line argument to provide a value for
this variable.

That is the correct outcome: exit code 1, a clear message, and a job that stops in seconds. Without it, Terraform would sit at an Enter a value: prompt with nobody there to type. This is the single most important setting in Terraform automation, and it is the one people skip because they reach for TF_IN_AUTOMATION instead.

What TF_IN_AUTOMATION really does

The name promises a lot. Test it rather than trusting it. Wipe the initialized state and run a plain init first, so you have something to compare against:

bash
rm -rf .terraform .terraform.lock.hcl && terraform init -input=false -no-color

The interesting part is the tail of that output:

output
Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

Those closing paragraphs are advice for a person. Now run the identical command with the automation flag set:

bash
rm -rf .terraform .terraform.lock.hcl && TF_IN_AUTOMATION=1 terraform init -input=false -no-color

Sample output:

output
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

The output stops at the success line. Everything else is identical, including the provider download lines and the exit code. The same thing happens after a plan, where the variable removes the Note: You didn't use the -out option... paragraph and nothing else.

So be honest with yourself about what this buys you:

  • It suppresses next-step suggestions aimed at a human terminal
  • It leaves behaviour, exit codes, and substantive output unchanged
  • It does not prevent prompts, which remains the job of -input=false or TF_INPUT=0

Set it because tidier logs are genuinely nicer to read during an incident, not because you think it makes Terraform safe to automate.


Prepare the repository for CI

The pipeline reads your repository, so what is in the repository decides what the pipeline can do. The layout is small:

text
.
├── main.tf
├── versions.tf
├── .terraform.lock.hcl
├── .gitignore
└── .github/
    └── workflows/
        └── terraform.yml

Four things belong in version control and two categories must stay out. The .gitignore handles the exclusions:

text
.terraform/
*.tfstate
*.tfstate.*
tfplan
manifest.txt

The reasoning behind each line matters more than the line itself:

  • .terraform/ holds working-directory metadata such as the backend record, plus downloaded provider binaries that are large, platform-specific, and reinstalled by init on every run
  • *.tfstate and *.tfstate.* are state, which belongs in a remote backend and often contains secrets in plaintext
  • tfplan is a generated artifact that may carry sensitive values, covered in detail further down
  • .terraform.lock.hcl is deliberately absent from this list, because you want it committed

That last point is the one that breaks plan and apply consistency when people get it wrong. The dependency lock file records exactly which provider versions were selected and their checksums. Commit it and your runner installs what you tested with; leave it out and each run is free to resolve a newer provider that plans differently.

Confirm the ignore rules actually work rather than assuming. Stage everything after a plan has already produced state and an artifact:

bash
git add -A

git add prints nothing, so ask git what it actually staged:

bash
git status --short

Sample output:

output
A  .gitignore
A  .terraform.lock.hcl
A  main.tf
A  versions.tf

Four files, and not one of them is state or a plan. The lock file is there, which is the result you want.

To see exactly which rule excluded each path, ask git directly instead of reading the ignore file and hoping:

bash
git check-ignore -v terraform.tfstate tfplan .terraform/providers

Sample output:

output
.gitignore:2:*.tfstate	terraform.tfstate
.gitignore:4:tfplan	tfplan
.gitignore:1:.terraform/	.terraform/providers

Each row names the source file, the line number, the pattern that matched, and the path it tested. The lock file never appears in this output, which is the proof that it is tracked on purpose rather than surviving by accident.


Gate 1: check formatting with terraform fmt

The cheapest gate runs first because it needs no providers, no credentials, and no state. It also settles style arguments permanently, which is worth more than it sounds during code review.

Suppose someone commits the local_file block with hand-rolled indentation. Ask Terraform to check formatting without changing anything:

bash
terraform fmt -check -recursive -no-color

Sample output:

output
main.tf

One filename and nothing else. The exit code is the part that matters to CI, and it is not what most people guess:

bash
echo $?
output
3

Terraform reserves exit code 3 for "the check ran fine, but files need formatting", keeping 1 for a command that genuinely failed. Any non-zero code fails a GitHub Actions step, so the gate works either way, but the distinction matters the moment you write a script that branches on the code.

A bare filename is a thin error message for whoever has to fix it. Add -diff so the log shows the patch:

bash
terraform fmt -check -diff -no-color

Sample output:

output
main.tf
--- old/main.tf
+++ new/main.tf
@@ -17,8 +17,8 @@
 }
 
 resource "local_file" "manifest" {
-    filename = "${path.module}/manifest.txt"
-      content  = "environment=${var.environment}\n"
+  filename = "${path.module}/manifest.txt"
+  content  = "environment=${var.environment}\n"
 }
 
 output "release_id" {

Now the failed job tells the contributor precisely what to change. Fixing it locally is one command, and the write mode reports which files it touched:

bash
terraform fmt -no-color
output
main.tf

Re-run the gate to confirm it now passes:

bash
terraform fmt -check -recursive -no-color

Silence, and exit code 0. A passing format check prints nothing at all, which is what you want filling up a log. More detail on the command's other modes lives in the terraform fmt guide.


Gate 2: validate the configuration

Formatting says the code is tidy. Validation says it makes sense — references resolve, argument types match, required arguments exist. It still says nothing about whether your cloud will accept the result, because no API is contacted.

Ordering trips people up here. Try validating before initializing:

bash
terraform validate -no-color

Sample output:

output
Error: Missing required provider

This configuration requires provider registry.terraform.io/hashicorp/local,
but that provider isn't available. You may be able to install it
automatically by running:
  terraform init

Validation needs provider schemas to check resource arguments, so it needs init first. In a pipeline that has no backend credentials yet — a pull request from a fork, for instance — you can initialize providers while skipping the backend entirely:

bash
terraform init -backend=false -input=false -no-color

Sample output:

output
Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.9.0...
- Installed hashicorp/local v2.9.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

Note what is missing compared to a normal init: there is no Initializing the backend... line. Providers still download, so this is lighter only in the sense that it never touches remote state or needs backend credentials. With schemas available, validation passes:

bash
terraform validate -no-color
output
Success! The configuration is valid.

Seeing the gate succeed proves little on its own. Put a deliberate mistake in a scratch directory — a resource that references a variable nobody declared:

hcl
resource "terraform_data" "oops" {
  input = var.does_not_exist
}

Run init -backend=false in that directory, which needs no downloads because terraform_data is built in, then ask for validation:

bash
terraform validate -no-color

Sample output:

output
Error: Reference to undeclared input variable

  on main.tf line 2, in resource "terraform_data" "oops":
   2:   input = var.does_not_exist

An input variable with the name "does_not_exist" has not been declared. This
variable can be declared with a variable "does_not_exist" {} block.

Exit code 1, with the file and line named. If your pipeline posts results somewhere or builds an annotation, the JSON form gives you the same diagnostics as structured data:

bash
terraform validate -json

Sample output:

output
{
  "format_version": "1.0",
  "valid": false,
  "error_count": 1,
  "warning_count": 0,
  "diagnostics": [
    {
      "severity": "error",
      "summary": "Reference to undeclared input variable",
      "detail": "An input variable with the name \"does_not_exist\" has not been declared. This variable can be declared with a variable \"does_not_exist\" {} block.",
      "range": {
        "filename": "main.tf",
        "start": {
          "line": 2,
          "column": 11,
          "byte": 45
        },
        "end": {
          "line": 2,
          "column": 29,
          "byte": 63
        }
      }
    }
  ]
}

The JSON form still exits 1, so a step can fail on the exit code and a later step can parse diagnostics for line-level annotations. The terraform validate guide covers what this command does and does not catch.

If your repository has tests, this is where they belong — terraform test runs after validation and before the plan, since a failing assertion should stop the pipeline before anyone reviews a diff. The Terraform test framework covers writing those files. Skip the step entirely if you have no .tftest.hcl files rather than adding a step that always passes vacuously.


Gate 3: produce a plan the apply can trust

A plan in CI has two jobs. It shows a human what would change, and it becomes the exact instruction set the apply will execute later. The second job is why you save it to a file instead of planning twice.

Supply the sensitive variable the way a pipeline does — through the environment, never on the command line where it lands in shell history and process listings:

bash
export TF_VAR_api_token='lab-not-a-real-secret'

Terraform maps any TF_VAR_ prefixed variable to the matching input variable, so nothing in the configuration needs to change. Now plan and save the result:

bash
terraform plan -input=false -out=tfplan -no-color

Sample output:

output
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:

  # local_file.manifest will be created
  + resource "local_file" "manifest" {
      + content              = <<-EOT
            environment=dev
        EOT
      + filename             = "./manifest.txt"
      + id                   = (known after apply)
    }

  # terraform_data.credential will be created
  + resource "terraform_data" "credential" {
      + id     = (known after apply)
      + input  = (sensitive value)
      + output = (known after apply)
    }

  # terraform_data.release will be created
  + resource "terraform_data" "release" {
      + id     = (known after apply)
      + input  = "dev-release"
      + output = (known after apply)
    }

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

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

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

The redaction is per-value and worth noticing: terraform_data.credential shows input = (sensitive value) because the sensitive variable feeds it, while terraform_data.release prints dev-release in the clear. Terraform tracks sensitivity through expressions rather than blanking whole resources.

A pipeline often wants to know whether anything would change without a human reading the diff — to skip an apply, or to alert on drift from a scheduled run. Ask for a detailed exit code:

bash
terraform plan -input=false -detailed-exitcode -no-color

The plan body is unchanged; only the exit code carries the new information:

bash
echo $?
output
2

Three codes, three meanings, and 2 is the one people forget:

  • 0 — succeeded with no changes needed
  • 1 — the command failed
  • 2 — succeeded, and there are changes to apply

Because 2 is non-zero, a plain CI step treats "there are changes" as a failure unless you handle the code explicitly. That is a feature for a drift-detection job and a nuisance for a normal plan step, so use the flag deliberately. The terraform plan guide covers the command's other options.


Treat the saved plan as a secret

A saved plan is a file your pipeline will upload, store, and hand to another job. Before doing that, find out what is actually inside it. Start with what kind of file it is:

bash
file tfplan
output
tfplan: Zip archive data, made by v2.0, extract using at least v2.0, last modified Aug 12 2026 17:51:16, uncompressed size 914, method=deflate

A compressed archive, not an opaque encrypted blob. Terraform reads it back with show, and the -json form is what tooling uses. Ask it for the variables that went into the plan:

bash
terraform show -json tfplan | jq '.variables'

Sample output:

output
{
  "api_token": {
    "value": "lab-not-a-real-secret"
  },
  "environment": {
    "value": "dev"
  }
}

There it is in plaintext — the same value the human-readable plan carefully printed as (sensitive value). The resource entry repeats it:

bash
terraform show -json tfplan | jq '.planned_values.root_module.resources[] | select(.address=="terraform_data.credential")'

Sample output:

output
{
  "address": "terraform_data.credential",
  "type": "terraform_data",
  "name": "credential",
  "values": {
    "input": "lab-not-a-real-secret",
    "triggers_replace": null
  },
  "sensitive_values": {
    "input": true
  }
}

Read those last two blocks together, because their relationship is the whole lesson. The plaintext value sits in values, and sensitive_values next to it says "input": true. That marking is a rendering hint telling consumers to mask the value when displaying it. It is not encryption, and it is not omission.

WARNING
A saved plan file is unencrypted. If any sensitive variable was in scope when the plan was created, anyone who can read the artifact can read the secret with terraform show -json. Keep artifact retention short, restrict who can download workflow artifacts, and never publish a plan file to an untrusted location.

One popular way of checking this is actively misleading, so it is worth disproving. Searching the raw file for the secret finds nothing:

bash
strings tfplan | grep -c 'lab-not-a-real-secret'
output
0

Zero matches, and it proves nothing at all. The plan is a deflate-compressed archive, as file already told you, so the plaintext is never contiguous on disk for strings to find. Anyone who has "verified" that their plan files are clean this way has verified only that compression works.


Apply the saved plan

With a saved plan, the apply stage becomes mechanical. Pass the file instead of the directory:

bash
terraform apply -input=false -no-color tfplan

Sample output:

output
terraform_data.release: Creating...
terraform_data.release: Creation complete after 0s [id=a8a6547c-78ce-d62a-0116-bdf9b1afe8fb]
terraform_data.credential: Creating...
terraform_data.credential: Creation complete after 0s [id=319b79ea-5c98-e06c-f3cb-095d0c1bd562]
local_file.manifest: Creating...
local_file.manifest: Creation complete after 0s [id=1685b7a32904c8d97e4170def7199024e268bfb3]

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

Outputs:

release_id = "a8a6547c-78ce-d62a-0116-bdf9b1afe8fb"

No plan body, no approval prompt, no -auto-approve anywhere. Applying a saved plan needs no confirmation because the decision is already recorded in the file — Terraform executes what the plan says and has nothing left to ask. That is not the same as saying the change was approved, though. Any organisational approval has to happen before this job starts, which is what a protected environment is for, and it should be a review of this plan rather than of an earlier preview.

Reaching for -auto-approve in CI is the tell that a pipeline threw its plan away and recomputed one at apply time. That quietly breaks the link between what was reviewed and what runs.

What actually makes a saved plan stale

Terraform protects the gap between plan and apply, but not in the way most people describe. Test the assumption directly. Save a plan against the current state:

bash
terraform plan -input=false -out=stale.tfplan -no-color
output
No changes. Your infrastructure matches the configuration.

Now edit main.tf and add a resource that the saved plan knows nothing about:

hcl
resource "terraform_data" "added_later" {
  input = "drift-demo"
}

The configuration and the saved plan now disagree. Apply the outdated plan and see whether Terraform objects:

bash
terraform apply -input=false -no-color stale.tfplan

Sample output:

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

No error, exit code 0, and the new resource was never created. A saved plan carries its own snapshot of the configuration, so applying it executes the actions recorded at plan time and ignores your edited files completely. This is the failure mode to worry about, because nothing warns you: the pipeline reports success while silently doing the old thing.

State is what Terraform does guard, and you can prove it with the same resource. Save a plan that intends to create it, keeping only the summary line from the output:

bash
terraform plan -input=false -out=stale2.tfplan -no-color
output
Plan: 1 to add, 0 to change, 0 to destroy.

Now imagine a second pipeline run reaching the apply stage first. It applies its own plan, creates that resource, and moves the state forward — leaving the plan you just saved describing a world that no longer exists. Try to apply it anyway:

bash
terraform apply -input=false -no-color stale2.tfplan

Sample output:

output
Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.

Exit code 1, and the run stops. Put the two results together and the rule is precise:

  • State changed since the plan — Terraform refuses to apply, which catches two pipeline runs racing each other
  • Configuration changed since the plan — Terraform applies the old intent silently, which catches nobody

Your pipeline has to close the second gap itself. Plan and apply from the same commit, key the plan artifact to the commit SHA, and never let an apply job check out a branch tip that has moved on since the plan ran.


Keep state remote and locked

Everything so far used a local state file, which is fine on a lab VM and disqualifying in CI. A GitHub Actions runner is a fresh virtual machine that is destroyed when the job ends, taking terraform.tfstate with it. The next run starts with no memory of what it created and plans to create all of it again.

So the pipeline needs a backend that outlives the runner:

hcl
terraform {
  backend "s3" {
    bucket       = "example-terraform-state"
    key          = "cicd-demo/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true
  }
}

The bucket above is one concrete example, not a requirement — any backend that stores state remotely and supports locking does the job, and Terraform remote state and backends compares the options. If you use HCP Terraform, the cloud block replaces this entirely and runs happen remotely rather than on your runner, which changes how saved plans behave; migrating state to HCP Terraform covers that path.

Locking is the half that protects concurrent runs, and it is easy to see in action even with a local backend. While one apply is running, its lock is on disk:

bash
cat .terraform.tfstate.lock.info

Sample output:

output
{"ID":"8f368d5a-1fbf-8fb1-9715-ed7a5102211b","Operation":"OperationTypeApply","Info":"","Who":"root@golinuxcloud","Version":"1.15.8","Created":"2026-08-12T12:26:42.012701745Z","Path":"terraform.tfstate"}

The lock records who holds it and what they are doing. Now imagine a second pipeline run starting while that apply is still going. Give it a short patience setting so it gives up quickly:

bash
terraform plan -input=false -lock-timeout=5s -no-color

Sample output:

output
Acquiring state lock. This may take a few moments...

Error: Error acquiring the state lock

Error message: resource temporarily unavailable
Lock Info:
  ID:        8f368d5a-1fbf-8fb1-9715-ed7a5102211b
  Path:      terraform.tfstate
  Operation: OperationTypeApply
  Who:       root@golinuxcloud
  Version:   1.15.8
  Created:   2026-08-12 12:26:42.012701745 +0000 UTC
  Info:      


Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.

The second run failed after five seconds rather than corrupting anything, and it named the lock holder — the same ID, user, and timestamp from the lock file. In a real pipeline that identifies which run is in your way.

Failing is not always what you want, though. A run that waits is often better than a run that has to be retried by hand. Raise the timeout past the expected duration of the other job:

bash
terraform plan -input=false -lock-timeout=60s -no-color

Sample output:

output
Acquiring state lock. This may take a few moments...
terraform_data.release: Refreshing state... [id=54409c92-f7ac-3dc5-2fa4-5aaaca56855e]
terraform_data.credential: Refreshing state... [id=beb46463-2659-a190-ec11-2c0d54ba1ba9]
local_file.manifest: Refreshing state... [id=1685b7a32904c8d97e4170def7199024e268bfb3]

No changes. Your infrastructure matches the configuration.

Same opening line, completely different ending. The command held at the lock until the apply released it, then proceeded normally and exited 0. Setting -lock-timeout on every Terraform command in a pipeline turns a class of noisy failures into a short wait. Never reach for -lock=false to make the error go away — that removes the protection instead of the collision, and Terraform state locking explains what you lose.


The complete GitHub Actions workflow

Now the pieces go into .github/workflows/terraform.yml. Two jobs: one that runs on every pull request and every push, and one that runs only after a merge.

WARNING
Configure a remote backend with locking before using this workflow. The local-backend commands earlier in this article are lab demonstrations. The two jobs below run on separate ephemeral runners, so with the default local backend the apply job writes terraform.tfstate to a machine that is destroyed minutes later, and the next run plans to create everything again. Add a backend block to versions.tf and make sure both jobs receive the credentials that backend needs — the plan job to read state, the apply job to write it.

With a backend in place, versions.tf carries it alongside the version pin:

hcl
terraform {
  required_version = ">= 1.12.0"

  backend "s3" {
    bucket       = "example-terraform-state"
    key          = "cicd-demo/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true
  }
}

Substitute whichever backend your team uses; the workflow below is unchanged by that choice, apart from which credentials you wire into the two jobs.

The configuration itself also needs one change before it is safe on disposable runners. Drop the local_file resource and keep only what lives in state:

hcl
variable "environment" {
  type    = string
  default = "dev"
}

variable "api_token" {
  type      = string
  sensitive = true
}

resource "terraform_data" "release" {
  input = "${var.environment}-release"
}

resource "terraform_data" "credential" {
  input = var.api_token
}

output "release_id" {
  value = terraform_data.release.id
}

Leaving local_file.manifest out is deliberate, and the reason is the same property that makes remote state necessary. A GitHub-hosted runner is destroyed when its job ends, so a file written during apply disappears with the machine while the state entry describing it survives in the backend. The next run refreshes, finds nothing on disk, drops the resource from state, and plans to create it again. Deleting the file and re-planning in the lab produced exactly that: Plan: 1 to add with exit code 2, while the two terraform_data resources refreshed clean. On a pipeline where every run gets a fresh machine, that is a change reported on every single run even though nobody touched the configuration.

Anything whose real existence is tied to the runner's filesystem behaves this way. Keep pipeline configurations pointed at infrastructure that outlives the job.

Trimming the providers has one consequence worth knowing before you go looking for it. With only the built-in terraform_data provider left, there is no external provider selection to make, so terraform init writes no .terraform.lock.hcl for providers. A builtin provider ships inside the Terraform binary, so there is no version or checksum to record and nothing to download.

The .terraform/ directory is a separate concern. Terraform uses it for working-directory metadata, including backend initialization, so it can appear even when no provider was downloaded. With the backend above configured, a successful terraform init writes .terraform/terraform.tfstate, which records which backend this directory is bound to. Real configurations that use registry providers use the same directory for the downloaded provider packages, and they do produce a .terraform.lock.hcl holding the selected versions and checksums. That lock file is what the pipeline depends on for identical provider versions in both jobs.

yaml
name: Terraform

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

permissions:
  contents: read

env:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "0"

concurrency:
  group: terraform-${{ github.ref }}
  cancel-in-progress: false

jobs:
  plan:
    name: Format, validate and plan
    runs-on: ubuntu-latest
    steps:
      - name: Check out the configuration
        uses: actions/checkout@v7

      - name: Install Terraform
        uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: 1.15.8
          terraform_wrapper: false

      - name: Check formatting
        run: terraform fmt -check -recursive -diff

      - name: Initialize
        run: terraform init -input=false -lock-timeout=5m

      - name: Validate
        run: terraform validate

      - name: Plan
        run: terraform plan -input=false -lock-timeout=5m -out=tfplan
        env:
          TF_VAR_api_token: ${{ secrets.TF_VAR_API_TOKEN }}

      - name: Upload the plan for the apply job
        if: github.event_name == 'push'
        uses: actions/upload-artifact@v7
        with:
          name: tfplan-${{ github.sha }}
          path: tfplan
          retention-days: 1

  apply:
    name: Apply the reviewed plan
    needs: plan
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Check out the same commit
        uses: actions/checkout@v7

      - name: Install Terraform
        uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: 1.15.8
          terraform_wrapper: false

      - name: Download the plan built from this commit
        uses: actions/download-artifact@v8
        with:
          name: tfplan-${{ github.sha }}

      - name: Initialize
        run: terraform init -input=false -lock-timeout=5m

      - name: Apply the saved plan
        run: terraform apply -input=false -lock-timeout=5m tfplan

Several choices in there are deliberate, and they map onto problems demonstrated earlier:

  • permissions: contents: read narrows the default token to reading the repository, so a compromised step cannot push commits
  • concurrency with cancel-in-progress: false queues a second run instead of killing one mid-apply, which is the one time cancelling is genuinely dangerous
  • terraform_version: 1.15.8 pins the CLI, so plan and apply are produced by the same binary
  • terraform_wrapper: false stops the action wrapping the CLI, which keeps exit codes and output exactly as shown above
  • tfplan-${{ github.sha }} keys the artifact to a commit, so an apply cannot pick up a plan from different code
  • environment: production is where required reviewers attach, turning the merge into a second approval gate — with a caveat covered below
  • retention-days: 1 limits how long a file that may contain secrets sits in artifact storage

The if: github.event_name == 'push' condition on both the upload and the apply job is what separates the two halves. A pull request runs the gates and stops, keeping its plan as a preview that is never uploaded. A merge to main runs them again against the merged commit, saves that plan, and hands it to the apply job.

Several of those pins exist because a saved plan is not a portable deployment package. HashiCorp expects the apply to run in a comparable environment to the plan: the same operating system and CPU architecture, matching Terraform and provider versions, and an equivalent working-directory layout, since a plan can reference absolute paths. That is why both jobs use ubuntu-latest, both pin terraform_version to the same value, .terraform.lock.hcl is committed so providers resolve identically, and both jobs check out the same commit. Change any one of those between the jobs and the apply may fail or behave differently from what the plan described.

Validate the file parses before pushing it, since a malformed workflow fails in a way GitHub reports poorly:

bash
python3 -c "import yaml; d=yaml.safe_load(open('.github/workflows/terraform.yml')); print('jobs:', list(d['jobs'].keys()))"
output
jobs: ['plan', 'apply']

There is one wrinkle if you script checks against workflow files. Ask Python what the top-level keys are:

bash
python3 -c "import yaml; d=yaml.safe_load(open('.github/workflows/terraform.yml')); print([(repr(k), type(k).__name__) for k in d.keys()])"

Sample output:

output
[("'name'", 'str'), ('True', 'bool'), ("'permissions'", 'str'), ("'env'", 'str'), ("'concurrency'", 'str'), ("'jobs'", 'str')]

The trigger key came back as boolean True rather than the string 'on'. PyYAML follows YAML 1.1, where bare on, off, yes, and no are booleans, while GitHub parses workflows as YAML 1.2 where only true and false are. The file is perfectly valid on GitHub — but a local linter that looks up d['on'] will raise KeyError and tell you the workflow has no triggers.

What the workflow actually did on GitHub

Pushing this to a throwaway repository and opening a pull request confirms the split works. Ask the CLI which jobs the pull request actually ran:

bash
gh run view "$PR_RUN"

Sample output, trimmed to the job list:

output
✓ Format, validate and plan in 8s
- Apply the reviewed plan

The dash marks a skipped job. The API reports "conclusion": "skipped" for the apply job and for the upload step, so a pull request produces a plan for review and nothing else — no artifact, no apply, and no credentials that could change anything.

The merge run is where the plan handoff has to hold. Pull the upload lines out of that run's log:

bash
gh run view "$MERGE_RUN" --log | grep -aE 'Uploading artifact|digest of uploaded'

Sample output:

output
Uploading artifact: tfplan-ded670c0bf9502e2fdeb0aa85e52bb4201b322d8.zip
SHA256 digest of uploaded artifact is 0581dc551f3e78e27b524f172843fd52b6eb21d7647bbc2235dcf0e53f5e766e

The apply job ran on a completely separate runner, so ask what it received:

bash
gh run view "$MERGE_RUN" --log | grep -aE 'digest of downloaded|download completed'

Sample output:

output
SHA256 digest of downloaded artifact is 0581dc551f3e78e27b524f172843fd52b6eb21d7647bbc2235dcf0e53f5e766e
Artifact download completed successfully.

Identical digests mean the file the apply job executed is byte-for-byte the file the plan job produced. Be precise about what that does and does not prove: it establishes that nothing altered the plan in transit between the two runners, so the apply cannot have executed a different set of actions than the merge-run plan described. It says nothing about the earlier pull request preview, which was never uploaded and never left its runner. The digest guarantees integrity across the handoff, not agreement with an earlier review.

The apply then created the resources without ever being given TF_VAR_api_token, because a saved plan already carries its resolved variable values.

The secret did reach the plan job. Check how the runner logged the environment it passed to Terraform:

bash
gh run view "$MERGE_RUN" --log | grep -aE 'TF_IN_AUTOMATION|TF_INPUT|TF_VAR_api_token'

Sample output:

output
TF_IN_AUTOMATION: true
  TF_INPUT: 0
  TF_VAR_api_token: ***

Actions masked the value, so the log proves delivery without exposing it. The stronger proof is that the plan succeeded at all — api_token has no default and TF_INPUT is 0, so a missing secret would have failed with No value for required variable instead.

WARNING
environment: production is not an approval gate on its own. The apply job ran two seconds after the plan job finished, with nobody approving anything, because naming an environment that does not exist makes GitHub create it with no protection rules attached. Attempting to add a required reviewer to a private repository returned HTTP 422: Failed to create the environment protection rule. Please ensure the billing plan supports the required reviewers protection rule. Environment protection rules need a public repository or a paid plan. Until a reviewer is actually attached, the environment key only creates a deployment record — it gates nothing.

That last finding is worth checking on your own repositories rather than assuming, because the workflow looks identical either way. A step called something like "apply the approved plan" will happily apply a plan nobody approved — which is why the workflow above names that step for what it does, Apply the saved plan, rather than for a guarantee it cannot make. Confirm the gate exists by looking for a pending deployment on the run; if the API returns an empty array, there was never anything to approve.


Handle credentials without putting them in the repository

The plan step above reads ${{ secrets.TF_VAR_API_TOKEN }} and passes it as an environment variable, which is the pattern worth internalising. Terraform picks up any TF_VAR_-prefixed variable automatically, so secrets reach the configuration without appearing in a command line, a .tfvars file, or the repository.

For cloud providers, prefer short-lived credentials over stored ones. GitHub Actions can request an OIDC token that AWS, Azure, or Google Cloud exchanges for temporary credentials scoped to that specific workflow, which removes long-lived keys from your secret store entirely. That requires adding id-token: write to the job's permissions and a trust policy on the cloud side.

Give the pull request job the least access that still produces a plan

Planning is not a credential-free operation. Terraform has to read remote state and query providers about existing resources, so a realistic plan job needs backend access and provider credentials. Give it the minimum that still works — read-only provider credentials where the provider supports that, and backend access scoped to the one state file — rather than reusing the credentials the apply job holds.

Where the pull request comes from changes what is even possible, and the two cases need different treatment:

  • Same-repository pull request — secrets are available, so the job can initialize the real backend, validate, and plan against real state
  • Pull request from a fork — GitHub withholds repository secrets from workflows triggered by untrusted forks, so a real-state plan cannot run and should not be made to run

The workflow earlier in this article implements the first case only. For a public repository that accepts fork contributions, add a separate job that runs fmt -check, terraform init -backend=false, and validate with no secrets at all, so outside contributors still get useful feedback. Do not reach for pull_request_target to hand secrets to fork code — that trigger runs with repository privileges and is a well-known route to leaking them.

Whichever route you choose, the log is the leak nobody plans for. GitHub masks registered secret values in output, but Terraform's own sensitivity tracking does not always carry a value all the way through. Tearing down the lab makes the point better than any warning:

bash
terraform destroy -auto-approve -input=false -no-color

Most of that transcript is routine. One resource block in it is not:

output
- resource "terraform_data" "credential" {
      - id     = "319b79ea-5c98-e06c-f3cb-095d0c1bd562" -> null
      - input  = (sensitive value) -> null
      - output = "lab-not-a-real-secret" -> null
    }

The input attribute is redacted because the sensitive variable feeds it. The output attribute is a computed copy of that same value, it carries no sensitivity marking, and Terraform printed it in the clear four lines later. Sensitivity does not propagate reliably through computed attributes, which is a stronger reason to register every secret with your CI platform than any amount of care with sensitive = true. Terraform sensitive data goes further into where values escape.


Common Terraform CI/CD mistakes

Most broken pipelines fail for a handful of reasons, and the symptom rarely names the cause.

Symptom Likely cause Fix
Job hangs until the runner times out Terraform is waiting at a prompt for a missing variable or confirmation Set TF_INPUT=0 or pass -input=false on every command
Every run plans to create resources that already exist State lives on the runner and is destroyed with it Configure a remote backend; never rely on a local terraform.tfstate
Two runs corrupt or conflict over state No locking, or -lock=false used to silence an error Use a backend that supports locking and set -lock-timeout instead
Apply does something the reviewer never saw Plan was recomputed at apply time instead of being carried forward Save with -out, key the artifact to the commit SHA, apply that file
Apply succeeds but ignores the latest code An outdated saved plan was applied; config changes do not invalidate it Plan and apply from the same commit; never reuse a plan across commits
Error: Saved plan is stale State changed between plan and apply, usually a second run Serialise runs with a concurrency group and re-plan before retrying
Plan and apply disagree on provider behaviour .terraform.lock.hcl is not committed, so each run resolves versions freshly Commit the lock file and pin terraform_version in the workflow
Secrets appear in workflow logs A computed attribute copied a sensitive value, or the secret was never registered Register every secret with the CI platform; audit destroy and apply output
A pull request from a fork fails at init The backend needs credentials the fork's run cannot have Use init -backend=false for validation-only jobs on untrusted branches
Production changes with no human involved -auto-approve on every commit to the default branch Apply a saved plan and gate the job behind an environment with reviewers
Apply runs unapproved even though the job names an environment The environment exists but has no protection rules attached, which fails silently Attach a required reviewer, then confirm a pending deployment appears on the run
What applied differs from the plan reviewers signed off on The pull request plan is a speculative preview; the merge run computes a new one Put the approval gate on the merge run and review that plan, not the pull request preview
Fork pull requests fail with missing variables or backend errors GitHub withholds repository secrets from untrusted fork workflows Give forks a separate secret-free job using init -backend=false and validate

References


Summary

A Terraform CI/CD pipeline is two halves with different privileges. The pull request half runs fmt -check, init, validate, and plan to preview what a change would do, with the least access that still produces a useful plan. The merge half computes a fresh plan from the merged commit, saves it, and applies exactly that file using credentials the preview half never needs. Getting the split right matters more than the specific CI platform, which is why the commands above run identically whether GitHub Actions, GitLab, or a Jenkins agent invokes them.

Keep the two plans distinct in your head, because the wording around them is where pipelines go wrong. The pull request plan is a speculative preview that informs code review and is then discarded. The merge-run plan is the executable artifact. If someone must approve the exact actions that will run, they approve the second one, at the environment gate, not by clicking merge.

The details that break pipelines are rarely the ones people worry about. TF_IN_AUTOMATION turned out to be cosmetic — it tidies logs and nothing else, while -input=false is what actually stops a job hanging on a prompt. terraform fmt -check exits 3 rather than 1. A saved plan file is a compressed archive that hands over sensitive variables to anyone who runs terraform show -json against it, even though strings finds nothing and the human-readable plan says (sensitive value). And most surprising of all, editing your configuration does not invalidate a saved plan: Terraform guards the state between plan and apply, not the code, so an outdated plan applies the old intent and reports success.

That last point is the one to design around. Key your plan artifact to a commit SHA, let the concurrency group serialise runs, and put the apply behind an environment that genuinely requires a reviewer — verify that one does, because naming an environment in the workflow gates nothing until a protection rule is attached to it. Add -lock-timeout to every command so a queued run waits instead of failing, and keep state in a remote backend that supports locking, because a runner-local state file is thrown away with the runner.

Take the workflow above, point it at a repository with a disposable configuration like the terraform_data lab used here, and open a pull request against it before you trust it with anything real. Watch which steps run on the pull request and which wait for the merge — that boundary is the whole point, and it is much easier to verify on infrastructure you do not mind destroying.


Frequently Asked Questions

1. What does TF_IN_AUTOMATION actually change?

Less than most people assume. It suppresses the next-step advice Terraform prints for a human at a terminal, such as the paragraph after init suggesting you run terraform plan, and the note after plan telling you that you did not use the -out option. It changes no behaviour, no exit code, and no substantive output. It is a cosmetic setting for cleaner CI logs and it is not what stops a pipeline hanging on a prompt. The -input=false flag or the TF_INPUT environment variable does that job.

2. Why does terraform fmt -check exit with 3 instead of 1?

Terraform uses exit code 3 to mean the check ran successfully but found files that are not correctly formatted, which is different from exit code 1 meaning the command itself failed. Any non-zero code fails a CI step by default, so a pipeline behaves correctly either way, but the distinction matters if you write a script that branches on the exact code.

3. Does a saved plan file contain my secrets?

Yes, if any sensitive value was in scope when you created it. Running terraform show -json against a saved plan prints sensitive variable values in plaintext under the variables key and again under the resource values. The sensitive_values block sitting next to them is only a rendering hint for tools, not encryption. Treat a saved plan as a secret, keep its artifact retention short, and never publish it to somewhere untrusted.

4. Does editing the configuration invalidate a saved Terraform plan?

No, and this surprises people. A saved plan embeds its own snapshot of the configuration, so applying an outdated plan after editing your files succeeds and quietly performs the old set of actions while ignoring your edits. What Terraform does guard is the state. If the state changes between plan and apply, the apply fails with a Saved plan is stale error. Guard the configuration side yourself by planning and applying from the same commit.

5. Can I use a local state file in a CI pipeline?

No, not for anything real. A CI runner is destroyed after the job finishes, so a local terraform.tfstate is thrown away with it and the next run has no memory of what it created. That leads to duplicate resources and orphaned infrastructure. Use a remote backend that supports locking so state survives the runner and concurrent runs are serialised.

6. Is the plan reviewed on a pull request the same plan that gets applied?

Not in this design, and usually not in any design. The pull request plan is speculative — it previews what the change would do so reviewers can judge the code. After the merge, the pipeline computes a brand new plan from the merged commit, because that commit may include other work that landed first and the state may have moved on. That second plan is the one saved to a file, transferred to the apply job, and executed. If someone has to approve the exact actions that will run, they need to approve the merge-run plan at a protected environment gate rather than treating the merge button as approval.

7. Does adding environment to a job require approval before Terraform applies?

Not by itself. Naming an environment that does not already exist makes GitHub create it with no protection rules, so the apply job runs immediately and nobody is asked to approve anything. You have to attach a required reviewer to that environment before it gates anything, and environment protection rules are only available on public repositories or paid plans. On a private repository on a free plan the API refuses to create the rule at all, returning a 422 that mentions the billing plan. Verify the gate is real by checking whether the run has a pending deployment rather than trusting the workflow file.

8. Why does my CI plan keep proposing the same change on every run?

A common cause is a resource whose real existence lives on the runner's filesystem, such as local_file. The apply job writes the file and records it in remote state, then the runner is destroyed and the file goes with it. On the next run a fresh machine has nothing on disk, so the refresh drops the resource from state and the plan proposes creating it again. The state survives the runner but the filesystem does not, so keep pipeline configurations pointed at infrastructure that outlives the job.

9. Should I commit the .terraform.lock.hcl file?

Yes, in almost every case. Committing the dependency lock file is what makes your CI runner select the same provider versions your machine selected, so the plan a reviewer approved matches the apply that follows. Leaving it out lets each run resolve providers independently, which is how a pipeline ends up planning with one provider version and applying with another.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)