Pulumi Pipelines on GitHub Actions
Run pulumi preview on pull requests and pulumi up on merge with warp- runners: OIDC token exchange, cached provider plugins, serialized stack applies.
Overview
A Pulumi pipeline on GitHub Actions is two jobs against one stack: a preview job that runs pulumi preview on every pull request and posts the diff, and an apply job that runs pulumi up after merge behind a deployment gate. Moving that pipeline to WarpBuild is a one-line runs-on change plus a cache step for ~/.pulumi/plugins, because a Pulumi job spends most of its wall time installing provider plugins and waiting on cloud control planes rather than computing anything.
Three requirements shape the workflow file, and each one is a place where pipelines break.
The state backend has to be reachable and authenticated before any Pulumi command runs. Pulumi Cloud is the default, and pulumi login picks it up from a PULUMI_ACCESS_TOKEN or from an OIDC exchange. A self-managed backend keeps the checkpoint in your own object storage, selected with pulumi login s3://my-bucket/pulumi-state, and it carries a second requirement: a secrets provider. The default passphrase provider needs PULUMI_CONFIG_PASSPHRASE in the job environment, and a KMS provider set at pulumi stack init time with --secrets-provider awskms://alias/pulumi records itself in Pulumi.<stack>.yaml so the runner does not need a passphrase at all.
Credentials for the cloud the stack manages are separate from credentials for the backend. A Pulumi job that provisions AWS resources and stores state in Pulumi Cloud performs two OIDC exchanges, one for each side.
Applies against a single stack have to be serialized. Pulumi Cloud holds a per-stack lock and rejects a second concurrent update with a conflict error, so an overlapping run fails rather than waits. A self-managed backend keeps the checkpoint in a bucket, and object storage gives you no interlock between two runs by itself. Either way the durable fix is a concurrency group at the GitHub Actions layer, covered in concurrency group.
Pulumi pipelines live on the Linux labels, and the rest of this page uses them. Per-size rates for every label are on the pricing page.
Configuration
This workflow previews on pull requests and applies on merge, with both OIDC exchanges, the plugin cache on WarpBuilds/cache, and a job-level concurrency group on each side.
name: pulumi
on:
pull_request:
paths:
- "infra/**"
push:
branches: [main]
paths:
- "infra/**"
env:
PULUMI_SKIP_UPDATE_CHECK: "true"
jobs:
preview:
runs-on: warp-ubuntu-latest-x64-2x
concurrency:
group: pulumi-preview-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
id-token: write
pull-requests: write
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: infra/package-lock.json
- run: npm ci
- name: Restore Pulumi provider plugin cache
uses: WarpBuilds/cache@v1
with:
path: /home/runner/.pulumi/plugins
key: ${{ runner.os }}-pulumi-plugins-${{ hashFiles('infra/package-lock.json') }}
restore-keys: |
${{ runner.os }}-pulumi-plugins-
- name: Exchange the GitHub OIDC token for AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/pulumi-preview
aws-region: us-east-1
- name: Exchange the GitHub OIDC token for a Pulumi access token
uses: pulumi/auth-actions@v1
with:
organization: myorg
requested-token-type: urn:pulumi:token-type:access_token:organization
- uses: pulumi/actions@v6
with:
command: preview
stack-name: myorg/infra/production
work-dir: infra
diff: true
comment-on-pr: true
github-token: ${{ secrets.GITHUB_TOKEN }}
up:
if: github.ref == 'refs/heads/main'
needs: preview
runs-on: warp-ubuntu-latest-x64-4x
environment: production
concurrency:
group: pulumi-up-myorg-infra-production
cancel-in-progress: false
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: infra/package-lock.json
- run: npm ci
- name: Restore Pulumi provider plugin cache
uses: WarpBuilds/cache@v1
with:
path: /home/runner/.pulumi/plugins
key: ${{ runner.os }}-pulumi-plugins-${{ hashFiles('infra/package-lock.json') }}
restore-keys: |
${{ runner.os }}-pulumi-plugins-
- name: Exchange the GitHub OIDC token for AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/pulumi-apply
aws-region: us-east-1
- name: Exchange the GitHub OIDC token for a Pulumi access token
uses: pulumi/auth-actions@v1
with:
organization: myorg
requested-token-type: urn:pulumi:token-type:access_token:organization
- uses: pulumi/actions@v6
with:
command: up
stack-name: myorg/infra/production
work-dir: infra
refresh: trueFive details in that file earn their place.
The two OIDC exchanges are independent. aws-actions/configure-aws-credentials@v4 assumes an IAM role from the GitHub OIDC token and exports temporary AWS credentials for the provider. pulumi/auth-actions@v1 exchanges the same OIDC token for a short-lived Pulumi access token and exports PULUMI_ACCESS_TOKEN for later steps. Both need id-token: write on the job, and together they remove every long-lived cloud key and backend token from repository secrets. The mechanics of that exchange are covered in OIDC token exchange.
The preview and apply roles are different IAM roles. pulumi-preview needs read and describe permissions to refresh state, while pulumi-apply needs write. Splitting them means a pull request from a fork or a branch cannot mutate infrastructure even if the workflow is edited in that branch.
The environment is what gates the job. environment: production makes the job wait for the required reviewers configured on that environment before any step runs. The if line only keeps the job off pull request runs.
The concurrency group on the apply job names the stack. Keying on github.ref looks equivalent while every apply comes from main, then breaks the first time someone triggers the same stack from a tag, a schedule, or workflow_dispatch on a branch. Those runs carry different refs and would land in different groups, which is exactly the case the group exists to prevent.
cancel-in-progress is true on preview and false on apply. Cancelling a preview wastes nothing. Cancelling a pulumi up halfway leaves the stack partially updated and, on Pulumi Cloud, leaves the stack lock held until someone runs pulumi cancel against it.
Pulumi update plans, saved with pulumi preview --save-plan and consumed with pulumi up --plan, are still an experimental feature gated behind PULUMI_EXPERIMENTAL. Most pipelines therefore re-preview at apply time rather than replaying a saved plan, which is the structural difference from the saved plan file used in Terraform pipelines on GitHub Actions.
Runner isolation matters here because these jobs hold cloud credentials. Each WarpBuild runner is a fresh virtual machine with its own encrypted storage volume, created on demand and destroyed after the job, as described in the security documentation.
Sizing
pulumi preview and pulumi up call the provider once per resource and spend the wait on HTTPS responses, so the small labels are usually correct. These are the Linux runners worth considering, with shapes from the cloud runners documentation and rates from the pricing page.
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 |
| warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 |
warp-ubuntu-latest-x64-2x at $0.004 per minute is the default for a preview job. Two cores keep up with plugin extraction and with a Node or Python program that describes a few hundred resources.
warp-ubuntu-latest-x64-4x at $0.008 per minute is the step up for the apply job, for stacks that pull four or more provider plugins, and for programs that build container images or render Helm charts inside the same job.
warp-ubuntu-latest-x64-8x rarely pays for itself on Pulumi alone. Reach for it when the job compiles a large TypeScript or Go program before the engine starts, or runs a policy pack over the preview JSON in the same job.
ARM64 lowers the rate at every size, and the Pulumi CLI and the major provider plugins publish linux-arm64 builds. Confirm every plugin your stack installs has an arm64 binary before switching, because a missing one fails at install time rather than at plan time.
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 2GB with 6,000 operations a month adds about $1.00.
Worked cost model
GitHub publishes per-minute list prices for its hosted runners in the GitHub Actions billing reference. 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 25 stacks running 3,000 Pulumi jobs a month at an average of 4 minutes each. That is 12,000 runner minutes.
| Scenario | Rate | Monthly minutes | Monthly cost |
|---|---|---|---|
| GitHub-hosted standard Linux, 2 vCPU | $0.006/min | 12,000 | $72.00 |
| warp-ubuntu-latest-x64-2x, 2 vCPU | $0.004/min | 12,000 | $48.00 |
| warp-ubuntu-latest-arm64-2x, 2 vCPU | $0.003/min | 12,000 | $36.00 |
| Plugin cache storage and operations | see above | n/a | $1.00 |
The per-minute arithmetic at the 2 vCPU size: $0.006 minus $0.004 is $0.002, and $0.002 divided by $0.006 is 33 percent lower list price (GitHub pricing, checked on 2026-08-13). 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.
Bottlenecks
Cold plugin installs. Without a restored ~/.pulumi/plugins, every job downloads and unpacks each provider plugin the program imports. This is the largest fixed cost in a Pulumi pipeline and the reason the cache step exists. The key hashes package-lock.json because the pinned provider SDK versions decide which plugin versions the engine installs.
Stack lock conflicts. Two runs against one stack end with a conflict error rather than a queue, and a cancelled pulumi up can leave the lock held. The job-level concurrency group above turns the collision into a wait, and pulumi cancel releases a lock left behind by an interrupted update.
Refresh against provider rate limits. refresh: true reads every resource in the stack from the cloud API before diffing. On a stack with a thousand resources that is a thousand calls into a control plane that throttles. Refreshing on apply and skipping it on pull request previews is the usual split, and breaking one large stack into several smaller ones is the durable fix.
Missing secrets configuration on self-managed backends. A stack created with the passphrase provider fails on a runner with an error about the passphrase not being set until PULUMI_CONFIG_PASSPHRASE is present. Moving the stack to a KMS secrets provider removes the variable from the workflow entirely.
Long-converging resources. A database or a node group can hold a job open for twenty minutes while the runner sits idle. A bigger machine changes nothing there, so the lever is the label rate rather than the label size, which is another argument for the 2x and 4x labels.
Queueing at review time. A monorepo with 25 stacks previewing on one pull request wants 25 jobs at once. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically, so the matrix width is a sizing choice rather than a quota question.
Opaque failures. When a preview hangs 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 cloud API from one that ran out of disk. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect ~/.pulumi, the plugin directory, and backend reachability on the machine itself. The wider version of the gating question is covered in deployment jobs 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 stack. Signup includes $10 free credits, which covers 2,500 minutes on warp-ubuntu-latest-x64-2x. Point the preview job at a warp- label, leave the apply job where it is, and compare the two timing views for the same commit against the same stack. The plugin cache makes the second run the one worth reading.
FAQ
How do I remove PULUMI_ACCESS_TOKEN from my GitHub Actions secrets?
Grant the job id-token: write and run pulumi/auth-actions@v1 with your organization and the urn:pulumi:token-type:access_token:organization token type. The action exchanges the GitHub OIDC token for a short-lived Pulumi access token and exports it for later steps, so no long-lived token is stored in the repository.
What concurrency group serializes pulumi up correctly?
Key the group on the stack rather than on github.ref. Runs that target one stack can start from different refs, from a scheduled trigger, or from workflow_dispatch, so a group such as pulumi-up-myorg-infra-production with cancel-in-progress: false is what keeps two updates off the same checkpoint.
Which runner size do pulumi preview and pulumi up need?
Both are bound by provider API latency rather than by cores, so warp-ubuntu-latest-x64-2x at $0.004 per minute covers most preview jobs and warp-ubuntu-latest-x64-4x at $0.008 per minute covers apply jobs and stacks with several provider plugins to install.
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.