Running CDKTF Pipelines in GitHub Actions
This guide wires a Python CDK for Terraform project into GitHub Actions: caching the generated provider bindings, running synthesis and a plan on pull requests, authenticating to AWS with OIDC instead of long-lived keys, and gating the deploy behind branch protection. It is the concrete automation behind CDKTF Testing and CI/CD, part of the wider CDKTF Workflows & Terraform Synthesis practice. The workflow below is deliberately small enough to read in one screen; the sections after it explain what each block is defending against and how to fan the same shape out across several stacks.
Context
Running cdktf deploy from a laptop couples your infrastructure state to one machine's credentials and local provider cache. Moving it into GitHub Actions makes deploys reproducible and auditable, but only if you handle three things correctly: the .gen bindings must be regenerated or cached so synthesis is deterministic, AWS access must use short-lived OIDC tokens rather than stored secrets, and the apply must be gated so a pull request cannot deploy itself. The plan-and-validate stages this workflow runs are described conceptually in validating synthesized Terraform from CDKTF.
A CDKTF repository has an unusual dependency shape for a Python project, and the runner is where that shows up first. Your stack code is Python, but cdktf-cli is an npm package, the provider bindings are produced by jsii from the provider's JSON schema, and cdktf itself shells out to the terraform binary for init, plan, and apply. Three toolchains therefore have to be present and agreeing on the same job: CPython for your constructs, Node for the CLI and the jsii runtime bridge, and Terraform for the actual graph walk. Miss one and the failure surfaces far from its cause — a missing Node install shows up as a Python import error, not as "node not found".
The second thing to internalise is that a GitHub-hosted runner is destroyed when the job ends. Nothing survives between the plan job and the deploy job except what is committed to the repository, what the cache restores, and what you deliberately upload as an artifact. That is why the deploy job below repeats the install and cdktf get steps instead of receiving a directory from the plan job: re-deriving the bindings from a committed cdktf.json is cheaper to reason about than trusting a handed-over folder, and it means a deploy triggered days later still produces the same synthesis input.
Prerequisites
cdktf-cliand providers pinned incdktf.json; commitcdktf.lockfor reproducible synthesis.- A remote state backend configured per state backend configuration for CDKTF (S3 + DynamoDB, GCS, or Terraform Cloud).
- An AWS IAM OIDC identity provider for
token.actions.githubusercontent.comand a role the workflow can assume. - The IAM role's trust policy scoped to your repository and branch (
repo:org/name:ref:refs/heads/main). - Branch protection on the default branch requiring the plan check to pass and a review to approve.
Verify the OIDC role from your own shell before trusting CI:
# CLI: confirm the role exists and its trust policy references the repo
aws iam get-role --role-name cdktf-deploy --query 'Role.AssumeRolePolicyDocument'
Two of those prerequisites deserve a second look. The Terraform binary on ubuntu-latest is preinstalled but unpinned, so a runner image refresh can move it under you; if your synthesized output carries a required_version constraint, install a fixed version with hashicorp/setup-terraform rather than accepting whatever the image ships. And the IAM role needs more than resource permissions — it must also be allowed to read and write the state object in S3, and to put and delete the lock item in the DynamoDB table described in configuring an S3 backend with DynamoDB locking in CDKTF.
How the Runner Gets AWS Credentials
Before any Terraform call runs, the job has to turn a GitHub identity into an AWS session. GitHub exposes two environment variables to a job that declares id-token: write — a request URL and a bearer token — and aws-actions/configure-aws-credentials@v4 uses them to fetch a signed JWT whose sub claim describes exactly where the job is running. It then calls sts:AssumeRoleWithWebIdentity with that JWT and exports AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN into the job environment for every later step to pick up.
The security property comes entirely from the role's trust policy, not from the workflow file. The policy must pin the audience to sts.amazonaws.com and constrain the sub claim to the repository — and, for a deploy role, to a specific ref or environment. A trust policy that matches repo:org/name:* will happily hand production credentials to a pull request branch, which defeats the whole exercise. Generate it from code so the constraint is reviewable:
# CLI: python scripts/bootstrap_oidc_role.py
# Provider note: this runs once, out of band, with admin credentials — not from the pipeline.
import json
from typing import Any
import boto3
ACCOUNT_ID: str = "111111111111"
REPO: str = "acme/platform-infra"
def trust_policy(repo: str, account_id: str) -> dict[str, Any]:
provider_arn = f"arn:aws:iam::{account_id}:oidc-provider/token.actions.githubusercontent.com"
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Federated": provider_arn},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
# Exact match on the ref: a PR branch cannot assume this role.
"token.actions.githubusercontent.com:sub": f"repo:{repo}:ref:refs/heads/main",
}
},
}
],
}
iam = boto3.client("iam")
iam.update_assume_role_policy(
RoleName="cdktf-deploy",
PolicyDocument=json.dumps(trust_policy(REPO, ACCOUNT_ID)),
)
Sessions default to one hour. An apply that creates an RDS instance or waits on a CloudFront distribution can exceed that, and the credentials do not auto-renew mid-step, so raise role-duration-seconds on the deploy job to comfortably exceed your slowest apply and set the role's MaxSessionDuration to match.
Implementation
Step 1 — Define the Workflow with Caching and a Plan Job
The workflow installs dependencies, restores the cached .gen bindings, runs static checks, synthesizes, validates, and posts a plan on pull requests. The deploy job runs only on a push to main.
# .github/workflows/cdktf.yml
# CLI invoked by the runner: this file drives `cdktf synth`, `diff`, and `deploy`.
name: cdktf
on:
pull_request:
push:
branches: [main]
permissions:
id-token: write # required for OIDC to AWS
contents: read
pull-requests: write # to post the plan comment
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/setup-node@v4 # cdktf-cli ships via npm
with:
node-version: "20"
- run: npm install -g cdktf-cli@latest
- run: pip install -e .
- name: Cache generated provider bindings
uses: actions/cache@v4
with:
path: .gen
key: cdktf-gen-${{ hashFiles('cdktf.json') }}
- run: cdktf get # no-op restore is fast; regenerates on cache miss
- run: python -m mypy . --strict
- run: pytest tests/ -m "not integration"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/cdktf-deploy
aws-region: eu-west-1
- run: cdktf synth
- run: terraform -chdir=cdktf.out/stacks/NetworkStack init -backend=false
- run: terraform -chdir=cdktf.out/stacks/NetworkStack validate
- run: cdktf diff --stack NetworkStack # the plan gate, reviewed on the PR
deploy:
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # add required reviewers here for a second gate
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm install -g cdktf-cli@latest
- run: pip install -e .
- uses: actions/cache@v4
with:
path: .gen
key: cdktf-gen-${{ hashFiles('cdktf.json') }}
- run: cdktf get
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/cdktf-deploy
aws-region: eu-west-1
- run: cdktf deploy --stack NetworkStack --auto-approve
State implication: the deploy job writes to the remote backend. Because both jobs assume the same OIDC role, no static AWS keys are ever stored in repository secrets.
Step 2 — Keep the Stack Definition CI-Friendly
The stack should read its environment from typed configuration rather than from interactive prompts, so the same code runs identically on a laptop and on the runner.
# CLI: cdktf synth (invoked by the workflow's `cdktf synth` step)
# Provider note: region comes from typed config, never from an interactive prompt.
from dataclasses import dataclass
from constructs import Construct
from cdktf import App, TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
@dataclass(frozen=True)
class NetworkConfig:
region: str
cidr_block: str
class NetworkStack(TerraformStack):
def __init__(self, scope: Construct, id: str, config: NetworkConfig) -> None:
super().__init__(scope, id)
AwsProvider(self, "aws", region=config.region)
vpc = Vpc(self, "main", cidr_block=config.cidr_block, enable_dns_hostnames=True)
TerraformOutput(self, "vpc_id", value=vpc.id)
app = App()
NetworkStack(app, "NetworkStack", NetworkConfig(region="eu-west-1", cidr_block="10.0.0.0/16"))
app.synth()
Step 3 — Enforce the Plan Gate with Branch Protection
The workflow generates the plan, but GitHub enforces the gate. Require the plan job as a status check and require a review before merge, so the deploy job can only run on already-approved code.
# CLI: require the plan check and at least one approval before merge
gh api -X PUT repos/:owner/:repo/branches/main/protection \
-f required_status_checks.strict=true \
-f 'required_status_checks.contexts[]=plan' \
-F required_pull_request_reviews.required_approving_review_count=1 \
-F enforce_admins=true
Verification
Confirm OIDC and the plan gate work by opening a trivial pull request and inspecting the run:
# CLI: watch the latest run and confirm the plan job ran without static AWS keys
gh run list --workflow cdktf.yml --limit 1
gh run view --log | grep -i "Assuming role\|No changes\|Plan:"
A correct run shows the role being assumed via web identity, the cdktf diff output, and no deploy job on the pull request.
Gotchas & Edge Cases
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity. The IAM role's trust policy condition does not match the workflow'ssubclaim. Confirm thetoken.actions.githubusercontent.com:subcondition matchesrepo:org/name:ref:refs/heads/main(or the PR ref), and thatpermissions: id-token: writeis set on the job.
Stale
.gencache produces a different graph. The cache key must hashcdktf.json. If you bump a provider version without the key changing, the runner restores old bindings. Keying onhashFiles('cdktf.json')forces a regenerate on any provider change.
The deploy job runs on a fork PR. Never gate deploy on
pull_requestfrom forks — they cannot be grantedid-token: writesafely. Restrict deploy topushon the protected branch, as shown.
Error: Backend initialization required, please run "terraform init". Theterraform validatestep above passes-backend=falsedeliberately, butterraform -chdir=... validatestill needs the provider plugins in.terraform/. Runinit -backend=falsefirst, in the same-chdirdirectory, or the validate step fails before it reads a single resource block.
npm install -g cdktf-cli@latestmoves under you. A CLI minor bump can change the synthesized JSON — new metadata keys, a different construct ID hashing rule — and turn a no-op deploy into a replace. Pin the exact version ([email protected]) and bump it in a pull request where the resultingcdktf diffis visible to a reviewer.
Operational Notes
Once the workflow goes green the remaining problems are operational: contention on the backend, runner minutes, and how much of the plan output ends up in a log that anyone with read access can scroll.
Serialise anything that touches the same state file. Two deploy jobs racing on one backend collide on the DynamoDB lock item and the loser exits with Error acquiring the state lock … ConditionalCheckFailedException: The conditional request failed, leaving a half-applied change and a lock ID someone has to force-unlock by hand. GitHub's concurrency key removes the race without any Terraform-side change:
# CLI: this block sits at the top level of .github/workflows/cdktf.yml, beside `jobs:`
# State implication: one in-flight run per ref means one holder of the backend lock.
concurrency:
group: cdktf-deploy-${{ github.ref }}
cancel-in-progress: false # cancelling mid-apply orphans the lock item
cancel-in-progress: false matters more than the group name. Cancelling a job that is inside terraform apply kills the process without releasing the lock, and the next run then reports a lock held by a workflow that no longer exists.
Once you have more than one stack, decide deliberately whether to fan out. A job matrix gives each stack its own log, its own diff and its own failure, which is what reviewers want; it also multiplies runner minutes by the number of stacks and starts several backend sessions at once.
Keep the matrix derived from the code rather than hand-maintained in YAML — a stack added in Python and forgotten in the workflow is silently never deployed. cdktf synth writes cdktf.out/manifest.json, which lists every stack it produced, so a discovery job can emit the list for fromJSON:
# CLI: cdktf synth && python scripts/list_stacks.py >> "$GITHUB_OUTPUT"
# State implication: reads only the synthesized manifest — it never contacts the remote backend.
import json
from pathlib import Path
from typing import Any
MANIFEST: Path = Path("cdktf.out/manifest.json")
def stack_names(manifest_path: Path) -> list[str]:
data: dict[str, Any] = json.loads(manifest_path.read_text(encoding="utf-8"))
return sorted(data["stacks"].keys())
print(f"stacks={json.dumps(stack_names(MANIFEST))}")
Set timeout-minutes on the deploy job to something below your OIDC session length. The default job timeout is six hours, far longer than a one-hour STS session, so an apply that hangs waiting on a resource will sit there burning minutes and then fail with ExpiredToken: The security token included in the request is expired rather than failing fast on the timeout. A deploy job with timeout-minutes: 45 and role-duration-seconds: 3600 fails in the right order.
Finally, treat the plan output as sensitive by default. cdktf diff prints attribute values, and anything not marked sensitive in the synthesized JSON appears in plain text in the run log — which is readable by every collaborator and, on a public repository, by everyone. Mark generated passwords and connection strings sensitive in the construct itself so Terraform renders them as (sensitive value), and prefer uploading the plan as a short-retention artifact over pasting it into a pull request comment that lives forever in the timeline.
Related
- CDKTF Testing and CI/CD — the pipeline stages and testing strategy this workflow automates.
- Validating Synthesized Terraform from CDKTF — the synth-then-validate step the workflow runs on every pull request.
- State Backend Configuration for CDKTF — configuring the remote backend the deploy job writes to.
FAQ
Why use OIDC instead of storing AWS access keys as GitHub secrets? OIDC issues a short-lived token scoped to the specific repository and branch, so there is no long-lived credential to leak or rotate. A stored access key, by contrast, stays valid until someone manually revokes it and is exposed to every workflow that can read secrets.
Should I cache .gen or regenerate it every run?
Cache it, keyed on a hash of cdktf.json. Regeneration is correct but slow; caching keeps synthesis fast while the key guarantees the bindings refresh whenever you change a provider version, so determinism is preserved.
How do I stop a pull request from deploying to production?
Two controls together: restrict the deploy job with if: github.ref == 'refs/heads/main' && github.event_name == 'push', and require the plan status check plus a review through branch protection. The PR can run synth, validate, and diff, but it cannot apply.
Can I run multiple stacks in one workflow?
Yes. Pass each stack name to cdktf synth, cdktf diff, and cdktf deploy, or use a job matrix over stack names. Keep the plan and deploy steps per-stack so a reviewer sees a separate diff for each one.
Why does the plan job need AWS credentials at all if it never applies anything?
cdktf diff runs terraform plan, which reads the current state from the backend and calls the provider's refresh APIs to compare it with reality. Without credentials the step fails at terraform init on the S3 backend. Give the plan job a read-only role — s3:GetObject on the state key, dynamodb:GetItem on the lock table, and the provider's Describe*/Get* actions — rather than reusing the deploy role.
Do I still need terraform validate if cdktf synth already succeeded?
Yes, they check different things. Synthesis proves your Python produced well-formed JSON; terraform validate proves the provider accepts that JSON — wrong attribute names, invalid enum values and impossible references surface there, before a plan spends time on a refresh that will fail anyway.
How do I run this against infrastructure inside a private VPC? A GitHub-hosted runner has no route to a private subnet, so any provider that talks to an in-VPC endpoint — a Postgres provider against a private RDS instance, a Kubernetes provider against a private API server — fails with a connection timeout. Move that stack's jobs to a self-hosted runner inside the VPC, or reach the endpoint through a bastion or a VPC endpoint that the runner's egress can hit.