AWS CDK Pipelines on GitHub Actions
Run cdk synth and cdk diff on pull requests and cdk deploy on merge with warp- runners: OIDC role assumption, a cached toolchain, serialized asset publishing.
Overview
A CDK pipeline on GitHub Actions is two jobs against one cloud assembly: a synth job that runs cdk synth and cdk diff on every pull request, and a deploy job that runs on merge, publishes the assets the assembly references, and executes the CloudFormation change. Moving that pipeline to WarpBuild is a one-line runs-on change plus a cache step for the Node dependency tree the CDK app compiles from, because a CDK job splits its wall time between compiling TypeScript, uploading assets, and waiting on CloudFormation.
Three properties of the CDK deployment model shape the workflow file.
Synth produces an artifact worth keeping. cdk synth writes a cloud assembly to cdk.out: one CloudFormation template per stack plus an asset manifest per stack naming every file and container image the template refers to by content hash. cdk diff --app cdk.out and cdk deploy --app cdk.out both read that directory instead of re-running the app, which is what makes the merge deterministic. The template that was reviewed is the template that ships.
Assets publish before the stack updates. A lambda.Function with bundled code, a DockerImageAsset, and a BucketDeployment source all become entries in the asset manifest. The CLI uploads file assets to the bootstrap staging bucket and pushes image assets to the bootstrap ECR repository, then creates and executes a change set that points at those object keys and image tags. Assets are addressed by hash, so an unchanged bundle is skipped on the next deploy and only changed artifacts move bytes.
Bootstrapping decides the permission model. cdk bootstrap creates the CDKToolkit stack with a staging bucket, an ECR repository, and five IAM roles named with the hnb659fds qualifier by default: file-publishing-role, image-publishing-role, deploy-role, lookup-role, and cfn-exec-role. The role your workflow assumes needs sts:AssumeRole on those roles and almost nothing else, because the CLI assumes them itself and CloudFormation acts as cfn-exec-role. The full role list is in the AWS CDK bootstrapping guide.
CDK 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 synthesizes and diffs on pull requests, uploads the cloud assembly, then publishes assets and deploys on merge. Both jobs exchange the GitHub OIDC token for AWS credentials, and the deploy job is serialized on the stack it targets.
name: cdk
on:
pull_request:
paths:
- "infra/**"
push:
branches: [main]
paths:
- "infra/**"
env:
AWS_REGION: us-east-1
jobs:
synth:
runs-on: warp-ubuntu-latest-x64-4x
concurrency:
group: cdk-synth-${{ 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
- name: Restore the CDK toolchain cache
uses: WarpBuilds/cache@v1
with:
path: |
/home/runner/.npm
infra/.cdk.staging
key: ${{ runner.os }}-cdk-${{ hashFiles('infra/package-lock.json') }}
restore-keys: |
${{ runner.os }}-cdk-
- run: npm ci
- 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/cdk-github-readonly
aws-region: ${{ env.AWS_REGION }}
- run: npx cdk synth --all
- run: npx cdk diff --app cdk.out --all
- uses: actions/upload-artifact@v4
with:
name: cloud-assembly-${{ github.sha }}
path: infra/cdk.out
retention-days: 5
deploy:
if: github.ref == 'refs/heads/main'
needs: synth
runs-on: warp-ubuntu-latest-x64-4x
environment: production
concurrency:
group: cdk-deploy-production-AppStack
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
- run: npm ci
- uses: actions/download-artifact@v4
with:
name: cloud-assembly-${{ github.sha }}
path: infra/cdk.out
- 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/cdk-github-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Publish file and image assets
run: npx cdk-assets publish -p cdk.out/AppStack.assets.json
- name: Deploy the reviewed cloud assembly
run: >
npx cdk deploy --app cdk.out AppStack
--require-approval never
--progress eventsFive details in that file earn their place.
The two jobs assume different IAM roles. cdk-github-readonly needs sts:AssumeRole on the lookup role so cdk diff can read the deployed template and resolve context. cdk-github-deploy needs the publishing and deploy roles as well. Scope each trust policy on the OIDC sub claim: the read role on repo:myorg/myrepo:pull_request, the deploy role on repo:myorg/myrepo:ref:refs/heads/main. A pull request from a fork then cannot reach a role that mutates infrastructure even if the workflow file is edited in that branch. GitHub documents the claim shapes in its OpenID Connect hardening guide, and the exchange itself is covered in OIDC token exchange.
--app cdk.out appears on both diff and deploy. Without it the CLI re-executes the app, re-resolves fromLookup context, and re-runs bundling. Any of those can produce a template that no longer matches the diff a reviewer approved.
The asset publish step is separate from the deploy step. cdk deploy publishes anything outstanding on its own, so the explicit cdk-assets publish call is there to give the upload phase its own timing line in the job view and to fail before the change set is created. On a repository with large Lambda bundles or container images, that step is usually the longest one in the job.
The concurrency group is keyed on the stack rather than on the ref. CloudFormation rejects an update against a stack already in UPDATE_IN_PROGRESS, and a group keyed on github.ref puts a workflow_dispatch run and a main push in different groups even though they target the same stack. cancel-in-progress: false queues the second run instead of leaving a half-applied change set.
--require-approval never is required for an unattended job. The CLI otherwise blocks on any change that widens IAM permissions or security group rules and waits for a terminal answer that never arrives. The environment: production gate is where human approval belongs, and the wider version of that argument is in deployment jobs on GitHub Actions.
Each WarpBuild runner is a fresh virtual machine with its own encrypted storage volume, created on demand and destroyed after the job, which is what keeps a deploy role's temporary credentials off any machine a later job can reach. The details are in the security documentation.
Sizing
CDK sizing differs from the usual infrastructure-as-code shape. npm ci and cdk synth compile the app and walk the construct tree, so the synth job is CPU bound and rewards cores, while the deploy job waits on uploads and on CloudFormation. Shapes below come 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-4x | 4 | 16GB | 150GB SSD | $0.006 |
| warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 |
warp-ubuntu-latest-x64-4x at $0.008 per minute is the default for the synth job. Four cores cover a TypeScript compile, esbuild bundling for several NodejsFunction constructs, and a construct tree of a few thousand nodes.
warp-ubuntu-latest-x64-8x at $0.016 per minute pays for itself when the app builds container images inline or synthesizes twenty or more stacks in one pass.
warp-ubuntu-latest-x64-2x at $0.004 per minute is enough for a deploy job that only uploads a small bundle and polls CloudFormation. Splitting the labels by job is worth doing when the deploy job routinely runs longer than ten minutes.
ARM64 lowers the rate at every size, and esbuild bundling produces the same JavaScript either way. One prerequisite: a DockerImageAsset built on an ARM64 runner produces an ARM64 image, so a Lambda function or ECS task definition configured for x86_64 fails after a label switch unless the build targets the platform explicitly.
Worked cost model and asset upload
GitHub publishes per-minute list prices for its hosted runners in the GitHub Actions billing reference. Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute and the standard ubuntu-latest runner for private repositories is $0.006 per minute at 2 vCPU.
Take a platform team with 18 CDK stacks running 2,600 jobs a month at an average of 6 minutes each. That is 15,600 runner minutes.
| Scenario | Rate | Monthly minutes | Monthly cost |
|---|---|---|---|
| GitHub-hosted 4-core Linux larger runner | $0.012/min | 15,600 | $187.20 |
| warp-ubuntu-latest-x64-4x | $0.008/min | 15,600 | $124.80 |
| warp-ubuntu-latest-arm64-4x | $0.006/min | 15,600 | $93.60 |
| Toolchain cache storage and operations | see below | n/a | $1.40 |
The per-minute arithmetic at the 4 vCPU size: $0.012 minus $0.008 is $0.004, and $0.004 divided by $0.012 is 33 percent lower list price (GitHub pricing, checked on 2026-08-13). Cache metering is small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001, so a 2.5GB toolchain cache with 9,000 operations a month adds $1.40. Every rate here carries its source and the date it was checked, and the same numbers appear on the pricing page.
Asset publishing carries two separate costs. The upload minutes are billed at the runner rate above, and a larger label does not shorten them because the step is bandwidth bound. The published objects then sit in the bootstrap staging bucket and the bootstrap ECR repository in your own AWS account and accrue storage charges there until they are removed, which is what cdk gc and an ECR lifecycle policy exist for. A repository deploying twice a day with a 90MB image asset writes roughly 5GB of new image layers a month into ECR.
Bottlenecks
Cold dependency installs. npm ci in a CDK app pulls aws-cdk-lib, constructs, the CLI, and the bundler on every fresh machine. Restoring the npm cache keyed on package-lock.json is the single largest fixed saving in the pipeline and the reason the cache step exists.
Unresolved context. Vpc.fromLookup and its siblings call AWS at synth time through the lookup role and write the answers to cdk.context.json. When that file is absent from the repository, every synth pays for the lookups, and a job without lookup credentials synthesizes dummy values that produce a template different from the deployed one. Commit cdk.context.json and refresh it with cdk context --clear when the underlying resources move.
Docker image assets built inline. Building an image on the runner puts a full container build inside a job sized for a TypeScript compile. WarpBuild remote Docker builders take that build off the runner and keep a persistent layer cache across jobs, which suits a repository whose CDK app ships several image assets.
Rollbacks holding a job open. A failed update enters UPDATE_ROLLBACK_IN_PROGRESS and the CLI keeps polling until the stack settles, which can take as long as the deploy did. Sizing up changes nothing there, so the lever is the label rate rather than the label size.
Wide synth matrices at review time. A monorepo that previews 18 stacks on one pull request wants 18 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 matrix width stays a sizing decision rather than a quota question.
Opaque failures. When a synth job slows down 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 an upload from one that ran out of memory during bundling. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect cdk.out, the asset manifest, and role assumption on the machine itself.
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 synth job above uses.
The cheapest check is your own app. Signup includes $10 free credits, which covers 1,250 minutes on warp-ubuntu-latest-x64-4x. Point the synth job at a warp- label, leave the deploy job where it is, and compare the two timing views for the same commit against the same cloud assembly. The second run is the one worth reading, because the toolchain cache is warm by then.
Teams that want the deploy job inside their own AWS account rather than on hosted runners can run the same workflow on BYOC runners on AWS, and teams comparing the CDK pattern against a saved plan file will find the parallel in Terraform pipelines on GitHub Actions.
FAQ
How does a GitHub Actions job get credentials for cdk deploy without an access key?
Grant the job id-token: write, assume an IAM role with aws-actions/configure-aws-credentials@v4, and give that role sts:AssumeRole on the cdk-hnb659fds-* bootstrap roles in the target account. The CDK CLI assumes the file publishing, image publishing, and deploy roles itself from those credentials, so no long-lived key is stored in the repository.
Why should cdk deploy run with --app cdk.out?
Pointing the CLI at the cloud assembly the synth job uploaded means the deploy applies the exact template that was reviewed on the pull request. Re-running the app at deploy time re-resolves context lookups and re-runs bundling, which can produce a template that differs from the one in the diff.
What runner size does a CDK app need?
Synth compiles the app and walks the construct tree, so it is CPU bound and warp-ubuntu-latest-x64-4x at $0.008 per minute is the usual default. Deploy is bound by asset upload bandwidth and CloudFormation convergence, so a larger label there buys little unless the app builds Docker image assets.
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.