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.
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.
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'
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.
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.
Frequently Asked Questions
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.