CDK for Terraform (CDKTF) lets you write infrastructure as a TypeScript program that synthesizes to Terraform JSON, then hands the actual plan/apply work to the Terraform binary you already trust. You get the Terraform provider ecosystem and state model unchanged, but you author it with classes, generics, npm packages, and a real test runner instead of HCL. That is a genuinely different value proposition from raw HCL or Terragrunt: the abstractions are first-class language constructs, not string-templated modules.
This guide is the principal-engineer version. It covers how synthesis actually works, how to generate and consume provider bindings, how to build a layered construct library up to an L3 abstraction, how cross-stack references and backends work in code, the escape hatches you will need when bindings fall short, and how to test and ship the whole thing through CI. Everything targets CDKTF 0.20+ and Terraform 1.x.
1. The architecture: synth to JSON, then standard Terraform
The single most important thing to internalise is that CDKTF does not provision anything. Your program constructs an object tree rooted at an App. When you call app.synth() (or run cdktf synth), each construct emits its fragment of a Terraform configuration, and CDKTF writes one cdk.tf.json file per stack into cdktf.out/stacks/<stack-name>/. That JSON is ordinary Terraform JSON syntax — providers, resources, data sources, outputs, backend config. From there, cdktf deploy shells out to the terraform binary to run init, plan, and apply against that generated file.
| Layer | Responsibility |
|---|---|
| Your TypeScript | Build the construct tree, wire dependencies via object references |
constructs / cdktf |
Object model (Construct, App, TerraformStack, tokens) |
| Synthesis | Tree to cdk.tf.json per stack in cdktf.out |
cdktf CLI |
Orchestrates terraform init/plan/apply over the JSON |
| Terraform core + providers | The real plan/apply, state, and cloud API calls |
Two consequences follow. First, the engine is still Terraform: state files, the provider plugin protocol, moved/import semantics, and the dependency graph all behave exactly as they do in HCL. CDKTF is a front end. Second, anything HCL can express, the generated JSON can express — so when a construct cannot produce the config you need, you can always drop down to raw overrides (see step 5). You are never trapped.
Construct dependencies are implicit. When you pass bucket.arn into another resource’s props, CDKTF emits a Terraform interpolation token (${aws_s3_bucket.x.arn}) and Terraform builds the edge. You almost never write depends_on by hand; you express ordering by referencing attributes, just like in HCL but with compiler-checked property names.
2. Project scaffolding and provider bindings
Initialise a TypeScript project. The template wires up cdktf.json, main.ts, tsconfig.json, Jest, and the cdktf and constructs dependencies.
mkdir infra && cd infra
cdktf init --template=typescript --local
--local configures local state to start; we switch to a remote backend in step 4. The generated cdktf.json is the control file. Declare the providers and modules you want bindings for here:
{
"language": "typescript",
"app": "npx ts-node main.ts",
"projectId": "f7c1a0e2-3b9d-4a11-9f2e-7c6b1d8e4a90",
"terraformProviders": ["aws@~> 5.0"],
"terraformModules": [
{ "name": "vpc", "source": "terraform-aws-modules/vpc/aws", "version": "~> 5.0" }
],
"codeMakerOutput": ".gen"
}
There are two ways to get provider bindings, and the distinction matters for build speed and repo hygiene:
- Pre-built providers are published npm packages (e.g.
@cdktf/provider-aws). They are versioned, cached, and add nothing to your synth time. Prefer them for the big three clouds. - Generated bindings are produced locally from the provider schema by
cdktf get, written intocodeMakerOutput(here.gen). Use these for providers without a pre-built package, or when you pin an exact version.
Add a pre-built provider:
cdktf provider add "aws@~> 5.0"
Generate local bindings for everything listed in cdktf.json (providers and modules):
cdktf get
Gitignore the generated output (
.gen/) and treat it as a build artifact, exactly likenode_modules. Regenerating is deterministic fromcdktf.json. Checking it in bloats reviews with thousands of machine-generated lines and creates spurious merge conflicts. Runcdktf getin CI before synth.
Module bindings are the underrated feature here. cdktf get reads the module’s variables.tf and outputs.tf and emits a typed class — so a community Terraform module like terraform-aws-modules/vpc/aws becomes new Vpc(this, "vpc", { ... }) with autocomplete on every input variable and typed access to every output.
3. Layered constructs: from L1 resources to an L3 abstraction
The CDK community describes three abstraction levels, and applying that vocabulary keeps a construct library legible:
- L1 — generated resource bindings, a 1:1 mapping to Terraform resources (
S3Bucket,IamRole). Maximum control, zero opinions. - L2 — a curated wrapper around one or a few L1s with sane defaults and a tighter API (e.g. an encrypted, versioned bucket).
- L3 — a “pattern”: a whole subsystem composed from L2s, exposing only the few inputs a team actually varies.
A construct is just a class extending Construct with a typed props interface. Here is an L2 that bakes in the security baseline I never want to forget on an S3 bucket:
import { Construct } from "constructs";
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket";
import { S3BucketVersioningA } from "@cdktf/provider-aws/lib/s3-bucket-versioning";
import { S3BucketServerSideEncryptionConfigurationA } from "@cdktf/provider-aws/lib/s3-bucket-server-side-encryption-configuration";
import { S3BucketPublicAccessBlock } from "@cdktf/provider-aws/lib/s3-bucket-public-access-block";
export interface SecureBucketProps {
readonly bucketName: string;
readonly versioned?: boolean;
}
export class SecureBucket extends Construct {
public readonly bucket: S3Bucket;
constructor(scope: Construct, id: string, props: SecureBucketProps) {
super(scope, id);
this.bucket = new S3Bucket(this, "bucket", { bucket: props.bucketName });
new S3BucketPublicAccessBlock(this, "pab", {
bucket: this.bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
});
new S3BucketServerSideEncryptionConfigurationA(this, "sse", {
bucket: this.bucket.id,
rule: [{ applyServerSideEncryptionByDefault: { sseAlgorithm: "aws:kms" } }],
});
if (props.versioned ?? true) {
new S3BucketVersioningA(this, "versioning", {
bucket: this.bucket.id,
versioningConfiguration: { status: "Enabled" },
});
}
}
}
Note the id discipline: every construct takes a scope and a string id that is unique within that scope. CDKTF derives the Terraform resource address from the path of ids, so renaming an id renames the resource and triggers a destroy/recreate unless you add a moveTo (step 5). Treat ids as a stable API.
An L3 composes L2s into a deployable pattern. A “static site” L3 might own a bucket, an OAC-fronted CloudFront distribution, and the bucket policy, exposing only the domain name and the bucket as outputs. The discipline is to surface intent (domainName, priceClass) and hide mechanism. That is the payoff over HCL modules: an L3 is a typed class you can unit test, version on npm, and refactor with the compiler watching your back.
4. Stacks, state backends, and cross-stack references
A TerraformStack is the synthesis unit — one stack, one cdk.tf.json, one Terraform state. Split stacks along blast-radius and lifecycle boundaries (networking vs. data vs. app), not arbitrarily. Each stack configures its own backend as a construct in its constructor. The first-class backend classes (S3Backend, CloudBackend, GcsBackend, AzurermBackend) emit the terraform { backend ... } block:
import { App, TerraformStack, S3Backend, TerraformOutput } from "cdktf";
import { Construct } from "constructs";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
class NetworkStack extends TerraformStack {
public readonly vpcId: string;
constructor(scope: Construct, id: string) {
super(scope, id);
new S3Backend(this, {
bucket: "kv-tfstate-prod",
key: `${id}/terraform.tfstate`,
region: "us-east-1",
dynamodbTable: "kv-tfstate-locks",
encrypt: true,
});
new AwsProvider(this, "aws", { region: "us-east-1" });
// ... create VPC ...
this.vpcId = "vpc-placeholder"; // e.g. vpc.id
}
}
Cross-stack references are the headline ergonomic win. You do not manage remote-state data sources by hand. Expose a value from the producer stack as a property, then read it in the consumer. CDKTF detects the cross-stack reference and automatically synthesizes a TerraformOutput in the producer and a terraform_remote_state data source in the consumer:
class AppStack extends TerraformStack {
constructor(scope: Construct, id: string, network: NetworkStack) {
super(scope, id);
new S3Backend(this, { bucket: "kv-tfstate-prod", key: `${id}/terraform.tfstate`, region: "us-east-1" });
new AwsProvider(this, "aws", { region: "us-east-1" });
// Referencing another stack's property wires up remote state automatically.
new TerraformOutput(this, "consumed_vpc", { value: network.vpcId });
}
}
const app = new App();
const network = new NetworkStack(app, "network");
new AppStack(app, "app", network);
app.synth();
Two caveats from production. First, the consumer’s remote-state data source needs read access to the producer’s backend — same bucket/credentials, or an explicitly granted role. Second, cross-stack references force deploy ordering: you must apply network before app, and CDKTF enforces that dependency when you run cdktf deploy '*'.
5. Asset bundling, escape hatches, and overrides
Two facilities cover the gap between “what the bindings model” and “what you actually need to ship.”
Assets. TerraformAsset stages a local file or directory into cdktf.out and gives you a path and hash to feed downstream resources. For a Lambda, archive a directory and upload the zip:
import * as path from "path";
import { TerraformAsset, AssetType } from "cdktf";
import { S3Object } from "@cdktf/provider-aws/lib/s3-object";
const asset = new TerraformAsset(this, "lambda-asset", {
path: path.resolve(__dirname, "lambda"),
type: AssetType.ARCHIVE,
});
new S3Object(this, "lambda-archive", {
bucket: bucket.bucket,
key: `lambdas/${asset.fileName}`,
source: asset.path,
sourceHash: asset.assetHash,
});
AssetType.FILE, AssetType.DIRECTORY, and AssetType.ARCHIVE cover single files, copied trees, and zipped trees respectively. Wiring assetHash into sourceHash is what makes Terraform notice code changes and redeploy.
Escape hatches. When a generated construct cannot express something — a brand-new provider attribute, a provisioner, a nested block the binding renders awkwardly — call addOverride(path, value) on the resource. The path is dot-delimited, uses snake_case attribute names (it operates on the synthesized JSON, not the camelCase TS), and supports numeric array indices:
const bucket = new S3Bucket(this, "bucket", { bucket: "kv-legacy" });
// Force an attribute the binding does not surface yet.
bucket.addOverride("force_destroy", true);
// Inject a dynamic block over the synthesized JSON.
bucket.addOverride("dynamic.lifecycle_rule", {
for_each: "${var.rules}",
content: { id: "${lifecycle_rule.key}", enabled: true },
});
Resource-level escape hatches also include the refactoring helpers that mirror HCL’s moved and import blocks: moveTo(target) / addMoveTarget(name) to rename without destroy, and importFrom(id) to adopt existing infrastructure into state. Reach for overrides sparingly and comment every one — they are invisible to the type system, so they are exactly where drift between intent and output hides.
6. Unit and snapshot testing
This is the reason teams adopt CDKTF in the first place: you can assert on synthesized infrastructure with a normal test runner, no cloud account required. The typescript template ships Jest with CDKTF’s custom matchers. Register them in your Jest setup file:
// setup.js
const cdktf = require("cdktf");
cdktf.Testing.setupJest();
// jest.config.js (excerpt)
{
"testMatch": ["**/*.test.ts"],
"setupFilesAfterEnv": ["<rootDir>/setup.js"]
}
Testing.synthScope synthesizes a fragment of the tree so you can assert on a single construct without standing up a whole stack. The matchers operate on that synthesized JSON using Terraform resource type names and snake_case properties:
import { Testing } from "cdktf";
import { S3Bucket } from "@cdktf/provider-aws/lib/s3-bucket";
import { SecureBucket } from "../lib/secure-bucket";
describe("SecureBucket", () => {
it("encrypts with KMS and blocks public access", () => {
const synth = Testing.synthScope((scope) => {
new SecureBucket(scope, "test", { bucketName: "kv-test" });
});
expect(synth).toHaveResource(S3Bucket);
expect(synth).toHaveResourceWithProperties(
"aws_s3_bucket_server_side_encryption_configuration",
{ rule: [{ apply_server_side_encryption_by_default: { sse_algorithm: "aws:kms" } }] }
);
expect(synth).toHaveResourceWithProperties("aws_s3_bucket_public_access_block", {
block_public_acls: true,
restrict_public_buckets: true,
});
});
it("omits versioning when disabled", () => {
const synth = Testing.synthScope((scope) => {
new SecureBucket(scope, "test", { bucketName: "kv-test", versioned: false });
});
expect(synth).not.toHaveResource("aws_s3_bucket_versioning");
});
});
The full matcher set: toHaveResource / toHaveResourceWithProperties, toHaveDataSource / toHaveDataSourceWithProperties, toHaveProvider / toHaveProviderWithProperties, plus the heavier toBeValidTerraform() and toPlanSuccessfully() which actually invoke Terraform (slower, integration-grade — gate them behind a separate test target).
Snapshot tests catch unintended changes to the whole generated config. Use Testing.synth(stack) for a full stack and Jest snapshots:
it("matches the approved synthesis", () => {
const app = Testing.app();
const stack = new NetworkStack(app, "network");
expect(Testing.synth(stack)).toMatchSnapshot();
});
Snapshots are a tripwire: a reviewer sees the exact JSON delta a refactor produces. Commit the __snapshots__/ directory, and treat an unexpected snapshot diff in a PR as a blocking signal, not a --updateSnapshot reflex.
7. CI/CD: get, synth, diff, deploy with an approval gate
The CI shape mirrors a Terraform pipeline, with a synth step in front. The key discipline is the gate between diff and deploy: synthesize and plan automatically, but require a human to approve the apply against production.
# .github/workflows/cdktf.yml
name: cdktf
on:
pull_request:
push:
branches: [main]
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write # for cloud OIDC
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- uses: hashicorp/setup-terraform@v3
with: { terraform_wrapper: false }
- run: npm ci
- run: npx cdktf get # regenerate bindings
- run: npm test # unit + snapshot tests
- run: npx cdktf synth # produce cdktf.out
- run: npx cdktf diff app # terraform plan for the 'app' stack
deploy:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # <-- required reviewers = approval gate
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- uses: hashicorp/setup-terraform@v3
with: { terraform_wrapper: false }
- run: npm ci
- run: npx cdktf get
- run: npx cdktf deploy app --auto-approve
The terraform_wrapper: false line matters: the wrapper that setup-terraform installs by default intercepts stdout and confuses CDKTF’s parsing of Terraform output. The approval gate is the GitHub environment: production with required reviewers — the deploy job blocks until someone approves. --auto-approve is safe only because the human gate already happened; never put it on a job that runs unattended without that protection. Use OIDC (id-token: write) for short-lived cloud credentials rather than long-lived secrets.
8. Choosing CDKTF vs. HCL vs. Pulumi
These tools are not interchangeable, and the right answer depends on your team more than on the technology.
| Dimension | HCL (Terraform) | CDKTF | Pulumi |
|---|---|---|---|
| Authoring | Declarative DSL | TS/Python/Go/Java/C# | TS/Python/Go/Java/C#/YAML |
| Engine | Terraform core | Terraform core (via synth) | Pulumi engine over gRPC |
| State | Terraform backends | Terraform backends (unchanged) | Pulumi backends |
| Abstraction | Modules | Language classes (L1/L2/L3) | ComponentResource classes |
| Testing | terraform test, Terratest |
Jest matchers + snapshots | Native test frameworks + mocks |
| Maturity | Highest; huge ecosystem | Stable but smaller community | Mature, large ecosystem |
Choose HCL when your team is comfortable with it, your modules are not fighting the language, and you value the largest ecosystem and the simplest mental model. Most teams should stay here. Choose CDKTF when you want real programming abstractions but must keep the Terraform engine, providers, and state model — for example, an existing Terraform shop standardising on TypeScript, or a platform team building a typed L3 library on top of community modules. Choose Pulumi when you want a code-first tool end to end and are willing to adopt its engine and state model rather than Terraform’s.
The honest tradeoff for CDKTF: you gain types, tests, and composition, and you pay with an extra synthesis layer, a smaller community, and one more abstraction to debug when generated JSON surprises you. For a team already strong in TypeScript and committed to Terraform, that trade is worth it. For a team fluent in HCL with modules that work, it usually is not.
Verify
Confirm the toolchain end to end on a fresh checkout:
# 1. Install and regenerate bindings deterministically.
npm ci
npx cdktf get
# 2. Tests pass (unit assertions + snapshots), no cloud needed.
npm test
# 3. Synthesis produces one cdk.tf.json per stack.
npx cdktf synth
ls cdktf.out/stacks # expect: network/ app/ ...
cat cdktf.out/stacks/network/cdk.tf.json | jq '.terraform.backend' # backend present
# 4. The generated JSON is valid Terraform and plans cleanly.
npx cdktf diff network # runs terraform plan; expect a clean, expected diff
# 5. List stacks and their dependency order.
npx cdktf list
If cdktf synth writes a cdk.tf.json per stack, npm test is green, and cdktf diff produces only the changes you intended, the pipeline is sound. A common failure is a noisy diff after a refactor — that is almost always a changed construct id renaming a resource; fix it with moveTo rather than accepting the destroy/recreate.