| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1hashicorp/local 2.9.0git 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.
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.
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 planThe 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.
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.
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:
terraform init -input=false -no-colorSample 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:
TF_INPUT=0 terraform plan -no-colorSample 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:
rm -rf .terraform .terraform.lock.hcl && terraform init -input=false -no-colorThe interesting part is the tail of that 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:
rm -rf .terraform .terraform.lock.hcl && TF_IN_AUTOMATION=1 terraform init -input=false -no-colorSample 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=falseorTF_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:
.
├── main.tf
├── versions.tf
├── .terraform.lock.hcl
├── .gitignore
└── .github/
└── workflows/
└── terraform.ymlFour things belong in version control and two categories must stay out. The .gitignore handles the exclusions:
.terraform/
*.tfstate
*.tfstate.*
tfplan
manifest.txtThe 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 byiniton every run*.tfstateand*.tfstate.*are state, which belongs in a remote backend and often contains secrets in plaintexttfplanis a generated artifact that may carry sensitive values, covered in detail further down.terraform.lock.hclis 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:
git add -Agit add prints nothing, so ask git what it actually staged:
git status --shortSample output:
A .gitignore
A .terraform.lock.hcl
A main.tf
A versions.tfFour 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:
git check-ignore -v terraform.tfstate tfplan .terraform/providersSample output:
.gitignore:2:*.tfstate terraform.tfstate
.gitignore:4:tfplan tfplan
.gitignore:1:.terraform/ .terraform/providersEach 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:
terraform fmt -check -recursive -no-colorSample output:
main.tfOne filename and nothing else. The exit code is the part that matters to CI, and it is not what most people guess:
echo $?3Terraform 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:
terraform fmt -check -diff -no-colorSample 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:
terraform fmt -no-colormain.tfRe-run the gate to confirm it now passes:
terraform fmt -check -recursive -no-colorSilence, 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:
terraform validate -no-colorSample 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 initValidation 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:
terraform init -backend=false -input=false -no-colorSample 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:
terraform validate -no-colorSuccess! 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:
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:
terraform validate -no-colorSample 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:
terraform validate -jsonSample 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:
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:
terraform plan -input=false -out=tfplan -no-colorSample 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:
terraform plan -input=false -detailed-exitcode -no-colorThe plan body is unchanged; only the exit code carries the new information:
echo $?2Three 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:
file tfplantfplan: 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=deflateA 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:
terraform show -json tfplan | jq '.variables'Sample 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:
terraform show -json tfplan | jq '.planned_values.root_module.resources[] | select(.address=="terraform_data.credential")'Sample 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.
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:
strings tfplan | grep -c 'lab-not-a-real-secret'0Zero 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:
terraform apply -input=false -no-color tfplanSample 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:
terraform plan -input=false -out=stale.tfplan -no-colorNo changes. Your infrastructure matches the configuration.Now edit main.tf and add a resource that the saved plan knows nothing about:
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:
terraform apply -input=false -no-color stale.tfplanSample 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:
terraform plan -input=false -out=stale2.tfplan -no-colorPlan: 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:
terraform apply -input=false -no-color stale2.tfplanSample 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:
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:
cat .terraform.tfstate.lock.infoSample 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:
terraform plan -input=false -lock-timeout=5s -no-colorSample 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:
terraform plan -input=false -lock-timeout=60s -no-colorSample 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.
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:
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:
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.
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 tfplanSeveral choices in there are deliberate, and they map onto problems demonstrated earlier:
permissions: contents: readnarrows the default token to reading the repository, so a compromised step cannot push commitsconcurrencywithcancel-in-progress: falsequeues a second run instead of killing one mid-apply, which is the one time cancelling is genuinely dangerousterraform_version: 1.15.8pins the CLI, so plan and apply are produced by the same binaryterraform_wrapper: falsestops the action wrapping the CLI, which keeps exit codes and output exactly as shown abovetfplan-${{ github.sha }}keys the artifact to a commit, so an apply cannot pick up a plan from different codeenvironment: productionis where required reviewers attach, turning the merge into a second approval gate — with a caveat covered belowretention-days: 1limits 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:
python3 -c "import yaml; d=yaml.safe_load(open('.github/workflows/terraform.yml')); print('jobs:', list(d['jobs'].keys()))"jobs: ['plan', 'apply']There is one wrinkle if you script checks against workflow files. Ask Python what the top-level keys are:
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:
[("'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:
gh run view "$PR_RUN"Sample output, trimmed to the job list:
✓ Format, validate and plan in 8s
- Apply the reviewed planThe 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:
gh run view "$MERGE_RUN" --log | grep -aE 'Uploading artifact|digest of uploaded'Sample output:
Uploading artifact: tfplan-ded670c0bf9502e2fdeb0aa85e52bb4201b322d8.zip
SHA256 digest of uploaded artifact is 0581dc551f3e78e27b524f172843fd52b6eb21d7647bbc2235dcf0e53f5e766eThe apply job ran on a completely separate runner, so ask what it received:
gh run view "$MERGE_RUN" --log | grep -aE 'digest of downloaded|download completed'Sample 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:
gh run view "$MERGE_RUN" --log | grep -aE 'TF_IN_AUTOMATION|TF_INPUT|TF_VAR_api_token'Sample 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.
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:
terraform destroy -auto-approve -input=false -no-colorMost of that transcript is routine. One resource block in it is not:
- 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
- Terraform CLI: running Terraform in automation — HashiCorp's guidance on non-interactive workflows
- Terraform CLI environment variables —
TF_INPUT,TF_IN_AUTOMATION,TF_VAR_and related settings - terraform plan command — saved plans,
-out, and-detailed-exitcode - terraform apply command — applying a saved plan file
- Terraform state locking — lock behaviour and
-lock-timeout - GitHub Actions workflow syntax — triggers, permissions, concurrency, and environments
- hashicorp/setup-terraform — the official action used in the workflow above
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.

