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.
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
terraformbinary onPATH—cdktf getshells 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)"
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.
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.
# 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.
Related
- Importing Existing Infrastructure into CDKTF — the parent topic covering adoption of objects that no state file yet manages.
- Resolve Drift After Importing Infrastructure into CDKTF — what to do when the first plan after adoption is not empty.
- Converting Existing Terraform HCL to CDKTF Python — the mechanical translation to reach for when porting beats wrapping.
- Pinning Terraform Provider Versions in CDKTF — the provider half of the version-constraint problem a wrapped module drags in.