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.

Context Context: Context with 4 facets. Context GitHub Actions key element AWS key element Terraform key element CDKTF key element
Context: how GitHub Actions, AWS, Terraform relate in this pattern.

Prerequisites

Prerequisites Prerequisites: layered from cdktf.json down to GCS. cdktf.json cdktf.lock CDKTF DynamoDB GCS
Prerequisites: the building blocks this section assembles.
  • cdktf-cli and providers pinned in cdktf.json; commit cdktf.lock for 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.com and 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.

OIDC token exchange inside the job OIDC token exchange inside the job: Workflow job → GitHub OIDC issuer → AWS STS → S3 backend. Workflow job GitHub OIDCissuer AWS STS S3 backend request JWT signed token AssumeRoleWithWebIdentity session creds read + lock state
Credential exchange: the job trades a repository-scoped JWT for a short-lived AWS session before any Terraform call.

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

Implementation Implementation: — Define the then — Keep the Stack then — Enforce the Plan — Define the — Keep the Stack — Enforce the Plan
Implementation: the stages run left to right — — Define the, — Keep the Stack, — Enforce the Plan.

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:

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# 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

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks sub watch this boundary cdktf.json watch this boundary hashFiles watch this boundary pull_request watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Error: Not authorized to perform sts:AssumeRoleWithWebIdentity. The IAM role's trust policy condition does not match the workflow's sub claim. Confirm the token.actions.githubusercontent.com:sub condition matches repo:org/name:ref:refs/heads/main (or the PR ref), and that permissions: id-token: write is set on the job.

Stale .gen cache produces a different graph. The cache key must hash cdktf.json. If you bump a provider version without the key changing, the runner restores old bindings. Keying on hashFiles('cdktf.json') forces a regenerate on any provider change.

The deploy job runs on a fork PR. Never gate deploy on pull_request from forks — they cannot be granted id-token: write safely. Restrict deploy to push on the protected branch, as shown.

Error: Backend initialization required, please run "terraform init". The terraform validate step above passes -backend=false deliberately, but terraform -chdir=... validate still needs the provider plugins in .terraform/. Run init -backend=false first, in the same -chdir directory, or the validate step fails before it reads a single resource block.

npm install -g cdktf-cli@latest moves 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 resulting cdktf diff is 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.

Fanning one workflow out across several stacks Fanning one workflow out across several stacks: comparison across State lock, Reviewer signal, Runner cost. Approach State lock Reviewer signal Runner cost Single job, stacks in sequence One lock at a time One merged diff Lowest Job matrix, one stack each Separate lock per stack Diff per stack Highest Matrix with max-parallel 1 Serialised on purpose Diff per stack Medium
Fan-out trade-offs: a matrix buys per-stack diffs but multiplies both runner minutes and the number of backends being locked 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.

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.