Controlling CDKTF Stack and Construct Naming
The second argument you pass to a CDKTF construct looks like a throwaway label. It is not. It is one segment of the path that becomes the Terraform address of the resource, and Terraform tracks everything it owns by address. This guide, part of CDKTF architecture and synthesis inside CDKTF workflows and Terraform synthesis, explains how those addresses are derived, exactly when they change, and how to pin them so an ordinary Python refactor does not turn into a production replacement.
Context
Every CDKTF program builds a tree of constructs.Construct nodes. App is the root, each TerraformStack is a child of the app, and every provider, resource, data source, output and variable is a descendant of some stack. Each node carries a node.id — the string you passed as the second positional argument — and a node.path, which is every id from the root joined with /. Synthesis walks that tree once per stack and asks the owning stack to allocate a logical ID for each Terraform element beneath it.
The allocation rule is short enough to memorise, and worth memorising:
- Take the node's scopes below the owning stack. The stack's own id, and the app above it, are dropped.
- Discard any segment named
Default, which is the conventional id for a construct's implicit child. - If exactly one segment remains and it is short enough, that segment — with characters outside
A-Z a-z 0-9 _ -stripped — becomes the logical ID verbatim. No hash is appended. - Otherwise, sanitise every remaining segment, collapse consecutive duplicates, join them with
_, and append_plus the first eight hex characters of the MD5 of the segments joined with/, uppercased.
Two consequences fall straight out of that. A resource declared directly in a stack body gets a clean, readable address such as aws_s3_bucket.audit. The moment the identical resource is created inside a reusable construct, its address becomes something like aws_s3_bucket.ingress_logs_2F91A5C7, because the path now has two segments and the hash is what guarantees uniqueness when the same construct is instantiated twice in one stack. Neither form is better; what matters is that you know which one you have and that it stops moving.
Prerequisites
- Python 3.9+ with
cdktf>=0.20and either prebuilt provider bindings or a populated.gen/directory - Terraform 1.5 or newer on
PATH—importandmovedblocks are the escape hatches used below - A program that already synthesises:
cdktf synthmust writecdktf.out/stacks/<stack-id>/cdk.tf.json - Read access to the state backend, because every naming question is answered by comparing the addresses in state with the addresses in the synthesized JSON
jqfor reading the synthesized output; the address list is buried a few levels down
# CLI: confirm the toolchain the naming rules below assume
cdktf --version && terraform version
python -c "import cdktf; print('cdktf import ok')"
Implementation
1. Read the addresses CDKTF actually generated
Never reason about addresses from the Python alone — synthesize and read them. The following program mixes both shapes deliberately: one bucket declared straight in the stack, and one pair of resources wrapped in a small reusable construct.
# main.py — one stack, one nested construct, mixed address shapes
# CLI: cdktf synth
from constructs import Construct
from cdktf import App, TerraformStack, TerraformOutput
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,
)
class LogSink(Construct):
"""A bucket plus its versioning configuration, reusable per ingest path."""
def __init__(self, scope: Construct, id_: str, *, bucket_name: str) -> None:
super().__init__(scope, id_)
self.bucket: S3Bucket = S3Bucket(self, "logs", bucket=bucket_name)
# State implication: path is <stack>/<id_>/logs — two segments, so the
# logical ID gains the MD5 suffix that keeps two LogSinks apart.
S3BucketVersioningA(
self,
"versioning",
bucket=self.bucket.id,
versioning_configuration=S3BucketVersioningVersioningConfiguration(
status="Enabled"
),
)
class PlatformStack(TerraformStack):
def __init__(self, scope: Construct, id_: str, *, region: str) -> None:
super().__init__(scope, id_)
AwsProvider(self, "aws", region=region)
self.audit: S3Bucket = S3Bucket(self, "audit", bucket="acme-audit-eu")
# State implication: a single path segment — logical ID is "audit", no hash.
sink = LogSink(self, "ingress", bucket_name="acme-ingress-logs-eu")
TerraformOutput(self, "ingress_bucket_arn", value=sink.bucket.arn)
app = App()
PlatformStack(app, "platform", region="eu-west-1")
app.synth()
Synthesize, then print every address next to the construct path it came from. CDKTF records that path in a "//" metadata object on each resource, which makes the mapping mechanical rather than a guess.
# CLI: map every synthesized Terraform address back to its construct path
cdktf synth
jq -r '.resource | to_entries[] | .key as $type | .value | to_entries[]
| "\($type).\(.key)\t<- \(.value["//"].metadata.path)"' \
cdktf.out/stacks/platform/cdk.tf.json
The synthesized cdk.tf.json is plain Terraform JSON with CDKTF bookkeeping in comment keys. Trimmed to the parts that matter here, it looks like this:
{
"//": {
"metadata": { "backend": "local", "stackName": "platform", "version": "0.20.11" },
"outputs": { "platform": { "ingress_bucket_arn": "ingress_bucket_arn" } }
},
"provider": { "aws": [ { "region": "eu-west-1" } ] },
"resource": {
"aws_s3_bucket": {
"audit": {
"bucket": "acme-audit-eu",
"//": { "metadata": { "path": "platform/audit", "uniqueId": "audit" } }
},
"ingress_logs_2F91A5C7": {
"bucket": "acme-ingress-logs-eu",
"//": { "metadata": {
"path": "platform/ingress/logs", "uniqueId": "ingress_logs_2F91A5C7" } }
}
}
}
}
uniqueId is the logical ID; the key it sits under is the Terraform address suffix. terraform state list in cdktf.out/stacks/platform/ prints exactly those keys, which is why the two views must be compared rather than trusted separately.
2. Shape the construct tree so addresses survive refactors
Because the hash is a function of the whole path, any change above a resource propagates down to it. Renaming the LogSink instance from ingress to edge rewrites both nested addresses; wrapping two loose resources into a new construct rewrites them too. Four conventions keep that from happening by accident.
Name the role, not the implementation. ingress, audit, billing survive a provider swap. s3, bucket2, new_bucket invite a rename the first time the design shifts.
Do not encode environment, region or account in an id. Those belong to the stack, and the stack id is excluded from the logical ID anyway, so putting prod in a construct id buys nothing and costs a rename when the same code runs in staging.
Key loops on a stable business value, never on an index. for i, cidr in enumerate(cidrs): Subnet(self, f"subnet-{i}", ...) is a trap: inserting a CIDR at position zero shifts every subsequent address and Terraform plans a full replacement of the whole set. Key on the availability zone or the CIDR instead.
Decide the wrapping depth before the first apply. Extracting a construct is nearly free on day one and a state migration on day two hundred.
# networking.py — loop ids keyed on a stable value, not on position
# CLI: cdktf synth && jq '.resource.aws_subnet | keys' cdktf.out/stacks/platform/cdk.tf.json
from typing import Dict
from cdktf_cdktf_provider_aws.subnet import Subnet
SUBNETS: Dict[str, str] = {
"euw1a": "10.20.0.0/20",
"euw1b": "10.20.16.0/20",
"euw1c": "10.20.32.0/20",
}
for zone_key, cidr in SUBNETS.items():
Subnet(
self,
f"private-{zone_key}",
vpc_id=self.vpc.id,
cidr_block=cidr,
availability_zone=f"eu-west-1{zone_key[-1]}",
)
# State implication: the address is derived from zone_key, so adding a
# fourth zone leaves the first three addresses — and their state — untouched.
3. Pin an address with override_logical_id
Every TerraformElement exposes override_logical_id, which replaces the derived value with a literal string. Reach for it in exactly three situations: you are adopting resources created by hand-written HCL and must match the existing address; you need to restructure the construct tree without touching state; or a generated address is so long that downstream tooling truncates it.
# adopt.py — match an address that already exists in a hand-written HCL state
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/platform plan
bucket = S3Bucket(self, "audit", bucket="acme-audit-eu")
bucket.override_logical_id("legacy_audit_bucket")
# State implication: emits aws_s3_bucket.legacy_audit_bucket no matter where the
# construct sits in the tree, so an existing state entry is matched, not replaced.
The override is a blunt instrument. It bypasses the uniqueness guarantee entirely, so two constructs given the same override collide and Terraform rejects the plan. It is invisible to anyone reading the construct id, so leave the comment above it in place. And it must be a legal Terraform name: letters, digits, underscores and dashes, starting with a letter or underscore. Call reset_override_logical_id() if you later want the derived value back — but treat that as a rename with all the consequences below.
4. Move or adopt without destroying
Two mechanisms cover the cases where an address has to change but the resource must not.
For an address that already exists in state under a different name, register a move target on the resource and point the old address at it. CDKTF emits a Terraform moved block, and the plan reports a move rather than a replacement.
# move.py — rename a construct without replacing the resource
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/platform plan
sink = LogSink(self, "edge", bucket_name="acme-ingress-logs-eu")
sink.bucket.add_move_target("ingest-bucket")
# State implication: registers a friendly move name; the moved block below
# rewrites the state address in place instead of destroying the bucket.
sink.bucket.move_to("ingest-bucket")
For a resource that exists in the cloud but not in state at all, import_from emits a Terraform import block against the resource's current address:
# import.py — adopt an existing bucket into the stack
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/platform apply
bucket = S3Bucket(self, "audit", bucket="acme-audit-eu")
bucket.import_from("acme-audit-eu")
# Provider note: the argument is the provider's import ID (bucket name for S3,
# instance ID for EC2). Delete this call once the apply has recorded the resource.
Verification
Verification for naming is a plan that proposes nothing. Synthesize, initialise the generated directory, and read the plan summary — anything other than "No changes" after a pure refactor means an address moved.
# CLI: prove a refactor did not move any address
cdktf synth
terraform -chdir=cdktf.out/stacks/platform init -input=false
terraform -chdir=cdktf.out/stacks/platform plan -detailed-exitcode
# exit 0 = no changes, 2 = changes proposed, 1 = error
terraform -chdir=cdktf.out/stacks/platform state list | sort > /tmp/addresses.txt
Better still, assert on the addresses in a unit test so a rename fails in CI rather than in a plan review.
# tests/test_addresses.py
# CLI: pytest tests/test_addresses.py -q
import json
from typing import Dict, Any
from cdktf import Testing
from main import PlatformStack
def test_addresses_are_pinned() -> None:
app = Testing.app()
stack = PlatformStack(app, "platform", region="eu-west-1")
synthesized: Dict[str, Any] = json.loads(Testing.synth(stack))
buckets = synthesized["resource"]["aws_s3_bucket"]
assert set(buckets) == {"audit", "ingress_logs_2F91A5C7"}
# State implication: this set is the contract with the state file. Changing
# it is a migration, and the failing assertion is the reminder.
Gotchas & Edge Cases
A rename is a destroy and a create. Terraform has no concept of a construct; it sees aws_s3_bucket.ingress_logs_2F91A5C7 disappear and aws_s3_bucket.edge_logs_9C4E20B1 appear. For a bucket that means data loss, for an RDS instance a multi-minute outage. Always read the plan verbs, not the diff summary.
Renaming the stack does not move resources. The stack id is excluded from logical ID allocation, so resource addresses are unaffected — but the synthesized directory cdktf.out/stacks/<id> moves, and with it any local backend path or state key that was templated from the stack name.
Ids that start with a digit fail late. S3Bucket(self, "2fa-logs", ...) synthesizes to an address beginning with 2, and Terraform rejects it: Invalid resource name: A name must start with a letter or underscore and may contain only letters, digits, underscores, and dashes. The failure comes from terraform validate, not from Python.
Duplicate overrides collide silently in Python. Two calls to override_logical_id("audit") in one stack produce one JSON key; the second write wins and one resource vanishes from the plan. Nothing warns you at synth time.
Default disappears from the path. A construct whose single child is named Default contributes nothing to the human-readable part, which is why some generated addresses look shorter than the tree suggests.
Operational Notes
Treat the address list as an interface. Commit terraform state list output alongside the code, regenerate it in CI, and diff the two — a non-empty diff is a migration that needs a moved block or an explicit sign-off. The check costs one command and catches the class of change that a Python-only review will always miss, because the diff looks like a harmless variable rename.
# CLI: CI guard — fail the build when synthesized addresses drift
cdktf synth
jq -r '.resource | to_entries[] | .key as $t | .value | keys[] | "\($t).\(.)"' \
cdktf.out/stacks/platform/cdk.tf.json | sort > /tmp/synth-addresses.txt
diff -u addresses.lock /tmp/synth-addresses.txt || {
echo "Terraform addresses changed — add a moved block or update addresses.lock"; exit 1; }
When a move is genuinely required, sequence it: land the moved block on its own, apply it, confirm an empty plan, then delete the block in a follow-up change. Bundling the move with a configuration change makes the plan unreadable at exactly the moment you need to read it carefully.
FAQ
Does renaming a TerraformStack change my resource addresses?
No. Logical ID allocation starts below the stack, so the stack id never appears in a resource address. It does change the synthesized output directory and anything derived from the stack name, such as a state key built from it, which is where the real risk lives.
Why does one resource get a hash suffix and another does not?
Because a path with a single segment below the stack is used verbatim, while any deeper path is joined and hashed. Declaring a resource directly in the stack body gives a clean name; wrapping it in a construct gives parent_child_HASH.
Can I remove the hash suffix from an existing resource?
Yes, with override_logical_id, but only if you keep the value identical to what state already holds — otherwise you have renamed it. If you want a genuinely new, cleaner address, pair the override with a moved block so Terraform rewrites the state entry instead of replacing the resource.
How do I adopt resources that were created by hand-written HCL?
Match the existing addresses with override_logical_id, run a plan, and confirm it proposes no changes. Where the resource exists in the cloud but not in any state file, use import_from with the provider's import ID and remove the call after the first apply.
Where can I see the construct path for a synthesized resource?
In the "//" metadata object CDKTF writes into every resource in cdk.tf.json. It carries both path (the construct path) and uniqueId (the allocated logical ID), which is the fastest way to trace a surprising address back to the Python that produced it.
Related
- How to Pin Terraform Provider Versions in CDKTF — the other half of a reproducible synth: stable bindings to go with stable addresses.
- CDKTF Architecture & Synthesis — the parent topic covering the whole Python-to-JSON translation.
- State Backend Configuration for CDKTF — where the addresses discussed here are ultimately recorded.
- Managing IaC State — broader state migration technique that a rename eventually requires.