Publish CDKTF Constructs as a Python Package
Once a reusable CDKTF construct proves itself, other teams want it. Packaging it as a normal Python distribution — part of Python constructs and modules under CDKTF workflows and Terraform synthesis — lets them pip install your infrastructure building blocks and pin versions like any other dependency.
Nothing about this is CDKTF-specific machinery. A construct is a plain Python class; a construct package is a plain wheel. What makes it different from an application library is the blast radius: a bad release does not throw a stack trace in one service, it changes the synthesized Terraform of every stack that upgrades, and a synthesized change is one terraform apply away from being a real change to real infrastructure. That is the reason the packaging discipline below is stricter than it would be for a utility library — release hygiene here is a production safety control, not tidiness.
Context
Copy-pasting a construct between repositories forks it immediately: a fix in one place never reaches the others. Publishing it as a versioned package gives every consumer the same tested code, a changelog, and a clear upgrade path — the same discipline you already apply to application libraries, now applied to infrastructure.
The forked-copy failure mode is quiet. Team A hardens the construct's S3 bucket with block_public_acls=True; team B, working from a copy taken four months earlier, still ships buckets without it. Nothing errors, no plan turns red, and the divergence only surfaces during an audit. A published package converts that silent divergence into an explicit, greppable fact: every consumer's lockfile names a version, and pip index versions acme-cdktf-network tells you in one command who is behind.
There is a second, subtler reason to package rather than vendor. CDKTF constructs sit on top of jsii-generated provider bindings, and those bindings are large — cdktf-cdktf-provider-aws alone is tens of megabytes of generated Python. When each repository vendors its own copy of a construct, each also carries its own opinion about which binding version to install. Centralising the construct centralises that opinion into one dependency declaration you can reason about.
Prerequisites
- Python 3.9+ with
buildandtwine, plus Poetry or pip-tools for dependency management - A construct that takes typed inputs and exposes typed outputs, not one hard-coded to one account
- Access to a package index (PyPI or a private index such as CodeArtifact)
- A pinned provider binding, chosen the way pinning Terraform provider versions in CDKTF describes — the pin you publish becomes every consumer's floor
- A CI runner that can hold a publish credential; the local-laptop upload works exactly once before it becomes a bus-factor problem
# CLI: verify build tooling is present
python -m build --version && twine --version
One thing you do not need is cloud credentials. Building, testing, and publishing a construct package is entirely offline work: cdktf synth resolves the construct graph in memory and writes JSON, so the release pipeline never touches an AWS account. Keep it that way — a release job that needs sts:AssumeRole is a release job someone will eventually point at production by accident.
Implementation
1. Choose the index before you choose the layout
The index decides your credential model, the URL consumers put in pip.conf, and — critically — whether a bad release can be withdrawn. PyPI supports yanking a version (it stays resolvable for existing pins but is skipped by new resolutions); most private indexes support outright deletion, which is worse, because deleting a version a stack has pinned turns a rebuild into a hard failure.
For an internal construct on AWS, CodeArtifact is the low-friction answer because it mints short-lived tokens from IAM instead of long-lived passwords:
# CLI: authenticate twine and pip against a CodeArtifact repository (token lives 12h)
aws codeartifact login --tool twine --domain acme --repository infra --region eu-west-1
aws codeartifact login --tool pip --domain acme --repository infra --region eu-west-1
# Provider note: the repo should have pypi-store as an upstream so cdktf and the
# provider bindings resolve through the same index as your own package.
2. Structure the package with a src/ layout and real metadata
Keep the construct import-light: it should depend on cdktf and the specific provider bindings it needs, and nothing about a particular environment. Declare those as install requirements so consumers resolve them transitively.
A src/ layout is not a style preference here. With a flat layout, import acme_cdktf_network succeeds from the repository root whether or not the package was actually installed, so your tests pass against the working tree and the wheel ships broken. Putting the code under src/ makes the import fail unless the wheel really contains the module, which is exactly the signal you want before publishing.
# pyproject.toml (excerpt) — declare the construct package
# CLI: python -m build (produces dist/*.whl)
[project]
name = "acme-cdktf-network"
version = "1.3.0"
requires-python = ">=3.9"
dependencies = [
"cdktf>=0.20,<0.21",
"cdktf-cdktf-provider-aws>=19,<20",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
With metadata in place, build a wheel and a source distribution. Provider note: pin the provider binding range so a consumer cannot accidentally pull an incompatible generated API.
Two additions turn that minimum into something an unfamiliar consumer can use. First, tell hatchling explicitly where the package lives, or it guesses from the project name and fails on any name/module mismatch. Second, ship py.typed — without it, mypy in a consuming repository silently treats every attribute of your construct as Any, which throws away the entire reason you wrote a typed props dataclass.
# pyproject.toml (continued) — wheel contents and typing marker
# CLI: python -m build && unzip -l dist/acme_cdktf_network-1.3.0-py3-none-any.whl
[tool.hatch.build.targets.wheel]
packages = ["src/acme_cdktf_network"]
[tool.hatch.build.targets.sdist]
include = ["src/", "tests/", "CHANGELOG.md"]
# Provider note: an sdist that omits tests/ still installs, but consumers can no
# longer reproduce your snapshot run against their own provider binding version.
3. Fix the public API surface before the first release
Whatever is importable from the top-level package is what consumers will import, and every name you leak becomes something you cannot rename without a major version. Declare the surface once, in __init__.py, and keep the internals in private modules.
# src/acme_cdktf_network/__init__.py — the entire supported API
# CLI: python -c "import acme_cdktf_network as m; print(m.__all__, m.__version__)"
from importlib.metadata import PackageNotFoundError, version
from acme_cdktf_network.network import Network
from acme_cdktf_network.props import NetworkProps
__all__: list[str] = ["Network", "NetworkProps"]
try:
__version__: str = version("acme-cdktf-network")
except PackageNotFoundError: # running from a source checkout, not installed
__version__ = "0.0.0.dev0"
# State implication: expose the version at runtime and tag it onto every resource
# the construct creates — then a drifted resource in the console names the exact
# construct release that produced it.
Threading __version__ into the construct's default tags costs one line and pays for itself the first time someone asks which release created a mislabelled subnet:
# src/acme_cdktf_network/network.py (excerpt) — stamp the release onto resources
# CLI: cdktf synth && jq '.resource.aws_vpc' cdktf.out/stacks/platform/cdk.tf.json
from constructs import Construct
from cdktf_cdktf_provider_aws.vpc import Vpc
from acme_cdktf_network.props import NetworkProps
class Network(Construct):
"""VPC plus one subnet per availability zone, with provenance tags."""
def __init__(self, scope: Construct, id: str, *, props: NetworkProps) -> None:
super().__init__(scope, id)
from acme_cdktf_network import __version__
merged_tags: dict[str, str] = {
**props.tags,
"acme:construct": "acme-cdktf-network",
"acme:construct-version": __version__,
}
self._vpc = Vpc(
self,
"vpc",
cidr_block=props.cidr_block,
enable_dns_hostnames=True,
tags=merged_tags,
)
# State implication: changing a tag value rewrites aws_vpc.tags in state on
# the next apply — an in-place update, not a replacement, but it does mean
# every consumer sees a non-empty plan the first time they upgrade.
4. Build, check, tag, and upload
Run the build from a clean tree so the sdist does not silently absorb local scratch files, then let twine check validate the metadata before the index rejects it.
# CLI: full release sequence from a clean checkout
rm -rf dist/ build/
python -m build # writes dist/*.whl and dist/*.tar.gz
python -m twine check dist/* # expect: PASSED for both artifacts
git tag -a v1.3.0 -m "network construct 1.3.0" && git push origin v1.3.0
python -m twine upload dist/*
The tag must go up before the upload, not after. An index version is immutable — retrying an upload of the same version returns HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/ File already exists. — so if the tag push fails after a successful upload you are left with a published artifact that corresponds to no commit anyone can find.
Verification
Install the built wheel into a throwaway environment and synthesize a stack that uses it — the surest proof the package is self-contained.
# CLI: install the wheel and synth a consuming stack
pip install dist/acme_cdktf_network-1.3.0-py3-none-any.whl
cdktf synth # expect: cdk.tf.json produced with the construct's resources
Do this in a directory that is not the construct repository — cd $(mktemp -d) and build a two-file consuming stack there. Running cdktf synth from inside the source tree proves nothing, because the working directory is on sys.path and the import resolves to the checkout rather than the installed wheel.
The stronger check is automated: assert on the synthesized JSON rather than on the fact that synthesis exited zero. Testing.synth() returns the stack's Terraform document as a string, so a consumer-side test can confirm both that the construct is importable from the installed distribution and that it emits what the changelog claims.
# tests/test_installed_package.py — runs against the wheel, not the checkout
# CLI: pytest tests/test_installed_package.py -q
import json
from typing import Any
from cdktf import Testing, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from acme_cdktf_network import Network, NetworkProps, __version__
def test_installed_construct_emits_tagged_vpc() -> None:
app = Testing.app()
stack = TerraformStack(app, "consumer")
AwsProvider(stack, "aws", region="eu-west-1")
Network(
stack,
"net",
props=NetworkProps(cidr_block="10.20.0.0/16", availability_zones=["eu-west-1a"]),
)
document: dict[str, Any] = json.loads(Testing.synth(stack))
vpc = next(iter(document["resource"]["aws_vpc"].values()))
assert vpc["cidr_block"] == "10.20.0.0/16"
assert vpc["tags"]["acme:construct-version"] == __version__
# State implication: this asserts on synthesized config only — no state file is
# read or written, so the test is safe to run on every pull request.
Finally, confirm the wheel's contents match your intent. unzip -l dist/*.whl should list acme_cdktf_network/ and py.typed, and should not list tests/, .env, or a stray cdktf.out/. A wheel that ships cdktf.out/ is a wheel that ships someone's account IDs.
Gotchas & Edge Cases
ModuleNotFoundError after install — the package used a flat layout and shipped tests but not the module; adopt a src/ layout and confirm the wheel contains your package with unzip -l dist/*.whl. The exact message is ModuleNotFoundError: No module named 'acme_cdktf_network' even though pip list shows the distribution installed, because pip installed a wheel whose only payload was the .dist-info directory. If hatchling cannot infer the package it usually says so at build time — ValueError: Unable to determine which files to ship inside the wheel using the following heuristics — which is your cue to set [tool.hatch.build.targets.wheel] packages.
jsii version conflicts — two construct packages pin incompatible cdktf ranges; align them or the consumer's resolver will fail. This is why narrow, explicit ranges matter. Pip reports it as ERROR: Cannot install acme-cdktf-network and acme-cdktf-data because these package versions have conflicting dependencies. followed by ResolutionImpossible. The failure mode you actually fear is the one that resolves: if a consumer force-installs a cdktf runtime older than the provider binding was generated against, the conflict surfaces at import time as a jsii error rather than a pip error, and the traceback points at the generated binding rather than at your construct.
Snapshot drift on upgrade — a provider-binding bump changes synthesized output; regenerate and review snapshot tests as part of the release. Provider generators add new optional attributes between minor versions, and CDKTF emits them as explicit nulls, so a diff of hundreds of lines can represent zero semantic change. Read the diff for keys that gained a value, not keys that gained a null.
Name normalisation surprises — the distribution acme-cdktf-network installs the module acme_cdktf_network and produces the wheel file acme_cdktf_network-1.3.0-py3-none-any.whl. Hyphen, underscore, and case are interchangeable to pip but not to the import statement, and CI scripts that glob for acme-cdktf-network-*.whl will match nothing.
A construct that reads the environment — a construct calling os.environ["AWS_ACCOUNT_ID"] at import time works perfectly in the repository that wrote it and raises KeyError: 'AWS_ACCOUNT_ID' in every other one. Account-shaped values belong in the props dataclass, passed by the consuming stack.
Publishing the provider bindings by accident — vendoring cdktf.out/ or a generated imports/ directory into the sdist can multiply its size by fifty and pin consumers to your generated copy. Keep generated bindings out of version control and let cdktf get regenerate them.
Operational Notes
The decision that generates the most long-run work is how tightly the package pins cdktf and the provider bindings. Pin exactly and you become the bottleneck for every upgrade; leave the ceiling open and a major provider release breaks synthesis in stacks you have never seen.
The workable middle is a bounded range in the package plus an exact pin in each consuming application's lockfile. The package says "any 0.20.x", the consumer's poetry.lock or requirements.txt says cdktf==0.20.7, and upgrades happen deliberately in the consumer's repository where a plan can be reviewed.
Semantic versioning for a construct package needs an infrastructure-flavoured definition, because "breaking" is not about compile errors. Treat any change that produces a non-empty terraform plan for an unchanged consumer as at least a minor release, and any change that produces a replacement — a new logical ID, a renamed child construct, a modified for_each key — as a major one. Renaming a child construct from subnet-0 to public-subnet-0 compiles fine, type-checks fine, and destroys a subnet.
Automate the release so it cannot be done from a laptop. A tag-triggered job in a CDKTF pipeline on GitHub Actions should run mypy --strict, the snapshot suite, python -m build, twine check, and only then upload — with the publish credential scoped to that one job.
# CLI: pre-publish gate, identical locally and in CI
python -m mypy --strict src/acme_cdktf_network
python -m pytest -q
python -m build && python -m twine check dist/*
python -c "import tomllib,pathlib,sys; v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']; sys.exit(0 if v in open('CHANGELOG.md').read() else 'CHANGELOG missing entry for '+v)"
That last line is the cheapest release control there is: it refuses to build a version that nobody documented. Consumers of an infrastructure construct read the changelog before upgrading precisely because the upgrade lands as a plan diff, and an undocumented change forces them to reverse-engineer intent from JSON.
Finally, keep a deprecation window. When a prop is removed, ship one minor release where passing it emits a DeprecationWarning naming the replacement and the version that will drop it, then remove it in the next major. Infrastructure repositories upgrade on a slower cadence than services, and a construct that breaks without warning gets vendored back into a private copy — which is exactly the failure this whole exercise was meant to end.
FAQ
Should each construct be its own package?
Group closely related constructs (a networking bundle) into one package, but keep unrelated domains separate so consumers only pull what they use. The practical test is the release cadence: constructs that change together belong together, because a shared version number is only informative if a bump means something for everything inside it.
How do consumers override defaults?
Expose typed constructor parameters with sensible defaults. Consumers pass overrides; they never edit your package source. If a consumer needs to reach past your props to a raw provider argument, treat that as a missing prop and add it — subclassing your construct to poke at private attributes creates a dependency on internals you will break.
Can I publish to a private index?
Yes — point twine at CodeArtifact or a self-hosted index and configure consumers' pip.conf accordingly. On CodeArtifact, aws codeartifact login --tool twine writes a 12-hour token into ~/.pypirc, so CI re-authenticates on every run instead of holding a static password.
How do I stop a bad release from spreading?
Yank rather than delete. Yanking leaves the version installable for anyone who already pinned it — so existing stacks still rebuild — while new resolutions skip it. Follow the yank immediately with a patch release, because a yanked version with no successor just moves consumers back one release with no explanation.
Does publishing a construct change existing infrastructure?
Not by itself. Nothing happens until a consumer upgrades their pin, re-synthesizes, and applies. That is why the version number and changelog carry so much weight here: they are the only signal a downstream team gets before the change reaches a plan.
Is this the same workflow as packaging Pulumi components?
The Python packaging half is identical — pyproject.toml, a wheel, an index. The difference is the runtime contract: CDKTF constructs depend on jsii-generated bindings whose versions must line up with the cdktf runtime, which is why the dependency range matters more here. See packaging Pulumi components for reuse for the comparison.
Related
- Building Reusable CDKTF Constructs in Python — how to design the construct you are about to package.
- Python Constructs & Modules — the parent topic on typed, reusable infrastructure.
- Managing IaC Dependencies with Poetry and pip-tools — pinning and resolving the dependencies your package declares.
- Running CDKTF Pipelines in GitHub Actions — where the tag-triggered release job belongs.