Adopt Terraform Modules into a CDKTF Python Stack

Very little of a real estate is unmanaged. Somewhere in the repository there is an HCL module — a four-thousand-line registry VPC module, or an internal acme-eks module three teams depend on — that already works, already has its edge cases beaten out of it, and already owns live resources. Rewriting it in Python is unpaid work with a fresh set of bugs at the end. CDKTF does not require the rewrite: a Python stack can call an HCL module directly, pass it typed values, and read its outputs. This guide sits under importing existing infrastructure into CDKTF inside CDKTF workflows and Terraform synthesis, and it covers the two ways to make that call, how to pin the module so the plan stops moving underneath you, and what happens to state addresses when a resource crosses the module boundary.

Context

A module call is not a construct. When your Python instantiates a module, synthesis emits a module block into cdk.tf.json and stops there — it does not expand the module, does not know what resources are inside it, and cannot type-check the variables you passed. Terraform downloads the module at init time, and everything after that is ordinary Terraform behaviour. That boundary explains almost every surprise in this guide: your Python owns the call site, Terraform owns the contents.

Where a module call stops and Terraform takes over Where a module call stops and Terraform takes over: layered from Python stack down to Module internals. Python stack instantiates the module construct with variables cdk.tf.json module block source, version, and the variable values only terraform init downloads the module source into .terraform/modules Module internals resources CDKTF never sees and cannot type-check
Synthesis emits a call, not an expansion — the module's resources appear only at init.

Two calling mechanisms exist and they differ only in how much of the module's interface reaches your editor. TerraformHclModule is the escape hatch: a generic construct that takes a source string and a dictionary of variables, with no knowledge of what the module accepts. Generated bindings are the typed route — you declare the module in cdktf.json, run cdktf get, and CDKTF reads the module's variable and output declarations and writes a Python class with a real constructor signature and one property per output. Both emit the same module block. The difference is whether a misspelled variable name is caught by your linter or by terraform plan four minutes later.

Prerequisites

  • CDKTF 0.20 or newer and a working terraform binary on PATHcdktf get shells out to Terraform to fetch modules before it can inspect them.
  • Network access to wherever the module lives: the public registry, a private registry such as app.terraform.io/acme/vpc/aws, or a Git host reachable with the credentials your CI runner has.
  • The module's own documentation for its input variables and outputs, or a local checkout you can read. Generated bindings tell you the names and types but not the semantics.
  • A backend already configured for the stack, because moving resources into or out of a module writes state.
# CLI: confirm the toolchain can fetch and inspect modules before you write any Python
cdktf --version                      # 0.20+ for stable module code generation
terraform version                    # required by `cdktf get` to download module sources
jq -r '.terraformModules[]?.source' cdktf.json   # what is already declared, if anything

Implementation

Step 1 — Call the module untyped with TerraformHclModule

Start here when you are proving the module works at all, or when the module is small enough that generating bindings is more ceremony than it is worth. TerraformHclModule takes the source, an optional version, and a variables dictionary whose keys are the module's HCL variable names verbatim.

# network.py — call an existing registry module without generating bindings
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod-net init
from typing import Any, Dict, List

from constructs import Construct
from cdktf import TerraformHclModule, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider


class NetworkStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, region: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region=region)

        variables: Dict[str, Any] = {
            "name": "acme-prod",
            "cidr": "10.40.0.0/16",
            "azs": ["eu-west-1a", "eu-west-1b", "eu-west-1c"],
            "private_subnets": ["10.40.1.0/24", "10.40.2.0/24", "10.40.3.0/24"],
            "public_subnets": ["10.40.101.0/24", "10.40.102.0/24", "10.40.103.0/24"],
            "enable_nat_gateway": True,
            "single_nat_gateway": False,
            "tags": {"owner": "platform", "managed-by": "cdktf"},
        }
        self.vpc: TerraformHclModule = TerraformHclModule(
            self,
            "vpc",
            source="terraform-aws-modules/vpc/aws",
            version="5.8.1",
            variables=variables,
        )
        # State implication: every resource the module creates lands under the state
        # address prefix module.vpc.*, not under any address your Python controls.

        self.vpc_id: str = self.vpc.get_string("vpc_id")
        self.private_subnet_ids: List[str] = self.vpc.get_list("private_subnets")

get_string, get_list, get_number and get_boolean do not return values. They return interpolation tokens — the string ${module.vpc.vpc_id} and friends — which Terraform resolves during apply. You can pass those tokens into any other construct's arguments and the dependency edge is created for you, but you cannot branch on them in Python, print them meaningfully, or slice a list token by index expecting a real element. Anything that looks like control flow over a module output has to be expressed in Terraform terms instead.

# app.py — module outputs flow into constructs as tokens, not as values
# CLI: cdktf synth && jq '.resource.aws_security_group' cdktf.out/stacks/prod-net/cdk.tf.json
from cdktf_cdktf_provider_aws.security_group import SecurityGroup

api_sg: SecurityGroup = SecurityGroup(
    self,
    "api",
    name="acme-prod-api",
    vpc_id=self.vpc.get_string("vpc_id"),   # renders as ${module.vpc.vpc_id}
    description="API tier ingress",
)
# Provider note: Terraform infers the dependency from the interpolation, so the module
# is fully applied before this security group is created. Do not add depends_on by hand.

Step 2 — Promote the call to generated typed bindings

Declare the module once in cdktf.json and CDKTF will write a Python class for it. This is the form to standardise on for any module you will call more than once, because the constructor signature becomes the module's contract as your editor sees it.

{
  "language": "python",
  "app": "pipenv run python main.py",
  "codeMakerOutput": "imports",
  "projectId": "6c0f2a19-2f0d-4a3e-9a05-1f2f0f2c7bd1",
  "terraformProviders": ["aws@~> 5.60"],
  "terraformModules": [
    {
      "name": "vpc",
      "source": "terraform-aws-modules/vpc/aws",
      "version": "5.8.1"
    },
    {
      "name": "eks_platform",
      "source": "app.terraform.io/acme/eks-platform/aws",
      "version": "2.3.0"
    }
  ]
}
# CLI: fetch each declared module and generate Python bindings under imports/
cdktf get
ls imports/vpc/__init__.py imports/eks_platform/__init__.py
python3 -c "from imports.vpc import Vpc; print(Vpc)"
From cdktf.json to a typed module class From cdktf.json to a typed module class: terraformModules entry then cdktf get then imports/vpc then Vpc(...) call terraformModulesentry name plus pinned source cdktf get fetches and inspects imports/vpc generated Python class Vpc(...) call keyword args, _output props
Generated bindings turn the module's variables and outputs into a Python signature.

The generated class takes the module's variables as keyword arguments and exposes one property per declared output, suffixed with _output. A variable the module does not declare is now a TypeError raised in your own process rather than an Unsupported argument error from Terraform, and the outputs are discoverable by autocomplete instead of by reading the module's source.

# network_typed.py — the same module call, with the module's interface in the type system
# CLI: cdktf get && cdktf synth
from typing import List

from constructs import Construct
from cdktf import TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider

from imports.vpc import Vpc


class NetworkStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str, region: str, cidr: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region=region)

        self.vpc: Vpc = Vpc(
            self,
            "vpc",
            name="acme-prod",
            cidr=cidr,
            azs=["eu-west-1a", "eu-west-1b", "eu-west-1c"],
            private_subnets=["10.40.1.0/24", "10.40.2.0/24", "10.40.3.0/24"],
            public_subnets=["10.40.101.0/24", "10.40.102.0/24", "10.40.103.0/24"],
            enable_nat_gateway=True,
            single_nat_gateway=False,
            tags={"owner": "platform", "managed-by": "cdktf"},
        )
        # Provider note: the construct ID "vpc" and the cdktf.json name "vpc" are
        # independent — the ID decides the module block key, the name decides the import path.

        self.vpc_id: str = self.vpc.vpc_id_output
        self.private_subnet_ids: List[str] = self.vpc.private_subnets_output

Generated code under imports/ is build output derived from a pinned source, so treat it the way you treat any other generated artifact: either commit it and regenerate deliberately in a reviewable change, or exclude it and run cdktf get in CI before synth. Committing it is the better default here, because it makes a module upgrade visible as a diff in the pull request — a removed constructor argument shows up as a removed keyword rather than as a plan failure on a Friday.

Step 3 — Pin the version, and prove the pin holds

The provider lock file does not cover modules. .terraform.lock.hcl records provider hashes only; module versions are resolved at terraform init and recorded in .terraform/modules/modules.json, a file nobody commits and nothing enforces. A source declared with ~> 5.8 therefore means "whatever the newest 5.x was on the runner that last initialised", which is a different answer on your laptop than in CI, and a different answer again next week.

# CLI: read what init actually resolved, not what you asked for
terraform -chdir=cdktf.out/stacks/prod-net init -upgrade
jq -r '.Modules[] | select(.Key != "") | "\(.Key)\t\(.Source)\t\(.Version // "unpinned")"' \
  cdktf.out/stacks/prod-net/.terraform/modules/modules.json

The failure this produces is quiet. A floating constraint picks up a new minor release, the module's authors changed a default — a NAT gateway strategy, a default tag, an added flow-log resource — and the very next plan proposes changes nobody in your repository wrote. Because the diff originates inside the module, git log on your own code explains nothing. Pin exact versions in cdktf.json, and put a check in front of synth so a re-resolved version cannot slip past review.

# tests/test_module_pins.py — refuse to synth against a floating module version
# CLI: pytest tests/test_module_pins.py -q
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Dict, List, Pattern

EXACT: Pattern[str] = re.compile(r"^\d+\.\d+\.\d+$")


def declared_modules(cdktf_json: Path) -> List[Dict[str, str]]:
    doc = json.loads(cdktf_json.read_text())
    return list(doc.get("terraformModules", []))


def test_every_module_is_pinned_to_an_exact_version() -> None:
    floating = [
        m["name"] for m in declared_modules(Path("cdktf.json"))
        if not EXACT.match(str(m.get("version", "")))
    ]
    # State implication: a floating version can change the resources inside module.*
    # without any diff in this repository, so the pin is the only change control there is.
    assert not floating, f"modules without an exact version pin: {floating}"

Local module sources are the exception, and getting it wrong produces a specific stop: a version argument alongside a source such as ./modules/network fails at init with Error: Invalid version constraint — A version constraint cannot be applied to a module at a local path. Local modules are pinned by your own repository's history, so drop the version key for those entries entirely.

Step 4 — Decide per module whether to wrap or to port

This is a judgement made module by module, not once for the whole migration. Wrapping keeps a proven implementation and costs you type safety at the boundary; porting gives you Python all the way down and costs you a rewrite plus the bugs the module's maintainers already fixed.

Wrap the module or port it to Python Wrap the module or port it to Python: choose among 3 options. An existing HCL module youmust keep working large Wrap it: heavilytested, orgstandard, resources thin Port it: fewresources, you needto extend beyond its shared Wrap it: otherteams' stacks callthe same module
Judged per module — the answer changes with size, ownership, and how far you must extend it.

Wrapping wins when the module is large and battle-tested, when it is the organisation's standard and other teams' stacks must stay compatible with it, when its resources already exist in production and moving them is the expensive part, or when it encodes compliance decisions somebody signed off on. The registry VPC module is the archetype: hundreds of conditional resources, years of accumulated edge cases, and nothing you would gain by expressing the same conditionals in Python.

Porting wins when the module is thin — a handful of resources and a locals block — when you need to extend it in ways its variables do not expose, when its interface has degenerated into a long list of booleans that a Python class would express as composition, or when you want the resources it creates to participate in your own reusable CDKTF constructs. A thin module wrapped is a dependency you cannot debug; the same thing ported is fifty lines you own. cdktf convert will translate the HCL mechanically as a starting point, as covered in converting existing Terraform HCL to CDKTF Python.

The decision is cheap only while the resources are hypothetical. Wrapping a module whose resources you currently declare as constructs means moving state entries into module.*; porting means moving them out. Both are the subject of the next step, and both are the reason to make this call before the objects are live rather than after.

Step 5 — Move resources across the module boundary deliberately

A resource inside a module is addressed module.<call_name>.<type>.<name>, with an index suffix when the module's author used count or for_each — which is why the registry VPC module's own VPC lives at module.vpc.aws_vpc.this[0] and not at module.vpc.aws_vpc.this. Terraform sees the old and the new address as unrelated objects, so an unannounced move plans as a destroy plus a create.

What changes when a resource crosses the module boundary What changes when a resource crosses the module boundary: module.vpc.aws_vpc.this[0] with 4 facets. module.vpc.aws_vpc.this[0] Address prefix every entry gains module.vpc ahead of the type Count index the module's own count adds a [0] you must type Unannounced move reads as destroy plus create, not a rename Replayable move a moved block survives a fresh clone; state mv does not
The address is the thing that moves; the cloud object never does.
# CLI: inspect the addresses before deciding how to move them
terraform -chdir=cdktf.out/stacks/prod-net state list | grep '^module\.vpc\.'
terraform -chdir=cdktf.out/stacks/prod-net state show 'module.vpc.aws_vpc.this[0]'

For a one-off correction, state mv is the direct instrument, and it is the right tool when you are folding a hand-declared resource into a module you have just adopted.

# CLI: fold an existing hand-declared VPC into the module's address space
terraform -chdir=cdktf.out/stacks/prod-net state mv \
  'aws_vpc.main' 'module.vpc.aws_vpc.this[0]'
# State implication: the object is untouched in the cloud; only the address changes.
# Back the state up first — state mv has no dry run and no undo.

For a move that has to reproduce on a colleague's machine and in CI, emit a moved block instead. CDKTF's move_to() operates on constructs and cannot see inside a module, so express module-boundary moves by overriding the synthesized document at the stack level.

# moves.py — a replayable module-boundary move recorded in the source
# CLI: cdktf synth && jq '.moved' cdktf.out/stacks/prod-net/cdk.tf.json
from typing import Dict, List

from cdktf import TerraformStack


def record_module_moves(stack: TerraformStack) -> None:
    moves: List[Dict[str, str]] = [
        {"from": "aws_vpc.main", "to": "module.vpc.aws_vpc.this[0]"},
        {"from": "aws_internet_gateway.main", "to": "module.vpc.aws_internet_gateway.this[0]"},
    ]
    stack.add_override("moved", moves)
    # State implication: the moves apply once and are then no-ops, but leaving them in
    # place is what makes a fresh clone converge on the same state layout.

Verification

The acceptance test is the same as for any adoption: a plan that proposes nothing. What is worth checking separately is that the module block synthesized with the version you meant, because that is the input the whole plan depends on.

# CLI: three checks — the pin, the addresses, and an empty plan
jq -r '.module | to_entries[] | "\(.key)\t\(.value.source)\t\(.value.version)"' \
  cdktf.out/stacks/prod-net/cdk.tf.json
terraform -chdir=cdktf.out/stacks/prod-net state list | grep -c '^module\.vpc\.'
terraform -chdir=cdktf.out/stacks/prod-net plan -detailed-exitcode
# exit 0 = no changes, 2 = changes pending, 1 = error

A synth-time assertion keeps the pin honest without needing cloud credentials, which makes it cheap enough to run on every pull request alongside the rest of the checks described in validating synthesized Terraform with CDKTF.

# tests/test_module_block.py — assert the synthesized module call, not the live plan
# CLI: pytest tests/test_module_block.py -q
import json

from cdktf import Testing

from main import NetworkStack


def test_vpc_module_is_called_at_the_pinned_version() -> None:
    app = Testing.app()
    stack = NetworkStack(app, "prod-net", region="eu-west-1", cidr="10.40.0.0/16")
    doc = json.loads(Testing.synth(stack))
    block = doc["module"]["vpc"]
    assert block["source"] == "terraform-aws-modules/vpc/aws"
    assert block["version"] == "5.8.1"
    assert block["single_nat_gateway"] is False

Finally, run terraform -chdir=… init -upgrade deliberately and read the resolved versions again. If modules.json reports a version your cdktf.json did not name, something else in the dependency graph — usually a module that itself calls another module — is doing the resolving, and that nested constraint is now part of your surface area.

Gotchas & Edge Cases

Module outputs are opaque at synth time. if module.get_string("vpc_id").startswith("vpc-") is always false, because the value is the literal string ${module.vpc.vpc_id}. Any conditional that depends on a module output has to become a Terraform-side expression or a data source lookup; there is no way to pull the value back into Python during synthesis.

Variable names are HCL names. Generated bindings convert enable_nat_gateway into a Python keyword of the same shape, but TerraformHclModule's dictionary keys go through untranslated. A typo there produces Error: Unsupported argument — An argument named "enable_nat_gatway" is not expected here. at plan time, pointing at a line in cdk.tf.json rather than at your Python.

Provider aliases must be passed explicitly. A module that declares configuration_aliases will not inherit your aliased providers. Pass them with the providers argument, mapping the module's internal alias to your provider instance, or init fails with Error: Provider configuration not present.

# multiregion.py — hand an aliased provider to a module that asks for one
# CLI: cdktf synth && jq '.module.cdn.providers' cdktf.out/stacks/prod-net/cdk.tf.json
from cdktf import TerraformHclModule, TerraformModuleProvider
from cdktf_cdktf_provider_aws.provider import AwsProvider

us_east: AwsProvider = AwsProvider(self, "aws-use1", region="us-east-1", alias="use1")

cdn: TerraformHclModule = TerraformHclModule(
    self,
    "cdn",
    source="app.terraform.io/acme/cdn/aws",
    version="1.4.2",
    variables={"domain": "acme.example"},
    providers=[TerraformModuleProvider(module_alias="acm", provider=us_east)],
)
# Provider note: certificates fronting a global CDN must be issued in us-east-1, which
# is exactly the situation configuration_aliases exists to express.

Nested provider constraints can conflict with yours. A module carrying a required_providers entry of aws = "~> 4.0" against a stack pinned to aws@~> 5.60 fails at init with Error: Failed to query available provider packages … no available releases match the given constraints. There is no override for this. Either the module gets upgraded or your stack does — see pinning Terraform provider versions in CDKTF for how to reason about the constraint set.

cdktf get is a network operation. It fails in an air-gapped runner, behind a proxy that blocks the registry, and against a Git source when the runner has no key. Generate bindings on a machine that can reach the source and commit the result, or mirror the module into a registry the runner can reach.

Count and for_each on the module call are overrides. CDKTF's module constructs do not expose count or for_each as constructor arguments. Reach them with add_override("for_each", …) on the module, and remember that doing so changes every address inside it from module.vpc.* to module.vpc["a"].* — a state move for everything the module owns.

Operational Notes

Treat each wrapped module as a dependency with an owner and an upgrade cadence, exactly as you would a Python package. Record who maintains it, where its release notes live, and how far behind you are. The reason to write this down is that a module upgrade is not a code change in your repository — it is a change to code you cannot see, whose blast radius is every resource under module.<name>.*.

Upgrade one module per pull request, and attach the plan output as an artifact. A version bump from 5.8.1 to 5.9.0 that produces an empty plan is a two-minute review; the same bump producing eleven in-place updates and one replacement needs a human reading each line. Batching three module upgrades together destroys the only signal you have about which one caused the replacement.

Mirror external modules into a private registry if you have one. A public registry outage stops cdktf get, and a module deleted or yanked upstream stops it permanently. Mirroring also gives you somewhere to hold an internal fork while an upstream fix is in review, without rewriting every call site's source string more than once.

Keep a single call site per module per stack where you can. Two calls to the same module with different variables are two independent address spaces, and the second one is usually a sign that a construct wrapping both would express the intent better. Where you do need several, name the calls after their purpose — vpc_prod, vpc_shared — because the call name appears in every state address for the rest of that module's life.

Finally, write down which modules are wrapped and which are on the porting list, with the reason for each. That list is the first thing a new engineer asks for, and without it the answer to "why is this one in Python and that one in HCL?" is lost within a quarter.

FAQ

Can I call a module straight from a Git repository?

Yes. Use the standard Terraform source syntax — git::ssh://[email protected]/acme/vpc.git?ref=v2.1.0 — and pin with the ref parameter rather than the version key, which only applies to registry sources. The runner needs whatever credentials that transport requires, and cdktf get fails before generating anything if it cannot clone.

Do I have to run cdktf get again after changing a module version?

Yes, and it is easy to forget. Changing the version in cdktf.json without regenerating leaves stale Python bindings that may reference variables the new version removed. Wire cdktf get into the same command that runs synth, or add a CI check comparing the version in cdktf.json against the one recorded in the generated package.

Can a module variable take a value from a construct in my stack?

Yes — pass the construct's attribute directly, such as kms_key_arn=key.arn. It renders as an interpolation and Terraform builds the dependency edge in the right direction. The reverse works too: a module output feeds a construct argument, as in step one.

Is TerraformHclModule worse than generated bindings at apply time?

No. Both synthesize to an identical module block, so the plan and the apply are byte-for-byte the same. The entire difference is developer-facing: argument checking, autocomplete, and a visible diff when the module's interface changes.

How do I adopt resources a module already manages in another state file?

That is a state migration, not an import. Move the entries between state files rather than importing them fresh, so the objects are never unmanaged in between — the mechanics are covered in migrating IaC state between backends.

Why does my module's resource address have [0] in it?

Because the module's author put count on that resource, typically something of the form count = var.create_vpc ? 1 : 0. The index is part of the address permanently, and any moved block or state mv you write must include it or Terraform reports Error: Invalid target address.