CDKTF Workflows & Terraform Synthesis

Status note: HashiCorp has deprecated CDK for Terraform (CDKTF). Teams maintaining existing CDKTF Python stacks should preserve state compatibility and plan migration paths. New greenfield infrastructure work should evaluate Pulumi or native Terraform instead.

CDK for Terraform lets you define cloud infrastructure as typed Python and compile it into the Terraform JSON that the existing provider ecosystem already understands. This section is the entry point for that workflow: it explains how the synthesis pipeline turns Python constructs into a plan you can apply, and it links out to the detailed pages on CDKTF architecture and synthesis, Python constructs and modules, state backend configuration, Terraform provider bridging, importing existing infrastructure, and CDKTF testing and CI/CD. The through-line of every page below is the same: nothing you write in Python reaches a cloud API directly. It reaches a JSON file, and Terraform reaches the cloud. Once that boundary is internalised, most of CDKTF's surprising behaviour stops being surprising.

CDKTF synthesis data flow Python constructs are processed by cdktf synth into Terraform JSON, which terraform plan and apply turn into cloud resources, with remote state recording the result. Python constructs typed stacks cdktf synth (jsii) DAG resolve Terraform JSON cdk.tf.json terraform plan / apply cloud resources Remote state backend (S3 + lock)
The CDKTF pipeline: typed Python constructs synthesize to Terraform JSON, which terraform plan and apply turn into cloud resources, recording results in a remote state backend.

Why This Matters

The reason to care about CDKTF is narrow and specific: it is the only mainstream way to keep an existing Terraform state file and an existing Terraform provider set while moving the authoring language to Python. Every alternative asks you to give one of those up. Rewriting in HCL keeps the providers but abandons the type system and the test runner. Moving to Pulumi gains a real runtime but requires a state migration and a different provider distribution. CDKTF changes only the front end, and that is both its value and the source of every constraint on this page.

The mechanism behind that front end is worth stating precisely, because it explains the failure modes later. CDKTF's Python packages are not native Python. They are generated bindings produced by jsii, HashiCorp's cross-language interop layer, from the TypeScript implementation of the CDKTF core and from each Terraform provider's JSON schema. When your Python code calls S3Bucket(self, "logs", bucket="acme-logs"), the constructor does not build a Python object graph in your process. It serialises the call and sends it over a pipe to a Node.js child process — the jsii kernel — which instantiates the real object and returns a handle. Your Python object is a proxy holding that handle.

Three consequences follow directly. First, Node.js is a hard runtime dependency of a Python CDKTF project; a container image with only Python in it cannot synthesize. Second, attribute reads are remote calls, so a stack that instantiates ten thousand resources in a tight loop is slow for reasons that have nothing to do with Python's speed — each construction is an inter-process round trip. Third, values that are not known until apply time cannot be real Python values. They are tokens: opaque strings such as ${TfToken[TOKEN.14]} that CDKTF substitutes for the real interpolation when it writes the JSON. A token behaves like a string in Python, which means if bucket.arn.startswith("arn:aws:s3") compiles, runs, and is wrong.

That last point is the single largest conceptual gap for engineers arriving from application Python. In a normal program, a value either exists or raises. In CDKTF, a value can be a placeholder that is truthy, iterable-looking, and meaningless until Terraform resolves it. You cannot branch on it, you cannot slice it, and you cannot pass it to a function that inspects its contents. You can only pass it to another resource argument or through a Fn.* helper that emits the equivalent Terraform function call. Designing around that constraint — pushing every decision that needs a real value into synthesis-time configuration, and leaving only opaque plumbing to tokens — is most of what "writing good CDKTF" means.

The payoff is that everything downstream of cdk.tf.json is ordinary Terraform. Your plan output, your state file, your terraform state mv, your Sentinel or OPA policies, your .terraform.lock.hcl, and your existing runbooks all still apply. A team with five years of Terraform operational knowledge keeps all of it and gains mypy, pytest, pip, and code review over real classes. That trade is the entire argument for the tool, and it is why deprecation does not make an existing CDKTF estate urgent to rewrite — the artifact it produces outlives the generator.

Core Concepts

The six problem areas a CDKTF project must solve The six problem areas a CDKTF project must solve: layered from Architecture & synthesis down to Testing & CI/CD. Architecture & synthesis construct tree, tokens, cdk.tf.json Provider bridging jsii bindings generated from provider schemas Constructs & modules typed, reusable, packaged components State backends S3 or Terraform Cloud, locking, isolation Import & adoption bringing pre-existing objects under management Testing & CI/CD snapshot tests, validate, plan gate, deploy
Every CDKTF project ends up solving these six problems; each has its own topic page in this section.

Architecture and the synthesis pipeline

Synthesis is the compile step, and understanding what it does — and does not — validate is the foundation for everything else. CDKTF architecture and synthesis covers the construct tree, the AppTerraformStack → construct hierarchy, how logical IDs are derived from the construct path, and how cdktf synth walks that tree to emit cdktf.out/stacks/<StackName>/cdk.tf.json alongside a manifest.json describing every stack it produced. Synthesis checks that your Python is type-correct enough to marshal across jsii and that required arguments are present. It does not check that a security group rule is legal or that a subnet CIDR fits inside its VPC — those are provider-side and surface at plan or apply.

Provider bridging

CDKTF has no cloud knowledge of its own. Every resource class you import is machine-generated from a Terraform provider's schema, which is why the argument names in Python match the HCL argument names one for one. Terraform provider bridging explains how cdktf get reads the terraformProviders list in cdktf.json, downloads the provider, dumps its schema, and code-generates a typed Python package into .gen/ — and when to use the pre-built PyPI packages such as cdktf-cdktf-provider-aws instead, which trade generation time for a fixed provider version. Multi-provider stacks and provider aliases are handled here too, since an alias in CDKTF is a constructor argument rather than a block.

Constructs and modules

A construct is a class that groups resources and exposes a typed interface. This is where CDKTF earns its keep over HCL modules: a construct can take a dataclass as input, validate it in __init__, raise a real exception, and be unit tested without any Terraform involvement. Python constructs and modules covers scope and ID conventions, composition versus inheritance, exposing outputs as properties rather than as TerraformOutput where cross-stack access is not needed, and packaging a construct library for internal distribution.

State backends

The stack is the state boundary. Each TerraformStack synthesizes to its own directory with its own backend block, and therefore its own state file and its own lock. State backend configuration for CDKTF covers the typed backend classes CDKTF ships — S3Backend, GcsBackend, AzurermBackend, and CloudBackend for Terraform Cloud — how to parameterise the state key per environment without hardcoding it, and how cross-stack references quietly turn into remote state data sources that require a remote backend to work at all.

Importing existing infrastructure

Almost no CDKTF adoption starts from an empty account, and adoption is where migrations stall. Importing existing infrastructure treats the two halves of the problem separately: getting a real cloud object recorded in state against the right address, and getting Python written that describes that object accurately enough for the planner to propose nothing. The definition of done is a zero-diff plan, and the sequencing advice there — leaf resources first, force-new attributes checked before every apply — is what keeps an import from destroying a production database.

Testing and CI/CD

Because synthesis is deterministic and offline, the whole test pyramid below terraform plan runs with no credentials. CDKTF testing and CI/CD covers cdktf.Testing.synth() unit assertions, snapshot tests over the emitted JSON, running terraform validate against cdktf.out with -backend=false, and the plan gate that must sit between a green pipeline and an apply against production state.

Architecture Decision Guide

The first decision is not "which CDKTF feature" but whether CDKTF is the right execution model at all. The table below compares the three realistic options for a Python-literate infrastructure team against the axes that actually change day-to-day work. The comparison of Pulumi and CDKTF specifically is drawn out further in Pulumi vs CDKTF for AWS.

Axis CDKTF (Python) Pulumi (Python) Terraform HCL
Execution model Synthesize to JSON, then Terraform plans and applies Python runs as the engine's language host over gRPC HCL evaluated directly by Terraform
Existing Terraform state Reused unchanged — same state format, same addresses Requires import or a state conversion pass Native
Provider availability Every Terraform provider, via generated bindings Terraform-bridged plus native providers Every Terraform provider
Debugging Read cdk.tf.json, correlate back to constructs Attach a Python debugger to a live process Read HCL and plan output
Unit testing Testing.synth() assertions on emitted JSON Mock the resource monitor terraform test / Terratest
Runtime prerequisites Python and Node.js (jsii kernel) Python only Single Go binary
Loop and conditional support Real Python control flow at synth time Real Python control flow at run time count, for_each, dynamic
Vendor status Deprecated by HashiCorp Actively developed Actively developed
Best fit Large existing Terraform estate, Python-first team Greenfield, or teams wanting a real runtime Small estates, teams happy in a DSL
Choosing an execution model for Python infrastructure Choosing an execution model for Python infrastructure: choose among 3 options. Do you already own Terraformstate and providers? yes, lots CDKTF: keep stateand providers, gainPython some HCL Terraform HCL plustargeted CDKTFstacks greenfield Pulumi: directexecution, nosynthesis step
The decision hinges on existing Terraform investment, not on language preference.

Read the table as a sequence of gates rather than a score. If you have a substantial Terraform estate whose state you cannot afford to migrate, the Pulumi column stops being available regardless of its other merits, and CDKTF is the only way to get Python in front of that state. If you are starting clean, the Node.js dependency and the deprecation notice are real costs with no offsetting benefit, and Pulumi wins. If your team is small and your infrastructure is a few dozen resources, the honest answer is often that HCL is enough and every abstraction layer you add is overhead you will maintain forever.

The second decision, once CDKTF is chosen, is how many stacks to define. A stack is a state file, a lock, and a blast radius all at once. One stack per environment per lifecycle tier — network, data, application — is a reasonable default: it keeps a routine application deploy from taking a lock on the VPC state, while avoiding the dozens-of-stacks sprawl that makes cross-stack references dominate the design. The mechanics of splitting are covered in splitting a CDKTF app into multiple stacks.

Canonical Code Pattern

The pattern below is the shape almost every production CDKTF stack converges on: a frozen dataclass carrying synthesis-time configuration, a typed backend declared with the real S3Backend class rather than a raw override, a provider configured from the dataclass, a reusable construct, and an App that builds one stack per environment. Everything decided in Python is a real value; everything decided by the cloud is a token passed straight through.

# app.py — canonical CDKTF stack shape
# CLI: cdktf get && cdktf synth && cdktf deploy --stack platform-prod
from dataclasses import dataclass
from typing import Optional

from constructs import Construct
from cdktf import App, S3Backend, TerraformOutput, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
from cdktf_cdktf_provider_aws.s3_bucket_versioning import (
    S3BucketVersioningA,
    S3BucketVersioningVersioningConfiguration,
)


@dataclass(frozen=True)
class EnvConfig:
    """Synthesis-time configuration. Every field must be a real Python value —
    a token here would be silently embedded in the state key."""
    name: str
    region: str
    state_bucket: str
    lock_table: str


class PlatformStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, cfg: EnvConfig) -> None:
        super().__init__(scope, ns)

        # State implication: one S3Backend per stack => one state file, one lock.
        S3Backend(
            self,
            bucket=cfg.state_bucket,
            key=f"platform/{cfg.name}/terraform.tfstate",
            region=cfg.region,
            dynamodb_table=cfg.lock_table,
            encrypt=True,
        )

        # Provider note: credentials come from the environment or OIDC, never from code.
        AwsProvider(self, "aws", region=cfg.region)

        artifacts = S3Bucket(self, "artifacts", bucket=f"acme-artifacts-{cfg.name}")
        S3BucketVersioningA(
            self,
            "artifacts_versioning",
            bucket=artifacts.id,  # token: resolved by Terraform, not by Python
            versioning_configuration=S3BucketVersioningVersioningConfiguration(
                status="Enabled"
            ),
        )
        TerraformOutput(self, "artifacts_bucket", value=artifacts.bucket)


def main(overrides: Optional[EnvConfig] = None) -> None:
    app = App()
    for cfg in (
        EnvConfig("staging", "eu-west-1", "acme-tfstate", "acme-tf-locks"),
        EnvConfig("prod", "eu-west-1", "acme-tfstate", "acme-tf-locks"),
    ):
        PlatformStack(app, f"platform-{cfg.name}", overrides or cfg)
    app.synth()


if __name__ == "__main__":
    main()

Two details in that listing carry more weight than their line count suggests. S3Backend takes no ID argument — a stack has exactly one backend, so the class is constructed with the stack as its only positional scope. And artifacts.id is a token, while cfg.name is a real string; the f-string in the state key is safe precisely because every value it interpolates comes from the dataclass. Reversing that — putting artifacts.id into the key — would write the literal placeholder text into your backend configuration.

Development Workflow Integration

The CDKTF inner loop is unusual because the expensive, credentialed steps are cleanly separable from the cheap, offline ones. A well-configured project lets an engineer iterate for an hour without a single AWS call, then spend thirty seconds on the two commands that need credentials.

The CDKTF inner development loop The CDKTF inner development loop: Edit construct → mypy --strict → pytest snapshot → cdktf synth → cdktf diff → commit / PR → repeat. Edit construct mypy --strict pytest snapshot cdktf synth cdktf diff commit / PR
The local loop a CDKTF engineer repeats: only the last two steps need cloud or backend credentials.

The offline half is mypy, pytest, and cdktf synth. Type checking catches the class of error jsii would otherwise raise mid-marshal, and it is worth running --strict because the generated provider bindings are fully annotated — you get real coverage, not Any everywhere. pytest over Testing.synth() asserts on the emitted JSON in milliseconds. cdktf synth is the integration point between them: if it succeeds, the construct tree resolved and every required argument was present.

# CLI: the offline loop — no cloud credentials, no backend access
python -m mypy . --strict
pytest tests/ -q
cdktf synth
jq -r '.resource | keys[]' cdktf.out/stacks/platform-prod/cdk.tf.json

The credentialed half is cdktf diff and cdktf deploy. cdktf diff is a wrapper around terraform plan; it acquires the state lock, refreshes, and prints the change set. Treat its output as the only trustworthy statement about what will happen, because it is the first step that has seen both your configuration and reality.

Three integration points repay the setup effort. Pin the toolchain: the CDKTF CLI version, the provider constraints in cdktf.json, and the .terraform.lock.hcl that Terraform writes should all be committed, so that a synth on a laptop and a synth on a runner produce byte-identical JSON. The reasoning is in pinning Terraform provider versions in CDKTF. Cache .gen/: cdktf get is slow and network-bound, and keying a CI cache on a hash of cdktf.json turns a two-minute step into a two-second one. Finally, decide early whether cdktf.out/ is committed. Committing it makes every pull request show the generated JSON diff — an excellent review artifact, and a noisy one. Most teams generate it in CI and post the diff as a comment instead.

Local environment setup — virtualenv layout, Node.js version pinning, and credential routing — is covered engine-agnostically in setting up Python IaC development environments.

The Paradigm Shift: From Declarative HCL to Programmatic Infrastructure

Modern infrastructure teams require deterministic control over cloud resource lifecycles. Transitioning from static configuration files to executable Python grants engineers strict type safety, modular reuse, and advanced debugging capabilities. Understanding the foundational CDKTF architecture and synthesis pipeline is essential for optimizing compilation performance and avoiding token resolution bottlenecks. This architectural shift enables developers to apply standard software engineering practices directly to provisioning workflows.

The Paradigm Shift: From Declarative HCL to Programmatic Infrastructure The Paradigm Shift: From Declarative HCL to Programmatic Infrastructure: layered from From Declarative HCL down to CDKTF Architecture. From Declarative HCL Python CDKTF Architecture
The Paradigm Shift: From Declarative HCL to Programmatic Infrastructure: the building blocks this section assembles.

The practical difference shows up in the parts of HCL that exist only because HCL is not a programming language. count and for_each exist because the DSL needs a way to express repetition; in Python you write a for loop and the repetition is real, with a real index and a real breakpoint. dynamic blocks exist because nested blocks cannot be generated; in Python a nested block is a constructor argument built from a list comprehension. locals exist because there is no way to name an intermediate value; in Python that is a variable. Terraform's templatefile exists because there is no string library; Python has one. Each of those replacements removes a category of HCL awkwardness, and each one hands you back the responsibility that the DSL was enforcing on your behalf.

That responsibility is the other half of the shift. HCL refuses to let you build an unreviewable configuration because it barely lets you build anything. Python will happily let you write a construct whose behaviour depends on an environment variable read at synthesis time, so that the JSON produced on your laptop differs from the JSON produced in CI while both stacks claim to be the same. Determinism stops being a property of the tool and becomes a property of your code. The rule that keeps it: synthesis must be a pure function of files committed to the repository plus an explicit environment name. Anything else — a clock read, a random suffix, a live API lookup outside a Terraform data source — makes two synths disagree and turns the plan into a source of noise nobody reads.

Provider Integration & API Translation

CDKTF does not replace Terraform providers; it translates their JSON schemas into strongly-typed Python bindings during compilation. Effective Terraform provider bridging ensures every cloud API capability remains accessible while enforcing strict schema validation. Credential injection follows environment-variable patterns to prevent secret leakage into generated artifacts. Major version upgrades require explicit dependency pinning to maintain backward compatibility across provider releases.

Provider Integration & API Translation Provider Integration & API Translation: Provider then API Translation then CDKTF then Terraform then JSON Provider API Translation CDKTF Terraform JSON
Provider Integration & API Translation: the stages run left to right — Provider, API Translation, CDKTF, Terraform, JSON.

The generation step is mechanical and worth watching once. cdktf get reads terraformProviders from cdktf.json, runs the provider binary to dump its schema as JSON, and hands that schema to the jsii code generator, which emits one Python module per resource and data source. A resource named aws_s3_bucket_versioning becomes the module s3_bucket_versioning and the class S3BucketVersioningA — the trailing A is a jsii disambiguation suffix applied when a generated name would collide with another symbol, and it catches everyone the first time. Nested HCL blocks become dataclass-like configuration classes: a versioning_configuration block becomes S3BucketVersioningVersioningConfiguration, constructed and passed by keyword.

There are two ways to get those bindings, and the choice has real consequences. Generating them locally with cdktf get gives you exactly the provider version your constraint resolves to, at the cost of a slow, network-dependent build step and a large .gen/ directory. Installing a pre-built package — pip install cdktf-cdktf-provider-aws — is fast and cacheable like any other dependency, but the package version pins the provider version, so upgrading the provider means upgrading the package. Mixed estates usually standardise on pre-built packages for the two or three major providers and generate bindings only for niche ones.

Multiple providers in one stack are configured by instantiating each provider class and passing alias where a second configuration of the same provider is needed; resources then take a provider= argument referencing the aliased instance. That mechanism, including the cross-account pattern where one alias assumes a role in another account, is covered in using multiple Terraform providers in one CDKTF stack. Teams arriving with existing HCL should start instead at converting existing Terraform HCL to CDKTF Python.

# main.py: multi-provider initialization and resource instantiation
# CLI: cdktf get && cdktf synth
from constructs import Construct
from cdktf import TerraformStack, App
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
from cdktf_cdktf_provider_google.provider import GoogleProvider

class CloudFoundationStack(TerraformStack):
    def __init__(self, scope: Construct, namespace: str) -> None:
        super().__init__(scope, namespace)

        # Provider note: credentials come from AWS_* / GOOGLE_* environment variables,
        # so no secret ever reaches cdk.tf.json.
        AwsProvider(self, "aws", region="us-east-1")
        GoogleProvider(self, "gcp", project="prod-analytics")

        # Resource instantiation with explicit naming conventions
        S3Bucket(self, "data_lake", bucket="prod-analytics-lake")

app = App()
CloudFoundationStack(app, "foundation")
app.synth()

Pythonic Abstraction Patterns

Infrastructure code must adhere to the same engineering standards as application logic. Wrapping low-level primitives into high-level abstractions eliminates duplication and enforces architectural guardrails across distributed teams. Mastering Python constructs and modules enables developers to build scalable, testable infrastructure libraries that integrate seamlessly with existing Python CI tooling. Type hints and dependency injection guarantee predictable resource graphs before deployment.

Pythonic Abstraction Patterns Pythonic Abstraction Patterns: Pythonic Abstraction P with 4 facets. Pythonic Abstraction P Definition typed Python Provider cloud API State recorded facts Outcome reproducible infra
Pythonic Abstraction Patterns: how Definition, Provider, State relate in this pattern.

The discipline that separates a useful construct from a leaky one is input validation at the boundary. A construct that accepts cidr: str and passes it through has gained nothing over calling Vpc directly. A construct that accepts a validated value object, rejects an overlapping range with a real ValueError, and refuses to emit a NAT gateway in an environment tier that forbids one has moved a policy decision from review time to synthesis time — and it can be tested in isolation with pytest.raises. That is the argument for constructs over HCL modules in one sentence: a module can only fail at plan; a construct can fail at import.

Naming is the second discipline, and it is more consequential than it looks because the logical ID CDKTF derives from the construct path becomes the Terraform address in state. Move a resource from the stack into a nested construct and the address changes, which means the planner sees a delete and a create rather than a rename. CDKTF exposes override_logical_id() to fix an address permanently, and add_move_target() / move_to() to emit a moved block so a refactor rewrites state in place instead of destroying anything. The full set of rules is in controlling CDKTF stack and construct naming, and packaging a stable construct library for other teams is covered in publishing CDKTF constructs as a Python package.

Prefer composition over inheritance when the shared behaviour is "these resources always appear together" and inheritance only when there is a genuine is-a relationship with a stable base contract. Deep construct hierarchies produce deep construct paths, deep paths produce long logical IDs, and long logical IDs get truncated and hashed — which makes state addresses unreadable exactly when you most need to read them.

# constructs/secure_vpc.py: typed VPC abstraction with conditional provisioning
# CLI: pytest tests/test_secure_vpc.py && cdktf synth
from typing import List
from constructs import Construct
from cdktf import TerraformOutput
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.internet_gateway import InternetGateway

class SecureVPC(Construct):
    def __init__(
        self,
        scope: Construct,
        id: str,
        cidr: str,
        azs: List[str],
        enable_nat: bool = True,
    ) -> None:
        super().__init__(scope, id)

        if not azs:
            raise ValueError("SecureVPC requires at least one availability zone")

        vpc = Vpc(self, "core_vpc", cidr_block=cidr, enable_dns_support=True)
        TerraformOutput(self, "vpc_id", value=vpc.id)

        if enable_nat:
            # State implication: the construct path 'core_vpc' fixes the Terraform
            # address, so renaming this construct would replace the VPC.
            igw = InternetGateway(self, "igw", vpc_id=vpc.id)

State Management & Remote Backends

State serves as the authoritative mapping between logical definitions and physical cloud resources. CDKTF requires explicit backend configuration to enable team collaboration and prevent concurrent write conflicts during parallel deployments. Proper state backend configuration for CDKTF guarantees consistent locking, auditability, and seamless integration with enterprise storage providers. Workspace routing isolates environment-specific state partitions to eliminate cross-environment drift.

State Management & Remote Backends State Management & Remote Backends: State Management & Rem with 4 facets. State Management & Rem State key element Remote key element State key element CDKTF key element
State Management & Remote Backends: how State, Remote, State relate in this pattern.

Modern CDKTF ships typed backend classes, and they are the right default: S3Backend, GcsBackend, AzurermBackend, and CloudBackend for Terraform Cloud or Enterprise. The add_override("terraform.backend", …) form shown below still works and is occasionally necessary for a backend CDKTF has no class for, but it bypasses type checking entirely — a typo in dynamodb_table produces a silently unlocked state rather than an error. Prefer the class; reach for the override only when there is no class. The S3-plus-locking setup end to end is in configuring an S3 backend with DynamoDB locking in CDKTF, and the managed alternative in using Terraform Cloud with CDKTF Python projects.

Cross-stack references deserve a specific warning. When one stack reads another stack's output within the same App, CDKTF does not pass the value in memory — it cannot, because the two stacks are applied separately. It synthesizes a TerraformOutput in the producing stack and a remote state data source in the consuming stack. That means cross-stack references require a remote backend, and it means the consumer reads whatever the producer last applied, not what the producer's current code says. A change that spans both stacks must be applied producer-first, and a CI pipeline that deploys stacks in parallel will read stale values. The engine-agnostic treatment of state as a shared, lockable, versioned artifact is in managing IaC state for Python projects.

# stacks/production.py: raw backend override for a backend with no typed class
# CLI: cdktf synth --stack production
from constructs import Construct
from cdktf import TerraformStack

class ProductionStack(TerraformStack):
    def __init__(self, scope: Construct, namespace: str) -> None:
        super().__init__(scope, namespace)

        # State implication: an override is injected verbatim into cdk.tf.json,
        # so a misspelled key disables locking without raising.
        self.add_override("terraform.backend", {
            "s3": {
                "bucket": "tf-state-prod",
                "key": "network/terraform.tfstate",
                "region": "us-east-1",
                "dynamodb_table": "tf-locks-prod",
                "encrypt": True,
            }
        })

Adopting Infrastructure That Already Exists

Adoption is the phase where CDKTF projects most often stall, and the reason is that it inverts the normal direction of authority. In greenfield work the code is right and the cloud converges on it. In adoption the cloud is right — it is serving traffic — and the code must converge on the cloud, attribute by attribute, until the planner has nothing to say.

Adopting one pre-existing resource into a CDKTF stack Adopting one pre-existing resource into a CDKTF stack: Inventory then Write construct then cdktf synth then import block then Zero-diff plan Inventory record real ID Write construct match live attrs cdktf synth read the address import block bind ID to address Zero-diff plan No changes
Adoption succeeds only when the synthesized address, the real resource ID, and the written attributes all agree.

The sequence in the figure is the whole method. Record the real resource identifier as the provider expects it, which is not always the obvious one: an S3 bucket imports by name, a security group by sg-…, an IAM role by name, and an RDS instance by its identifier rather than its ARN. Write the construct so the attributes match what is live. Run cdktf synth and read the address CDKTF generated out of cdk.tf.json — you do not choose that address, synthesis produces it, and getting it wrong is the most common import failure. Bind the identifier to the address, then plan. A plan that says No changes is the definition of done; anything else means the code and the object still disagree.

CDKTF supports both mechanisms Terraform offers. resource.import_from(id="acme-legacy-logs") emits a declarative import block into the synthesized JSON, which is reviewable in a pull request and applied like any other change. The imperative terraform import against the synthesized directory does the same job without a code artifact, which is faster for a one-off and worse for an audit trail. Prefer the declarative form for anything an approver needs to see. The detailed walkthroughs live in importing existing AWS resources into CDKTF Python and, when hand-writing the construct is impractical for hundreds of objects, generating CDKTF Python code from Terraform state.

Two adjacent problems come up constantly. Existing HCL modules do not need rewriting to be usable — TerraformHclModule lets a CDKTF stack call a registry or local module and map its outputs into Python, which is the pragmatic path described in adopting Terraform modules into a CDKTF Python stack. And after a large import, the plan is rarely clean on the first attempt; working through the residual diff systematically rather than by trial and error is covered in resolving drift after importing infrastructure into CDKTF.

# adopt.py: bind an existing bucket to the address synthesis produced
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/platform plan
from constructs import Construct
from cdktf import TerraformStack
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket

class AdoptionStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)

        legacy = S3Bucket(self, "legacy_logs", bucket="acme-legacy-logs")
        # State implication: emits an `import` block; the first apply writes a
        # state entry instead of creating a second bucket.
        legacy.import_from(id="acme-legacy-logs")

Automated Deployment Workflows

Infrastructure delivery requires deterministic, auditable pipelines that strictly separate compilation from execution. Pre-commit hooks enforce formatting, linting, and security scanning before code reaches the repository. Approval gates and automated drift detection provide critical testing boundaries to validate infrastructure plans prior to execution. The patterns for wiring synthesis, validation, and remote execution into a pipeline are covered in detail under CDKTF testing and CI/CD.

Automated Deployment Workflows Automated Deployment Workflows: cdktf synth then terraform plan then terraform apply cdktf synth terraform plan terraform apply
Automated Deployment Workflows: the stages run left to right — cdktf synth, terraform plan, terraform apply.

The separation that matters in CI is between the stage that produces a plan and the stage that consumes one. Producing a plan needs read credentials and the state lock; consuming it needs write credentials and a human decision in between. Collapsing them into a single cdktf deploy --auto-approve on merge is common and defensible for low-risk stacks, but it means the artifact reviewed on the pull request is the code, not the change set — and those differ whenever the cloud has drifted. Saving the plan to a file and applying that exact file is what makes the approval meaningful.

# CLI: CI pipeline sequence — synthesize, plan to a file, apply that exact file
cdktf get
cdktf synth
terraform -chdir=cdktf.out/stacks/platform-prod init
terraform -chdir=cdktf.out/stacks/platform-prod plan -out=tfplan -detailed-exitcode
terraform -chdir=cdktf.out/stacks/platform-prod show -json tfplan > plan.json
terraform -chdir=cdktf.out/stacks/platform-prod apply tfplan

-detailed-exitcode is the piece that makes this scriptable: 0 means no changes, 2 means changes are pending, and 1 means the plan itself failed. A pipeline can use that to skip the approval stage entirely when there is nothing to do, which keeps reviewers from rubber-stamping empty plans and learning to ignore them. The plan.json artifact feeds policy checks — security and compliance basics for Python IaC covers what to assert on it — and a scheduled run of the same commands with no apply stage is a serviceable drift detector.

Note: CDKTF's cdktf deploy wraps these Terraform commands automatically. The explicit Terraform CLI sequence above is useful when you need machine-readable plan output (-json) or detailed exit codes (-detailed-exitcode) for CI gating. The GitHub-specific wiring, including OIDC federation so no long-lived AWS keys sit in the repository, is in running CDKTF pipelines in GitHub Actions.

Common Failure Modes

Where CDKTF projects lose time Where CDKTF projects lose time: comparison across Surfaces at, Root cause. Failure Surfaces at Root cause Missing .gen bindings cdktf synth cdktf get not run in CI Token in an f-string terraform plan unresolved ${TfToken} literal Duplicate resource terraform apply renamed construct, no moved block Backend not initialised terraform validate init reached for remote state Provider drift cdktf get unpinned constraint in cdktf.json
The five failures that account for most wasted time on CDKTF projects, and the command that first reveals each.

ModuleNotFoundError: No module named 'cdktf_cdktf_provider_aws' — the generated bindings are absent. On a fresh clone or a CI runner, .gen/ does not exist until cdktf get runs, and pre-built packages are not installed until pip install runs. Fix by running cdktf get before cdktf synth and caching .gen/ keyed on a hash of cdktf.json; if you use the PyPI packages instead, pin them in the lockfile like any other dependency.

Error: There is already a Construct with name 'bucket' in PlatformStack [platform-prod] — two constructs share an ID within the same scope. This comes from the constructs library, not Terraform, and it is raised during synthesis. It almost always means a loop is passing a constant ID instead of one derived from the item, for example S3Bucket(self, "bucket", …) inside a for over regions. Derive the ID from a stable property of the item — never from the loop index, which shifts when the list is reordered and silently renames every resource after the insertion point.

A literal ${TfToken[TOKEN.7]} appears in plan output. The token was consumed by Python string handling instead of being passed through. Typical causes: an f-string that embeds an attribute in a longer string, a .upper() or .split() call on an attribute, or a dictionary key built from one. The fix is to move the operation into Terraform: use Fn.join, Fn.format, or Fn.upper so the transformation is emitted as a Terraform expression, or restructure so the value never needs transforming.

Error: Backend initialization required, please run "terraform init" — you invoked Terraform against cdktf.out without initialising that directory, which happens whenever CI re-creates the output directory after a cache restore. For a validate-only stage use terraform init -backend=false, which sets up providers without touching remote state or credentials. For a plan or apply stage, a full init is required.

Error: Resource already managed by Terraform — an import targeted an address that already has a state entry. Terraform will not overwrite an existing binding. Either the import ran twice, or the address you targeted belongs to a different resource than you intended. Inspect with terraform state list and terraform state show <address> before doing anything else; removing the wrong entry is how imports turn into outages.

Error: Cannot import non-existent remote object — the identifier does not resolve for that provider, in that region, under those credentials. The identifier format is provider-specific and rarely the ARN. Confirm the object exists from the CLI with the same credentials the pipeline uses before assuming the import syntax is wrong.

Error: Inconsistent dependency lock file.terraform.lock.hcl records provider selections that no longer satisfy the constraint in the synthesized configuration, usually because someone widened or narrowed the constraint in cdktf.json. Run terraform init -upgrade in the stack directory and commit the regenerated lock file, then confirm the plan is unchanged before merging — a provider upgrade can alter defaults.

Synthesis is slow and gets slower. Every construct instantiation is a round trip to the jsii Node process, so synthesis time scales with resource count and not with the complexity of your Python. A stack emitting thousands of near-identical resources is the usual cause. Split it into several stacks, or replace a wide loop with a single resource plus a provider-side for_each where the provider supports it.

Key Takeaways

CDKTF's core value is the ability to use Python's type system and testing ecosystem against infrastructure that ultimately runs through the mature Terraform provider ecosystem. The synthesis step (Python → Terraform JSON → Terraform plan → apply) is slower than Pulumi's direct execution model, but it preserves compatibility with existing Terraform state and providers. Teams maintaining existing CDKTF codebases should focus on the state backend configuration and testing patterns in this section to ensure operational reliability. Treat cdk.tf.json as the real deliverable, keep synthesis a pure function of committed files, and never let a token cross into Python string handling.

FAQ

Is CDKTF still worth adopting given its deprecation?

For new work, evaluate Pulumi or native Terraform first. CDKTF remains relevant for teams maintaining existing Python stacks who need state and provider compatibility. The artifact it produces is ordinary Terraform JSON, so an existing estate is not at risk — a deprecated generator is a maintenance concern, not an operational one.

How is CDKTF different from Pulumi?

CDKTF synthesizes Terraform JSON and runs through Terraform's engine, while Pulumi executes directly against provider plugins; see Pulumi vs CDKTF. The practical difference is that CDKTF debugging means reading generated JSON, while Pulumi debugging means attaching to a running Python process.

Can I reuse existing Terraform providers?

Yes — that is the point of provider bridging; CDKTF generates typed Python bindings from any Terraform provider's schema. Any provider that publishes a schema works, including internal and third-party ones, though only the popular providers have pre-built PyPI packages.

Do I need Node.js installed to run a Python CDKTF project?

Yes. The CDKTF CLI is distributed as a Node package, and the jsii kernel that your Python bindings call into is a Node child process. A container image for CI must contain both runtimes, and the Node version should be pinned alongside the Python version to keep synthesis reproducible.

Why does my plan show a destroy and create after I only renamed a class?

The Terraform address is derived from the construct path, so moving a resource between scopes changes its address and the planner reads that as a different resource. Emit a moved block with add_move_target() and move_to(), or fix the address permanently with override_logical_id(), and re-run the plan until it reports no destroys.

Can one CDKTF app manage several environments safely?

Yes, and it is the normal pattern: instantiate one TerraformStack per environment inside a single App, each with its own backend key. Because each stack is a separate state file with a separate lock, a staging deploy cannot block or corrupt production — but deploy them as separate pipeline stages rather than with cdktf deploy '*', so a failure in one does not leave the other half-applied.