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

terraform fmt -check -recursive and terraform validate (after init -backend=false)

No

Lint and scan

TFLint, Trivy, Checkov; custom policies (see Code Quality and Security Scanning)

No

Test

terraform test with command = plan or mocks (see Testing)

No, unless the tests apply real resources

Plan on the pull request

terraform plan -out, rendered into a pull request comment; optionally evaluated by policy

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

terraform apply of the reviewed plan, behind an approval gate

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

-input=false (or TF_INPUT=false)

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 -json option implies this setting.

-no-color

Removes terminal formatting sequences, so logs and pull request comments are not full of escape codes.

-lock-timeout=5m

Applies unless locking is disabled with -lock=false. Terraform retries acquiring the state lock for the given period before returning an error, which is what you want when two pipeline runs briefly overlap. The duration is a number followed by a time unit, such as 3s, 5m.

-out=tfplan

Saves the plan so that apply runs exactly what was reviewed. Do not use a file suffix Terraform recognises, such as .tf, because Terraform would try to parse the file as configuration.

-detailed-exitcode

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).

TF_CLI_ARGS, TF_CLI_ARGS_plan

Adds arguments after the subcommand for every command, or only for the named one. Explicit flags take precedence. Convenient for setting -input=false once for the whole job.

TF_WORKSPACE

Selects a workspace without workspace select. The reference recommends it only for non-interactive workflows because it is easy to forget it is set locally and apply to the wrong state.

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

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-approve fits 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 tfplan as 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 tfplan and 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

actions/checkout

v7

Clones the repository

hashicorp/setup-terraform

v4

Installs the Terraform CLI (inputs terraform_version, terraform_wrapper, cli_config_credentials_token)

opentofu/setup-opentofu

v2

Installs the OpenTofu CLI (inputs tofu_version, tofu_wrapper)

terraform-linters/setup-tflint

v6

Installs TFLint (input tflint_version)

aws-actions/configure-aws-credentials

v6

Assumes an AWS role through OIDC (role-to-assume, aws-region)

azure/login

v3

Signs in to Azure with OIDC; v2 is in maintenance and v1 end of life

google-github-actions/auth

v3

Federates to Google Cloud through Workload Identity Federation

actions/github-script

v9

Posts the plan as a pull request comment

actions/upload-artifact

v7

Hands the saved plan to the apply job (retention-days minimum 1)

actions/download-artifact

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.

Permissions and the two-file split

GitHub gives every workflow a GITHUB_TOKEN; its permissions should be declared explicitly and minimised:

  • contents: read to check out the code.

  • pull-requests: write only in the job that posts the plan comment.

  • id-token: write only in jobs that exchange the GitHub OIDC token for cloud credentials.

File Trigger What it may do

terraform-pr.yml

pull_request on changes to the infrastructure folder

Checks (fmt, validate, lint, test), then a plan with read-only cloud access; posts the plan as a comment

terraform-apply.yml

push to main

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,
              });
            }

What the workflow does and why:

  • concurrency cancels 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=false in the checks job lets validate and test run without any cloud access. The module being tested (infra/modules/bucket) is the Bookshelf covers-bucket module from Modules.

  • The plan job 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 +e is needed because -detailed-exitcode returns 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 show for 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

The essential points:

  • The apply job names environment: 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 the main branch 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 the production environment (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 use setup-terraform with no version drift; pin terraform_version in both for certainty. The artifact is retained one day and is a secret (see the section above).

  • concurrency with cancel-in-progress: false queues overlapping applies instead of killing one midway, and the -lock-timeout covers 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 inside infra/ for example.

  • If the plan changed between the plan job and the approval (somebody changed infrastructure out of band), apply of 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.tool is terraform or tofu, and the commands are spelled with the matrix expression followed by fmt, validate or test (see the workflow).

  • Two if: conditions select hashicorp/setup-terraform or opentofu/setup-opentofu. The OpenTofu action takes tofu_version, which accepts a specific version such as 1.6.0 or a semver range, and installs the latest when omitted.

  • fail-fast: false lets 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:

  1. You set up a trust relationship in the cloud, naming which GitHub workflows may obtain a role.

  2. For each job run, GitHub’s OIDC provider issues a signed JSON Web Token that identifies the workflow.

  3. A step presents the token to the cloud’s token service.

  4. 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:production subject. 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 sub from 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.

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
}

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}"
}

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 }}

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 import block; see Import and Query).

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.

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

atlantis plan

Plans the projects detected as modified. With flags (-d directory, -p project, -w workspace) it plans just those; a plain atlantis plan discards earlier plans created with a flag.

atlantis apply

Applies the matching plan; with no flags, all unapplied plans of the pull request.

atlantis unlock

Removes all Atlantis locks and discards all plans for the pull request.

atlantis import

Runs import for an address and ID (requires the server setting --allow-commands) and discards the plan, so plan again.

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), lint and plan jobs, 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 from main after 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.

sequenceDiagram autonumber actor Dev as Developer participant GH as GitHub pull request participant CI as PR workflow participant Cloud as Cloud (read-only role) actor Rev as Reviewer participant Env as Environment production participant Apply as Apply workflow participant State as Remote state and cloud (write role) Dev->>GH: Push branch and open pull request GH->>CI: pull_request event CI->>CI: fmt, validate, TFLint, test (Terraform and OpenTofu) CI->>Cloud: OIDC token, plan -out=tfplan Cloud-->>CI: Read-only credentials CI->>GH: Plan comment and check results Rev->>GH: Read the plan, approve Note over GH: Branch protection requires green checks and an approval GH->>Apply: Merge to main triggers push event Apply->>Apply: Plan again on the merged commit Apply->>Env: Request deployment Rev->>Env: Approve the deployment Env->>Apply: Job released, OIDC subject is environment production Apply->>State: apply tfplan with the write role State-->>Apply: Resources changed, state updated Apply->>GH: Deployment status

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 -policies flag) is described as generally available in the changelog while its documentation still shows a beta label; see Policy as Code.

hashicorp/setup-terraform v4

The README’s current major is v4; older material pins v3. Pin the README’s value.

actions/checkout v7, azure/login v3, google-github-actions/auth v3, configure-aws-credentials v6, actions/github-script v9

Current majors on the READMEs when this page was written. Older workflows (including HashiCorp’s own tutorial, which uses actions/checkout@v3 and actions/github-script@v6) still work on older majors but should be updated.

azure/login v1 and v2

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 repo:ORG/REPO:... shape.

AWS S3 backend

use_lockfile provides S3-native locking; DynamoDB locking is deprecated and will be removed in a future minor version.

Atlantis v0.24.0

Adds --default-tf-distribution (terraform or opentofu), superseding the deprecated --tf-distribution.

GitLab

Terraform CI/CD templates and the terraform-images job images are no longer distributed; the OpenTofu CI/CD component is the recommended path.

Dependabot

The github-actions ecosystem supports version and security updates; the opentofu ecosystem supports version updates but not security updates.

Source: Terraform 1.17 changelog and the READMEs and documentation pages listed under References.

Common pitfalls

  • Running apply with -auto-approve on a fresh plan. The reviewed plan and the applied plan can differ. Apply the saved plan, behind an approval.

  • Uploading tfplan as 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_target with 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 concurrency in 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=trace can print credentials into a log many people can read.

  • Skipping the lock file. Commit .terraform.lock.hcl so the pipeline installs the providers you tested, and run providers lock for the pipeline’s platform.

  • Mixing Terraform and OpenTofu on one state. Test with both, deploy with one.