Terraform Pipelines on GitHub Actions

Run terraform plan and apply as GitHub Actions jobs on warp- runners: cache the provider plugin directory, size 2x or 4x labels, reach private state backends.

Last verified:

Overview

A Terraform pipeline on GitHub Actions is two jobs: a plan job that runs on every pull request and writes a saved plan file, and an apply job that runs on merge and consumes that exact file. Moving that pipeline to WarpBuild is a one-line runs-on change plus a cache step for the provider plugin directory, because terraform init spends most of its wall time pulling provider binaries rather than computing anything.

The four phases of a Terraform job have very different resource profiles, and knowing which is which decides both the runner label and the cache configuration.

terraform init downloads provider plugins and modules and configures the backend. This is network and disk work. A workspace pinning the AWS provider plus a Kubernetes provider and a Helm provider can pull several hundred megabytes of compressed plugin archives and expand them into .terraform/providers on every fresh machine.

terraform fmt and terraform validate are cheap. They parse configuration and never leave the machine.

terraform plan refreshes state by calling the provider API once per managed resource, then diffs the result against configuration. The -parallelism flag defaults to 10 concurrent operations, and those operations are mostly waiting on HTTPS responses from a cloud control plane. A workspace with 800 resources spends the refresh phase almost entirely idle on CPU.

terraform apply has the same shape plus create, update, and delete calls that block on the provider. An RDS instance or a node group upgrade can hold a job open for many minutes while the runner does nothing at all.

Two phases still reward a faster machine. Extracting provider archives into the working directory is disk-bound, and checkout plus plan-artifact upload move real bytes. Per-size rates for every label are on the pricing page.

Terraform pipelines live on the Linux labels, and the rest of this page uses them.

There is a second relationship between Terraform and WarpBuild worth separating out. Terraform support exists for BYOC on AWS, which means the runners themselves can be declared as code alongside the infrastructure they build. That path is covered at the end of the configuration section and in depth on Terraform for WarpBuild BYOC on AWS.

Configuration

This workflow runs a plan on pull requests and an apply on merge, with the provider plugin directory cached through WarpBuilds/cache, a drop-in replacement for actions/cache@v4 that writes to the WarpBuild cache backend enabled by default on Linux runners.

name: terraform
on:
  pull_request:
    paths:
      - "infra/**"
  push:
    branches: [main]
    paths:
      - "infra/**"

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

env:
  TF_IN_AUTOMATION: "1"
  TF_PLUGIN_CACHE_DIR: /home/runner/.terraform.d/plugin-cache

jobs:
  plan:
    runs-on: warp-ubuntu-latest-x64-2x
    permissions:
      contents: read
      id-token: write
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4

      - run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
        working-directory: .

      - name: Restore Terraform provider plugin cache
        uses: WarpBuilds/cache@v1
        with:
          path: /home/runner/.terraform.d/plugin-cache
          key: ${{ runner.os }}-tfplugins-${{ hashFiles('infra/.terraform.lock.hcl') }}
          restore-keys: |
            ${{ runner.os }}-tfplugins-

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ vars.TERRAFORM_VERSION }}
          terraform_wrapper: false

      - run: terraform init -input=false
      - run: terraform fmt -check -recursive
      - run: terraform validate
      - run: terraform plan -input=false -lock-timeout=5m -out=tfplan

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ github.sha }}
          path: infra/tfplan
          retention-days: 5

  apply:
    if: github.ref == 'refs/heads/main'
    needs: plan
    runs-on: warp-ubuntu-latest-x64-4x
    environment: production
    permissions:
      contents: read
      id-token: write
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4

      - run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
        working-directory: .

      - name: Restore Terraform provider plugin cache
        uses: WarpBuilds/cache@v1
        with:
          path: /home/runner/.terraform.d/plugin-cache
          key: ${{ runner.os }}-tfplugins-${{ hashFiles('infra/.terraform.lock.hcl') }}
          restore-keys: |
            ${{ runner.os }}-tfplugins-

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ vars.TERRAFORM_VERSION }}
          terraform_wrapper: false

      - uses: actions/download-artifact@v4
        with:
          name: tfplan-${{ github.sha }}
          path: infra

      - run: terraform init -input=false
      - run: terraform apply -input=false -lock-timeout=5m -auto-approve tfplan

Four details in that file earn their place.

TF_PLUGIN_CACHE_DIR has to exist before terraform init runs. Terraform skips the plugin cache without an error when the directory is missing, so the mkdir -p step is what makes the cache step matter. The key hashes .terraform.lock.hcl because that file records the exact provider versions and their hashes, so the cache rolls when a provider version moves and holds steady otherwise.

terraform_wrapper: false keeps the real binary on PATH. The wrapper script captures stdout and stderr to expose them as step outputs, which interferes with plan -detailed-exitcode and with any script that reads Terraform's own exit code.

-lock-timeout=5m stops a job failing instantly when another pull request holds the state lock. The concurrency group with cancel-in-progress: false does the heavier lifting by serializing runs against the same ref, so plans queue rather than collide.

The apply job reads the artifact the plan job wrote. Applying a saved plan file is what makes the merge deterministic, because the apply performs the operations that were reviewed instead of recomputing a fresh diff against whatever the world looks like at merge time.

Reaching a private state backend

Most Terraform pipelines that fail at the network layer fail in the same place: the state backend, a self-hosted Vault, or an internal API sits behind a private network that a public runner cannot reach. WarpBuild handles this with the networking addon, which joins the runner to your Tailscale tailnet at job start using OIDC and ephemeral node keys, then removes the node when the job ends. Configuration lives in the networking documentation.

You create a Network Addon Configuration in the dashboard with your Tailscale OIDC Client ID, then append it to the label:

jobs:
  plan:
    runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet
    steps:
      - uses: actions/checkout@v4

      - name: Reach the private state backend
        run: |
          terraform init -input=false \
            -backend-config="address=https://tfstate.myorg.tailnet.ts.net/state/infra" \
            -backend-config="lock_address=https://tfstate.myorg.tailnet.ts.net/state/infra"

      - run: terraform plan -input=false -out=tfplan

Extra tailscale up flags go in the Additional Arguments field of the addon configuration, and --advertise-tags=tag:ci --accept-routes is the common pair. Five flags are reserved and rejected if you pass them, because WarpBuild sets them itself: --hostname, --auth-key, --client-id, --id-token, and --state. The addon works on Linux x64, Linux ARM64, macOS, and Windows runners, and the same label syntax composes with snapshot runners. For the wider version of this question, see running GitHub Actions runners in your VPC and the short answer at can GitHub Actions runners access my private network.

Declaring the runners themselves

Terraform support exists for BYOC on AWS. The WarpBuild Terraform provider is in beta and covers AWS BYOC runner images and custom runner sets through typed resources. Beta releases are prereleases, so a range constraint such as ~> 0.2 never matches one and the version has to be pinned exactly.

terraform {
  required_providers {
    warpbuild = {
      source = "WarpBuilds/warpbuild"
      # Prerelease version: pin it exactly
      version = "0.2.0-beta"
    }
  }
}

# Reads WARPBUILD_API_KEY from the environment
provider "warpbuild" {}

The API key needs the ci scope and is created at app.warpbuild.com/settings/api-keys. BYOC runs on AWS, GCP, and Azure, and the Terraform path covers AWS today. GCP and Azure runner sets are configured in the dashboard or through the automation API.

Sizing

Plan and apply are network bound rather than CPU bound, so the small labels are usually correct. These are the WarpBuild Linux runners worth considering for Terraform, with rates from the pricing page.

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-arm64-2x28GB150GB SSD$0.003
warp-ubuntu-latest-arm64-4x416GB150GB SSD$0.006

warp-ubuntu-latest-x64-2x at $0.004 per minute is the default for a plan job. A 2 vCPU machine keeps up with provider extraction and holds a state file of a few thousand resources in memory without trouble.

warp-ubuntu-latest-x64-4x at $0.008 per minute is the right step up for an apply job, for workspaces with four or more providers to extract, and for repositories where a single terraform plan reads a state file large enough that JSON decoding becomes visible in the timing view.

warp-ubuntu-latest-x64-8x rarely pays for itself on Terraform alone. Reach for it when the job does more than Terraform, such as building a container image or running a policy engine over the plan JSON in the same job.

ARM64 labels shave the rate further, and Terraform and the major providers publish linux_arm64 builds. One prerequisite matters: .terraform.lock.hcl records hashes per platform, so a lock file generated only on linux_amd64 fails terraform init on an ARM64 runner. Regenerate it for both before switching, with terraform providers lock -platform=linux_amd64 -platform=linux_arm64, and commit the result.

Cache metering is small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001. A plugin cache of 1.5GB with 8,000 operations a month adds about $1.10.

Worked cost model

GitHub publishes per-minute list prices for its hosted runners in the GitHub Actions billing reference and on the GitHub pricing page. Checked on 2026-08-13, the standard ubuntu-latest runner for private repositories is $0.006 per minute at 2 vCPU, and the 4-core Linux larger runner is $0.012 per minute.

Take a platform team with 60 Terraform workspaces running 4,000 Terraform jobs a month at an average of 3.5 minutes each. That is 14,000 runner minutes.

ScenarioRateMonthly minutesMonthly cost
GitHub-hosted standard Linux, 2 vCPU$0.006/min14,000$84.00
warp-ubuntu-latest-x64-2x, 2 vCPU$0.004/min14,000$56.00
warp-ubuntu-latest-arm64-2x, 2 vCPU$0.003/min14,000$42.00
Plugin cache storage and operationssee aboven/a$1.10

At the 4 vCPU size used by the apply job, the same arithmetic runs against $0.012 per minute for the GitHub 4-core Linux larger runner and $0.008 per minute for warp-ubuntu-latest-x64-4x. Every rate above carries its source and the date it was checked, and the same numbers appear on the pricing page and in the evidence data behind it.

Bottlenecks

Cold provider downloads. Without a restored plugin cache, every job re-downloads and re-extracts every provider in the lock file. This is the single largest fixed cost in a Terraform pipeline and the reason the cache step above exists. Verify it worked by reading the terraform init output: a warm job reports that it is using a cached provider rather than downloading one.

Lock file platform mismatch. Moving a pipeline between x64 and ARM64 labels, or between a Linux runner and a macOS laptop, fails terraform init with a checksum error until the lock file carries hashes for every platform in play. Regenerate with terraform providers lock and commit it.

Refresh against provider rate limits. A large workspace makes one API call per resource during refresh, and cloud providers throttle. Raising -parallelism past the default of 10 usually makes throttling worse rather than better. Splitting a monolithic workspace into several smaller ones is the durable fix, and -refresh=false on pull request plans is the cheap one when state was refreshed recently by the last apply.

State lock contention. Several pull requests planning against the same workspace serialize on the backend lock. The concurrency group in the workflow above queues them at the GitHub Actions layer instead, which turns a hard failure into a wait, and -lock-timeout covers the rest.

Long applies holding a job open. Resources that take ten or twenty minutes to converge keep a runner allocated the whole time. Sizing up does not help here, so the lever is the label rate rather than the label size, which is another argument for the 2x and 4x labels.

Opaque failures. When a job is slow or fails and the cause is unclear, WarpBuild's CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a job waiting on a provider API from a job that ran out of disk. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect .terraform/providers, the plugin cache directory, and backend reachability on the machine itself. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger are all part of the same product surface.

Deployment pipelines that end in a cluster rather than a cloud API have a matching set of failure modes; those are covered in Kubernetes deployments on GitHub Actions.

Proof

Public OSS repositories running warp- labels are citable evidence, and you can read the runs-on line yourself instead of taking a claim on trust. The Trigger.dev end-to-end suite runs its matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x in triggerdotdev/trigger.dev's e2e.yml, checked on 2026-08-13. That is the same 4x Linux label the apply job above uses.

The cheapest check is your own state.

SSO is available for a flat $250 per month, whatever the user count, which is the number to bring to the review where someone asks how apply permissions are governed.

Once the pipeline is stable, the next step for most teams is declaring the runners the same way they declare everything else. Terraform for WarpBuild BYOC on AWS covers the provider resources, the two credentials involved, and which objects Terraform owns.

FAQ

Where should TF_PLUGIN_CACHE_DIR point on a WarpBuild runner?

Point it at a directory under the runner home, such as /home/runner/.terraform.d/plugin-cache, create it before terraform init runs, and restore it with WarpBuilds/cache@v1 keyed on the hash of .terraform.lock.hcl. Terraform silently skips the cache when the directory does not exist.

What runner size do terraform plan and apply need?

Plan and apply are network bound rather than CPU bound, so warp-ubuntu-latest-x64-2x at $0.004 per minute is usually correct, and warp-ubuntu-latest-x64-4x at $0.008 per minute covers large state files and workspaces with many providers.

How does a Terraform job reach a private state backend?

Append the networking addon to the runs-on label as network.name=<config>, where the config is a Network Addon Configuration you create in the WarpBuild dashboard. The runner joins your tailnet as an ephemeral node at job start and is removed when the job finishes.

Can Terraform manage the WarpBuild runners themselves?

Terraform support exists for BYOC on AWS. The WarpBuilds/warpbuild provider is in beta, covers AWS BYOC runner images and custom runner sets, and authenticates with a ci-scoped WARPBUILD_API_KEY. Pin the prerelease version exactly, for example 0.2.0-beta.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.