CI/CD and Automation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A Terraform configuration that only ever runs from a laptop is a single point of failure: the person who owns the credentials, the local plugin cache and the unpushed branch is the only person who can change production. Continuous integration (checks on every pull request) and continuous delivery (an automated, reviewed apply) move the same commands into a pipeline, so that every change is formatted, validated, scanned, planned in front of reviewers and applied by a machine identity whose credentials live for minutes. This page shows how to run the CLI safely without a terminal, how to build that pipeline in GitHub Actions for Terraform and OpenTofu, how to give it cloud access without long-lived keys, how to notice drift, and how Atlantis and GitLab CI solve the same problem.
The baseline is Terraform 1.17 (1.17.0 was released on October 21, 2026) and OpenTofu 1.13. The page was checked against the
Terraform automation tutorial, the plan command reference, the environment-variable reference, the READMEs of the GitHub
Actions used, the GitHub Actions documentation for OpenID Connect and environments, the Atlantis documentation and the
GitLab infrastructure-as-code documentation. It builds on
CLI Essentials (the commands used here),
The Dependency Graph and the Plan (what a plan contains) and
Backends and Remote State (a pipeline needs a remote backend with
locking). The checks that run in the pipeline are described in
Code Quality and Security Scanning,
Testing and Policy as Code. The managed
alternative to building all of this yourself is described in
HCP Terraform and Terraform Enterprise, and the multi-deployment
orchestration built on top of it in Terraform Stacks.
The shape of a Terraform pipeline
Whatever the tool, a delivery pipeline for Terraform has the same stages. The cheap, credential-free stages run on every push; the stages that need cloud access run only when a human can see the result.
| Stage | What it does | Needs cloud credentials? |
|---|---|---|
Format and validate |
|
No |
Lint and scan |
TFLint, Trivy, Checkov; custom policies (see Code Quality and Security Scanning) |
No |
Test |
|
No, unless the tests apply real resources |
Plan on the pull request |
|
Read access to state and to the cloud APIs |
Review and merge |
People read the plan, branch protection enforces approvals and green checks |
No |
Apply after merge |
|
Write access |
The rest of the page implements each row. The guiding principle is that the plan a human reads is the plan that is applied, and that the credentials that can write to production are only ever handed to a job running reviewed code on the default branch.
Running Terraform in automation
The CLI is designed for interactive use: it prompts for missing variables, colours its output and suggests the next command. None of that is useful in a pipeline. The tutorial on automation lists the adjustments, and the command reference documents each flag.
TF_IN_AUTOMATION
When TF_IN_AUTOMATION is set to any non-empty value, Terraform makes minor adjustments to its output, chiefly that it does
not suggest the next commands to run, since a wrapping tool decides what happens next. The environment-variable reference
describes the change as cosmetic and says the exact output can differ between minor versions, so never parse the
human-readable text of a plan; use the JSON forms (see
CLI Essentials) instead.
Flags every pipeline command should carry
| Flag or variable | Why |
|---|---|
|
Stops Terraform from prompting for root module variables that have no value. A missing variable becomes an immediate
error instead of a job that hangs until it times out. The |
|
Removes terminal formatting sequences, so logs and pull request comments are not full of escape codes. |
|
Applies unless locking is disabled with |
|
Saves the plan so that |
|
Makes the exit code describe the plan: 0 for a successful empty diff, 1 for an error, and 2 for a successful non-empty diff. Essential for drift detection (below). |
|
Adds arguments after the subcommand for every command, or only for the named one. Explicit flags take precedence.
Convenient for setting |
|
Selects a workspace without |
TF_LOG enables debug logging on stderr; leave it unset in routine runs, because trace logs can contain request bodies and
credentials, and a pipeline log is readable by everyone with access to the repository.
# The same flags, on every command a pipeline runs (illustrative)
export TF_IN_AUTOMATION=true
export TF_INPUT=false
terraform init -input=false
terraform validate -no-color
terraform plan -input=false -no-color -lock-timeout=5m -out=tfplan
terraform apply -input=false -no-color -lock-timeout=5m tfplan
The exit codes of plan
Without -detailed-exitcode, plan exits 0 whenever it succeeds, whether or not it found changes, and 1 on error. With
the flag, a successful plan with changes exits 2. A pipeline must therefore treat 2 as "changes present", not as a failure,
which is why the drift job later in this page does not use a plain run step with set -e.
terraform plan -input=false -detailed-exitcode -lock-timeout=5m -out=tfplan
echo "exit code: $?"
# 0 = no changes, 1 = error, 2 = changes present
Source: Terraform Docs — terraform plan.
Saved plans, and why a plan file is a secret
The recommended automation workflow from HashiCorp’s tutorial is four steps: init -input=false, then
plan -out=tfplan -input=false, then a human review of the plan, then apply -input=false tfplan, which applies exactly
that plan. The tutorial adds the constraints that make this safe:
-
A saved plan references absolute paths. If plan and apply run on different machines (or in different jobs), the whole working directory, including
.terraform, must be archived and restored at the same absolute path. -
A plan is tied to one operating system and CPU architecture, to the provider plugins and to the Terraform version that produced it. Upgrading either between plan and apply is an error.
-
Credentials used to plan and apply should point at the same account.
-
Allow only one outstanding plan at a time: applying a plan invalidates other plans made against the same state. Use a backend with remote state and locking.
-
-auto-approvefits only non-critical infrastructure such as development environments.
The part that is easy to miss is the security of the file itself. The plan reference says the plan file includes the full
configuration and the input variables, and that sensitive values are saved in cleartext, even though they are hidden in
terminal output. The tutorial adds that the plan holds a copy of the state the plan applies to. A tfplan uploaded as a
workflow artifact is therefore readable by anyone who can download artifacts, including, for a public repository, anyone.
The practical rules:
-
Treat
tfplanas a secret: short artifact retention (one day is enough), no upload from jobs that run code from forks, and no public repositories for production infrastructure pipelines. -
Prefer provider credentials from environment variables or OIDC. The tutorial notes Terraform does not store those in the plan or on disk, whereas credentials written into configuration are (see Sensitive Data, Ephemeral Values and Write-Only Arguments for keeping secrets out of plan and state altogether).
-
To show a plan to humans, render it with
terraform show -no-color tfplan, which replaces sensitive values with a placeholder, never by printing the file. -
Pass the machine-readable form to scanners with
terraform show -json tfplanand delete the file when the job ends.
A GitHub Actions pipeline
The pipeline below is the Bookshelf repository’s infrastructure workflow. It is split in two files with different triggers and different privileges, which is the most important design decision: the file that runs on pull requests can read, the file that runs on the default branch can write.
Actions used and their current majors
Every action in this page was checked against its README. Pin these in your own workflows and let Dependabot’s
github-actions ecosystem propose updates (Dependabot supports it for version and security updates).
| Action | Major | Role |
|---|---|---|
|
v7 |
Clones the repository |
|
v4 |
Installs the Terraform CLI (inputs |
|
v2 |
Installs the OpenTofu CLI (inputs |
|
v6 |
Installs TFLint (input |
|
v6 |
Assumes an AWS role through OIDC ( |
|
v3 |
Signs in to Azure with OIDC; v2 is in maintenance and v1 end of life |
|
v3 |
Federates to Google Cloud through Workload Identity Federation |
|
v9 |
Posts the plan as a pull request comment |
|
v7 |
Hands the saved plan to the apply job ( |
|
v8 |
Retrieves it |
The hashicorp/setup-terraform README shows v4 as the current major. Older workflows that pin @v3 are out of date, so
use the README’s value. The same holds for actions/checkout: examples written in 2023 and 2024 show v3 or v4, and the
HashiCorp tutorial page still uses actions/checkout@v3 in its HCP Terraform workflows. Older majors work but run on
older Node.js runtimes; check each README when you adopt a workflow.
The setup actions install a wrapper around the binary by default (terraform_wrapper and tofu_wrapper default to
true) that exposes stdout, stderr and exitcode as step outputs. The workflows here do not need those outputs and
read the binary’s own exit code, so they set the wrapper to false.
Source: hashicorp/setup-terraform README.
Permissions and the two-file split
GitHub gives every workflow a GITHUB_TOKEN; its permissions should be declared explicitly and minimised:
-
contents: readto check out the code. -
pull-requests: writeonly in the job that posts the plan comment. -
id-token: writeonly in jobs that exchange the GitHub OIDC token for cloud credentials.
| File | Trigger | What it may do |
|---|---|---|
|
|
Checks (fmt, validate, lint, test), then a plan with read-only cloud access; posts the plan as a comment |
|
|
Plans again on the merged commit, then applies behind a protected environment |
Keep the plan job read-only. A pull request from a branch changes the workflow files and the Terraform code that runs inside the job, so anything the plan job can do, an author of that branch can do. That is why the cloud role used for pull requests (a planner role with read access to state and to the resources) is a different role from the one used to apply.
The pull request workflow
The workflow has two jobs. checks runs the credential-free stages for Terraform and OpenTofu (the matrix is explained in
the next section). plan runs once per pull request with the Terraform CLI, assumes the read-only AWS role, produces the
plan and comments it.
# .github/workflows/terraform-pr.yml
name: terraform-pr
on:
pull_request:
paths:
- "infra/**"
- ".github/workflows/terraform-*.yml"
concurrency:
group: terraform-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
env:
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
TF_WORKING_DIR: infra/envs/prod
jobs:
checks:
name: checks (${{ matrix.tool }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
tool: [terraform, tofu]
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
if: matrix.tool == 'terraform'
with:
terraform_wrapper: false
- uses: opentofu/setup-opentofu@v2
if: matrix.tool == 'tofu'
with:
tofu_wrapper: false
- name: fmt
run: ${{ matrix.tool }} fmt -check -recursive -diff
- name: init (no backend)
working-directory: ${{ env.TF_WORKING_DIR }}
run: ${{ matrix.tool }} init -backend=false -input=false
- name: validate
working-directory: ${{ env.TF_WORKING_DIR }}
run: ${{ matrix.tool }} validate -no-color
- name: test
working-directory: infra/modules/bucket
run: |
${{ matrix.tool }} init -backend=false -input=false
${{ matrix.tool }} test -no-color
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: terraform-linters/setup-tflint@v6
- name: tflint
run: |
tflint --init
tflint --recursive --format compact
plan:
needs: [checks, lint]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # request the GitHub OIDC token
pull-requests: write # post the plan comment
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: init
working-directory: ${{ env.TF_WORKING_DIR }}
run: >
terraform init -no-color
-backend-config="bucket=${{ vars.STATE_BUCKET }}"
-backend-config="region=${{ vars.AWS_REGION }}"
- name: plan
id: plan
working-directory: ${{ env.TF_WORKING_DIR }}
run: |
set +e
terraform plan -no-color -lock-timeout=5m -detailed-exitcode -out=tfplan
code=$?
echo "exitcode=$code" >> "$GITHUB_OUTPUT"
if [ "$code" -eq 1 ]; then exit 1; fi
terraform show -no-color tfplan > "$RUNNER_TEMP/plan.txt"
- name: comment the plan on the pull request
uses: actions/github-script@v9
env:
PLAN_EXIT: ${{ steps.plan.outputs.exitcode }}
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync(`${process.env.RUNNER_TEMP}/plan.txt`, 'utf8');
const limit = 60000;
const text = plan.length > limit ? plan.slice(0, limit) + '\n... truncated ...' : plan;
const status = process.env.PLAN_EXIT === '2' ? 'Changes present' : 'No changes';
const marker = '<!-- terraform-plan -->';
const body = `${marker}\n### Terraform plan: ${status}\n\n<details><summary>Show plan</summary>\n\n\`\`\`\n${text}\n\`\`\`\n</details>`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body,
});
}
Source: hashicorp/setup-terraform, actions/github-script, aws-actions/configure-aws-credentials and terraform plan.
What the workflow does and why:
-
concurrencycancels an older run of the same pull request when a new commit arrives, so two plans of the same branch never compete for the state lock. -
init -backend=falsein thechecksjob letsvalidateandtestrun without any cloud access. The module being tested (infra/modules/bucket) is the Bookshelf covers-bucket module from Modules. -
The
planjob passes the backend settings as partial configuration with-backend-config, keeping the bucket name out of the committed code (see Backends and Remote State). -
set +eis needed because-detailed-exitcodereturns 2 for a plan with changes, which GitHub would otherwise treat as a failed step. Exit code 1 is still a failure. -
The comment is updated in place, found through a hidden HTML marker, so a pull request with twenty pushes has one plan comment and not twenty. The comment text is truncated: GitHub limits the size of a comment, and
terraform showfor a large change set can exceed it. -
The rendered plan hides sensitive values, but resource attributes that are not marked sensitive still appear in a comment visible to everyone with read access to the repository. That is one more reason to mark secrets sensitive (or, better, write-only), and to review the comment as a public document.
For pull requests from forks, GitHub does not hand secrets or an OIDC token to the workflow by default. Do not work around
that with pull_request_target for infrastructure code: that trigger runs with the base repository’s privileges, and a job
that checks out and runs a fork’s Terraform is a remote code execution on your cloud role. Fork contributors get the
credential-free checks job; maintainers push branches to the repository itself.
The apply workflow
On a push to main the second workflow plans once more against the merged commit and applies that plan after a human
approves the deployment. Re-planning on main rather than applying the plan from the pull request is deliberate: the
pull request plan was computed against the state of the day it was opened and against the base branch of that day, and
another merge can have changed either. The reviewers approved the change; the pipeline re-derives the exact plan, and the
approval gate shows it before anything happens.
# .github/workflows/terraform-apply.yml
name: terraform-apply
on:
push:
branches: [main]
paths:
- "infra/**"
concurrency:
group: terraform-apply-prod
cancel-in-progress: false # never interrupt an apply
permissions:
contents: read
env:
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
TF_WORKING_DIR: infra/envs/prod
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
outputs:
exitcode: ${{ steps.plan.outputs.exitcode }}
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: init and plan
id: plan
working-directory: ${{ env.TF_WORKING_DIR }}
run: |
terraform init -no-color \
-backend-config="bucket=${{ vars.STATE_BUCKET }}" \
-backend-config="region=${{ vars.AWS_REGION }}"
set +e
terraform plan -no-color -lock-timeout=5m -detailed-exitcode -out=tfplan
code=$?
echo "exitcode=$code" >> "$GITHUB_OUTPUT"
if [ "$code" -eq 1 ]; then exit 1; fi
terraform show -no-color tfplan >> "$GITHUB_STEP_SUMMARY"
- name: package the working directory
if: steps.plan.outputs.exitcode == '2'
run: tar -czf "$RUNNER_TEMP/tfplan.tgz" -C "$GITHUB_WORKSPACE" .
- uses: actions/upload-artifact@v7
if: steps.plan.outputs.exitcode == '2'
with:
name: tfplan
path: ${{ runner.temp }}/tfplan.tgz
retention-days: 1
apply:
needs: plan
if: needs.plan.outputs.exitcode == '2'
runs-on: ubuntu-latest
environment: production # required reviewers are enforced here
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: tfplan
path: ${{ runner.temp }}
- name: restore the working directory
run: tar -xzf "$RUNNER_TEMP/tfplan.tgz" -C "$GITHUB_WORKSPACE"
- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_APPLY_ROLE_ARN }} # an environment variable of "production"
aws-region: ${{ vars.AWS_REGION }}
- name: apply the reviewed plan
working-directory: ${{ env.TF_WORKING_DIR }}
run: terraform apply -no-color -lock-timeout=5m tfplan
Source: HashiCorp Developer — Automate Terraform and GitHub Docs — Managing environments for deployment.
The essential points:
-
The
applyjob namesenvironment: production. GitHub holds that job until the environment’s protection rules pass. The rules available are required reviewers (up to six people or teams, and one approval is enough), prevent self-review, a wait timer, deployment branch and tag restrictions and environment secrets, which are available to the job only after the rules pass. Restrict the environment to themainbranch so a feature branch can never reach the apply role. -
Environments are available on every plan for public repositories and on GitHub Team (organisations) or GitHub Pro (users) for private ones; on the free plan a private repository’s protection rules are ignored. Check this before relying on it.
-
Defining the apply role as a variable of the environment (
AWS_APPLY_ROLE_ARN) means the plan job and the pull request workflow cannot even read it. Together with the OIDC trust policy that only accepts theproductionenvironment (next section), it makes the gate enforceable by the cloud, not only by the workflow file. -
The plan travels between jobs as an archive of the whole working directory, extracted at the same absolute path (
GITHUB_WORKSPACE) on the same runner image, exactly as the tutorial requires. Both jobs use the same Terraform version because both usesetup-terraformwith no version drift; pinterraform_versionin both for certainty. The artifact is retained one day and is a secret (see the section above). -
concurrencywithcancel-in-progress: falsequeues overlapping applies instead of killing one midway, and the-lock-timeoutcovers the short window in which the previous run is still releasing the lock. -
The
if: ... exitcode == '2'condition skips the approval request entirely when the merge does not change infrastructure, a documentation-only change insideinfra/for example. -
If the plan changed between the plan job and the approval (somebody changed infrastructure out of band),
applyof the saved plan fails because the state moved on; the answer is to run the workflow again, never to fall back to an unreviewed-auto-approve.
Where policy and scanners fit
Policy engines consume the plan as JSON. In the pull request workflow, after the plan step, a further step runs
terraform show -json tfplan > plan.json and passes the file to Conftest, Checkov or tfpolicy, failing the job when a
rule is violated; the details and the rules for the Bookshelf network are in
Policy as Code, and the static scanners (Trivy, Checkov on source) are in
Code Quality and Security Scanning. Terraform Policy is
described there with its maturity caveat: the Terraform 1.17 changelog calls it generally available while the policy
documentation still carries a beta label; treat it according to your organisation’s risk appetite. Security scanners that
upload SARIF results need the additional permission security-events: write.
A Terraform and OpenTofu matrix
OpenTofu is a drop-in alternative for most configurations (see OpenTofu), and a
module that is published for others should be proven on both. Because the CLIs have the same subcommands, one job with a
matrix over the executable name covers both, as the checks job above does:
-
matrix.toolisterraformortofu, and the commands are spelled with the matrix expression followed byfmt,validateortest(see the workflow). -
Two
if:conditions selecthashicorp/setup-terraformoropentofu/setup-opentofu. The OpenTofu action takestofu_version, which accepts a specific version such as1.6.0or a semver range, and installs the latest when omitted. -
fail-fast: falselets both legs finish, so a failure on one tool does not hide the result of the other.
To test several versions, add a version dimension and pass it as terraform_version or tofu_version. Test the oldest
version your required_version allows, the newest, and, if you want to detect problems early, an upcoming pre-release.
strategy:
fail-fast: false
matrix:
include:
- tool: terraform
version: "1.15.x"
- tool: terraform
version: "1.17.x"
- tool: tofu
version: "1.12.x"
- tool: tofu
version: "1.13.x"
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
if: matrix.tool == 'terraform'
with:
terraform_version: ${{ matrix.version }}
terraform_wrapper: false
- uses: opentofu/setup-opentofu@v2
if: matrix.tool == 'tofu'
with:
tofu_version: ${{ matrix.version }}
tofu_wrapper: false
Features that exist in only one tool will fail on the other, which is exactly what the matrix is meant to expose: OpenTofu
reads .tofu files and has features without a Terraform equivalent (state encryption, for example), and Terraform has
features OpenTofu does not. Only the plan and apply jobs run on a single, chosen tool: the one that will actually own the
state, because mixing the two CLIs on one state file is a migration, not a pipeline choice. The same applies to the lock
file: run providers lock for all platforms with the tool you deploy with.
Cloud access without long-lived keys: OIDC
A pipeline needs cloud credentials. The classic answer, an access key or a service-principal password stored as a repository secret, is a long-lived credential in a place with many readers, which leaks through logs, forks and compromised actions, and which nobody rotates. OpenID Connect (OIDC) federation replaces it with a short-lived token exchange:
-
You set up a trust relationship in the cloud, naming which GitHub workflows may obtain a role.
-
For each job run, GitHub’s OIDC provider issues a signed JSON Web Token that identifies the workflow.
-
A step presents the token to the cloud’s token service.
-
If the token’s claims satisfy the trust rule, the cloud returns credentials valid for the job.
The job needs the id-token: write permission to be able to request the token. The claim that carries the identity is
sub. Documented shapes are repo:ORG/REPO:environment:NAME for jobs that use an environment and
repo:ORG/REPO:ref:refs/heads/main for a branch. The token also carries claims such as repository, repository_owner,
ref, environment, actor, event_name and job_workflow_ref that trust rules can test. Organisation administrators
can additionally add repository custom properties to the token, which appear with a repo_property_ prefix, for
attribute-based trust.
Three consequences for the pipeline above:
-
The trust rule for the apply role must match the
environment:productionsubject. That ties the cloud’s decision to the environment’s protection rules: no approval, no job, no token, no write access. -
The trust rule for the plan role matches pull requests (or the repository) and grants read-only permissions.
-
Always constrain the subject. A trust rule that accepts any
subfrom the GitHub issuer lets every repository on GitHub that can reach the role’s ARN assume it.
GitHub’s documentation states that repositories created after July 15, 2026 use an immutable default subject format that
includes the owner and repository identifiers (it is not available on GitHub Enterprise Server). A trust rule written
from an older blog post that matches repo:ORG/REPO:... literally may therefore not match the token of a new repository;
copy the subject from the token’s actual claims (printing the decoded claims in a throwaway job is the quickest way) or from
the current GitHub documentation.
Source: GitHub Docs — OpenID Connect.
AWS
In AWS the trust has two pieces: an IAM OpenID Connect identity provider for the GitHub issuer, and an IAM role whose trust
policy conditions on the token. In Terraform the provider resource is small; url and client_id_list are required,
thumbprint_list is optional (the provider documentation includes an example without it).
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
variable "github_repository" {
type = string
description = "ORG/REPO that may assume the roles, for example the Bookshelf infrastructure repository."
}
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_policy_document" "apply_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repository}:environment:production"]
}
}
}
resource "aws_iam_role" "terraform_apply" {
name = "bookshelf-terraform-apply"
assume_role_policy = data.aws_iam_policy_document.apply_trust.json
}
Source: Terraform Registry — aws_iam_openid_connect_provider and aws-actions/configure-aws-credentials README.
The workflow side is the configure-aws-credentials step already used above: role-to-assume is the role’s ARN and
aws-region the region, with id-token: write in the job’s permissions. The action puts temporary credentials in the
environment, where both the AWS provider and the S3 backend find them. The role ARN contains the account ID; store it in a
repository or environment variable (it is an identifier, not a secret) and never hard-code it in the repository. If you
prefer to let Terraform itself assume the role, the S3 backend accepts an assume_role_with_web_identity block whose
role_arn and token file come from AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE; the backend documentation recommends the
environment variables over inline values, because hard-coded values are written to the .terraform directory and to plan
files. The backend also needs s3:GetObject, s3:PutObject and s3:DeleteObject on the lock file when use_lockfile is
enabled (DynamoDB locking is deprecated). A bootstrap you run once from a laptop creates the provider and roles; after that
the pipeline manages everything else, including (carefully) its own roles. The AWS side of identity is explained in
AWS Identity and Access Management, and the Terraform baseline in
AWS Infrastructure as Code.
Azure
Azure uses a federated identity credential on an app registration (or a user-assigned managed identity). Its issuer is
the GitHub token issuer, its audiences default to api://AzureADTokenExchange, and its subject must match the
token’s sub exactly. The azuread_application_federated_identity_credential resource requires application_id,
display_name, audiences, issuer and subject; the issuer and subject pair must be unique per application.
variable "github_repository" {
type = string
}
resource "azuread_application" "terraform" {
display_name = "bookshelf-terraform"
}
resource "azuread_application_federated_identity_credential" "prod" {
application_id = azuread_application.terraform.id
display_name = "github-environment-production"
description = "Apply job of the production environment"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:${var.github_repository}:environment:production"
}
In the workflow there are two options. azure/login@v3 takes client-id, tenant-id and subscription-id (the README
suggests GitHub secrets or variables for them) and needs id-token: write with contents: read; it signs in the Azure CLI,
which the azurerm provider can use. Alternatively the provider performs the exchange itself: the AzureRM OIDC guide for
GitHub Actions sets ARM_CLIENT_ID, ARM_TENANT_ID and ARM_SUBSCRIPTION_ID, and enables OIDC with ARM_USE_OIDC (the
provider block equivalent is use_oidc = true). The provider detects the GitHub variables
ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN automatically. When the state lives in the azurerm
backend, that backend needs OIDC configured as well; follow its documentation. The Azure side of identities is in
Azure Identity and Access.
Google Cloud
Google Cloud calls this Workload Identity Federation. A workload identity pool contains a provider for the GitHub issuer,
whose attribute_mapping must map google.subject and whose attribute_condition is a Common Expression Language
expression that must evaluate to a boolean; it is the place to restrict the federation to your organisation or repository.
variable "project_id" {
type = string
}
variable "github_owner_id" {
type = string
description = "Numeric ID of the GitHub organisation, used in the attribute condition."
}
variable "github_repository" {
type = string
}
resource "google_iam_workload_identity_pool" "github" {
project = var.project_id
workload_identity_pool_id = "bookshelf-github"
}
resource "google_iam_workload_identity_pool_provider" "github" {
project = var.project_id
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
}
attribute_condition = "assertion.repository_owner_id == \"${var.github_owner_id}\""
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account" "terraform" {
project = var.project_id
account_id = "bookshelf-terraform"
display_name = "Bookshelf Terraform pipeline"
}
resource "google_service_account_iam_member" "github_impersonation" {
service_account_id = google_service_account.terraform.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repository}"
}
Source: Terraform Registry — google_iam_workload_identity_pool_provider and google-github-actions/auth README.
The workflow uses google-github-actions/auth@v3 after the checkout step (the README warns that omitting the checkout
or placing it after auth leaves later steps unable to authenticate), with id-token: write in the permissions and the
workload_identity_provider input, the full resource name of the provider, including the project number. Supplying
service_account selects Workload Identity Federation through a service account (the pool then needs
roles/iam.workloadIdentityUser on it); omitting it selects direct federation, where the pool is granted roles on the
resources itself. By default the action writes a credentials file and exports GOOGLE_APPLICATION_CREDENTIALS, which is
removed at the end of the job. The README warns that the GitHub token lives five minutes, so derived credentials also
expire in five minutes; for a very long apply, plan for that. Changes to pools, providers and IAM can take up to five
minutes to propagate, so a bootstrap and a first pipeline run should not be back to back. The Google Cloud side is in
Google Cloud Identity and Access, and the Terraform baseline in
Google Cloud Infrastructure as Code.
- uses: actions/checkout@v7
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_TERRAFORM_SERVICE_ACCOUNT }}
Source: google-github-actions/auth README.
For other CI systems the principle is the same (GitLab CI and Bitbucket also issue OIDC tokens), and the managed platform removes the glue: HCP Terraform’s dynamic provider credentials do the exchange for you; see HCP Terraform and Terraform Enterprise.
GitOps, reconciliation and drift
GitOps means the Git repository is the source of truth and an automated process continuously drives the real world towards it. The pipeline above is only half of that: it reconciles on a merge, then lets reality diverge until the next merge. Infrastructure drifts for three reasons: a person changes a resource in the console during an incident, another automation changes the same resource, or the provider’s API changes a default. Detecting this requires running a plan on a schedule, with no code change at all.
# .github/workflows/terraform-drift.yml
name: terraform-drift
on:
schedule:
- cron: "17 5 * * *" # every day at 05:17 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
drift:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
issues: write
env:
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: plan
id: plan
working-directory: infra/envs/prod
run: |
terraform init -no-color \
-backend-config="bucket=${{ vars.STATE_BUCKET }}" \
-backend-config="region=${{ vars.AWS_REGION }}"
set +e
terraform plan -no-color -lock-timeout=5m -detailed-exitcode > "$RUNNER_TEMP/drift.txt"
echo "exitcode=$?" >> "$GITHUB_OUTPUT"
- name: fail on error
if: steps.plan.outputs.exitcode == '1'
run: exit 1
- name: open an issue on drift
if: steps.plan.outputs.exitcode == '2'
uses: actions/github-script@v9
with:
script: |
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Infrastructure drift detected ${new Date().toISOString().slice(0, 10)}`,
body: `The scheduled plan found differences. See the workflow run ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}.`,
labels: ['drift'],
});
The job uses the read-only plan role, exits 1 on a real error (so the run is red and somebody is notified) and 2 as the signal for an issue. The plan text goes to a file and into the run log only through the issue link, so a drift report is not copied into a place with a wider audience than the Actions log. GitHub may delay or skip scheduled runs under load and disables schedules in repositories without recent activity, so do not treat a scheduled workflow as an SLA.
Once drift is found, there are three reactions, and which is right depends on who is correct:
| Situation | Response |
|---|---|
The change was a mistake |
Run the normal pipeline (or re-run apply): Terraform reverts the resource to match the configuration. |
The change is legitimate and should stay |
Update the configuration in a pull request so the plan is empty (or, for a resource created by hand, adopt it with an
|
The remote object changed and only the record needs updating |
Use a refresh-only plan. |
A refresh-only plan (terraform plan -refresh-only, terraform apply -refresh-only) creates a plan that only updates
the state and root module outputs to match remote objects changed outside Terraform. It cannot be combined with other
planning modes or with -refresh=false, and it exists from Terraform v0.15.4. It is useful during an incident, to record the
emergency fix in the state before encoding it in code. It never creates, changes or destroys real infrastructure, but it does
overwrite state values, so run it through the same reviewed pipeline. I could not confirm from the documentation how
-detailed-exitcode combines with -refresh-only; test it on your version before depending on it.
HCP Terraform automates this loop with continuous health assessments and drift detection (see
HCP Terraform and Terraform Enterprise). Terraform
check blocks provide complementary continuous validation: they
assert facts about the world (for instance that the Bookshelf endpoint answers) on every plan.
Source: Terraform Docs — terraform plan.
Atlantis: pull request automation you host
Atlantis is a self-hosted application that listens to webhooks from the Git host and runs Terraform (or OpenTofu) on your
behalf, driven by pull request comments. Instead of a workflow file you get a server with the credentials, and the pull
request becomes the console: the plan is posted as a comment, atlantis apply applies it, and Atlantis locks the
directory and workspace for the duration of the pull request so nobody else plans or applies on top.
The commands, typed as comments on the pull request, are:
| Comment | Effect |
|---|---|
|
Plans the projects detected as modified. With flags ( |
|
Applies the matching plan; with no flags, all unapplied plans of the pull request. |
|
Removes all Atlantis locks and discards all plans for the pull request. |
|
Runs |
Extra Terraform flags go after --, for example atlantis plan -d infra/envs/prod — -var foo=bar; flags that change the
plan, such as -target and -var, are ignored when applying, because apply uses the saved plan.
atlantis.yaml
The repository-level file atlantis.yaml declares the projects. version is required and must be 3. Each project
needs dir; name is required only when several projects share the same directory and workspace; workspace defaults to
default. autoplan has enabled (default true) and when_modified, a list of patterns relative to the project directory
that trigger a plan. apply_requirements accepts approved, mergeable and undiverged. workflow names a custom
workflow, whose plan and apply blocks are ordered lists of steps such as init, plan, apply and run. automerge
(default false) merges the pull request when every plan has been applied, and parallel_plan / parallel_apply run
projects concurrently.
# atlantis.yaml
version: 3
automerge: false
parallel_plan: true
parallel_apply: false
projects:
- name: bookshelf-staging
dir: infra/envs/staging
autoplan:
enabled: true
when_modified: ["*.tf", "../../modules/**/*.tf"]
- name: bookshelf-prod
dir: infra/envs/prod
autoplan:
enabled: true
when_modified: ["*.tf", "../../modules/**/*.tf"]
apply_requirements: [approved, mergeable]
workflow: with-checks
workflows:
with-checks:
plan:
steps:
- init
- run: terraform fmt -check -recursive
- plan
apply:
steps:
- apply
Some keys, workflow and apply_requirements among them, are restricted: they take effect only if the server-side repo
configuration allows the repository to override them. That is a security feature, not an inconvenience: otherwise anyone who
can open a pull request could add a custom run step (arbitrary code on the Atlantis server) or remove the approval
requirement. Configure the server side with --repo-config (a YAML file, distinct from --config, which only sets server
flags), restrict webhooks with --repo-allowlist (github.com/ORG/*, with * wildcards and ! negation), and give
Atlantis a narrowly scoped credential: --gh-token for a user or --gh-app-id with --gh-app-key-file for a GitHub App.
For OpenTofu, set --default-tf-distribution (environment variable ATLANTIS_DEFAULT_TF_DISTRIBUTION) to opentofu; the
documentation lists terraform and opentofu as the values, available since Atlantis v0.24.0, and the older
--tf-distribution flag is deprecated. The default --autoplan-file-list already includes .tofu and .tofu.json
patterns, but module dependency tracing with --autoplan-modules (since v0.26.0, default off) may not fully support
.tofu files, in which case write explicit autoplan.when_modified patterns, as the example above does.
Atlantis’s trade-offs: you host and secure a server that holds production credentials, and it is the credential holder (not the pull request workflow) that you must protect; in return you get locking, plan-on-comment ergonomics and one server for many repositories, with no per-run cost. It is not a state backend, a policy engine or a scheduler: drift detection still needs a scheduled job somewhere.
GitLab CI, briefly
GitLab’s infrastructure-as-code documentation recommends the OpenTofu CI/CD component, which adds validate, plan and apply
jobs to the pipeline and integrates with GitLab’s own features (GitLab-managed Terraform state exposed as an HTTP backend and
the merge request widget that summarises plan changes). It is included with include: - component:, with the @<VERSION>
suffix choosing the component version, and separate version and opentofu_version inputs.
include:
- component: gitlab.com/components/opentofu/validate-plan-apply@<VERSION>
inputs:
version: <VERSION>
opentofu_version: <OPENTOFU_VERSION>
root_dir: infra/envs/prod
state_name: production
stages: [validate, build, deploy]
The same page states that GitLab no longer distributes the Terraform CI/CD templates or the terraform-images job images;
to run Terraform (not OpenTofu) in GitLab pipelines you build and host your own templates and images from the Terraform
Images project, or run the CLI from a container image of your choice in ordinary jobs. Apart from the syntax, the design
carries over from the GitHub pipeline: a read-only plan on merge requests, a protected environment with a manual
approval for the apply job, and CI/CD OIDC ID tokens (the same federation, with GitLab as the issuer) for the cloud role
instead of stored keys.
Branch protection: making the pipeline unavoidable
A pipeline that can be skipped is a suggestion. The repository settings turn it into a rule. On GitHub, a branch
protection rule or a ruleset on main should:
-
Require a pull request before merging, with at least one approving review, and CODEOWNERS review for the folders that matter (
infra/envs/prod/, the IAM modules, the workflow files themselves). -
Require status checks to pass before merging, naming the
checks (terraform),checks (tofu),lintandplanjobs, and require the branch to be up to date so the plan was computed against the current base. -
Block force pushes and deletions, and include administrators, so the rule is not skipped in a hurry.
-
Protect the workflow files with CODEOWNERS. A pull request can edit
.github/workflows, and the workflow that runs on the pull request is the edited one. The privileged workflow is safe only because it runs frommainafter review, and the environment and OIDC trust are what keep a malicious branch from reaching the apply role.
The pull request mechanics (reviews, required checks, merge methods) are covered in Pull Requests, and branches, trunk-based development and protection rules in Branches.
For multi-environment layouts (staging promoted to production) the common shapes are one directory per environment with the same modules (see Workspaces, Environments and Project Structure), a matrix over the environment directories with an approval gate before production, and, for module consumers, versions pinned to a tag so that an environment changes only through a pull request that updates the pin (see Module Development and Publishing).
Other platforms in one paragraph
Terraform CD can also run on Jenkins, Azure DevOps Pipelines, Google Cloud Build, AWS CodeBuild, Argo CD or Flux with a Terraform controller, and on managed platforms (HCP Terraform, env0, Spacelift, Scalr, Digger, Terrateam, Harness, Octopus Deploy, Terrakube). They differ in who holds the credentials, whether state storage, policy, drift detection, a private registry and cost estimation are built in, and in pricing, but they all implement the same loop from this page: checks on a pull request, a plan that people read, a gated apply of the reviewed plan, short-lived credentials and scheduled drift detection. The HashiCorp-operated option is described in HCP Terraform and Terraform Enterprise, and the next step beyond one workspace per deployment in Terraform Stacks. HashiCorp also publishes GitHub Actions that drive HCP Terraform runs (upload configuration, create run, plan output, apply run); the tutorial pairs them with a plan comment in the same way as the workflow above.
The change flow, end to end
The diagram follows one Bookshelf change from a branch to production, showing which parts are machines and where people decide.
Drift detection is the second loop: a scheduled workflow plans with -detailed-exitcode and opens an issue on exit
code 2, which a person turns into either a revert (re-apply) or a pull request that updates the code.
What changed recently
| Version or date | Change |
|---|---|
Terraform 1.17 (October 21, 2026) |
Current baseline. Terraform Policy (the |
|
The README’s current major is v4; older material pins v3. Pin the README’s value. |
|
Current majors on the READMEs when this page was written. Older workflows (including HashiCorp’s own tutorial, which
uses |
|
v1 is end of life and v2 is in maintenance mode according to the README; use v3. |
GitHub repositories created after July 15, 2026 |
The default OIDC subject format is immutable and includes owner and repository IDs (not on GitHub Enterprise Server).
Re-check trust rules written for the old |
AWS S3 backend |
|
Atlantis v0.24.0 |
Adds |
GitLab |
Terraform CI/CD templates and the |
Dependabot |
The |
Source: Terraform 1.17 changelog and the READMEs and documentation pages listed under References.
Common pitfalls
-
Running
applywith-auto-approveon a fresh plan. The reviewed plan and the applied plan can differ. Apply the saved plan, behind an approval. -
Uploading
tfplanas a long-lived artifact. It holds sensitive values in cleartext. Short retention, restricted access. -
Long-lived keys in repository secrets. Replace them with OIDC and constrain the subject to the environment.
-
Trusting any
sub. A trust rule without a subject condition is open to every repository on GitHub that knows the role. -
pull_request_targetwith a checkout of the branch. It runs untrusted code with privileged credentials. -
Treating exit code 2 as failure (or ignoring it). With
-detailed-exitcode, 2 means changes present, 1 is the failure. -
Plan and apply on different Terraform, provider, OS or path. A saved plan is bound to all of them.
-
Two runs on one state. Use
concurrencyin the workflow, a backend with locking, and-lock-timeout. -
Parsing the text of a plan. It is not an interface and varies by version; use
show -json. -
Letting the plan role write. The pull request job runs code from a branch; keep its role read-only.
-
Debug logging in CI.
TF_LOG=tracecan print credentials into a log many people can read. -
Skipping the lock file. Commit
.terraform.lock.hclso the pipeline installs the providers you tested, and runproviders lockfor the pipeline’s platform. -
Mixing Terraform and OpenTofu on one state. Test with both, deploy with one.
References
-
HashiCorp Developer — Automate Terraform with GitHub Actions
-
GitHub Docs — Dependabot supported ecosystems and repositories
-
Terraform Registry — azuread_application_federated_identity_credential
-
Terraform Registry — AzureRM service principal with OpenID Connect
-
Terraform Registry — google_iam_workload_identity_pool_provider
-
GitLab Docs — Infrastructure as code with Terraform and OpenTofu