How to Pin Terraform Provider Versions in CDKTF

Pinning Terraform provider versions in CDKTF makes synthesis reproducible: the same Python code regenerates the same typed bindings and emits the same Terraform JSON on every machine and every CI run. This task sits inside the CDKTF architecture and synthesis workflow under CDKTF Workflows & Terraform Synthesis, where the provider schema you compile against directly shapes the generated .gen bindings and the resulting cdk.tf.json.

An unpinned provider is a silent reproducibility bug. A laptop that ran cdktf get last month may have a different AWS provider than a CI runner that regenerates bindings today, so a stack that synthesized cleanly can suddenly fail type-checking or emit a different plan—without any change to your Python. Pinning closes that gap by tying both the generated bindings and the Terraform-side provider lock to explicit versions.

Context

CDKTF is unusual among IaC tools in that a provider version influences your build twice, at two different times, through two different mechanisms. The first influence is at code generation time: cdktf get downloads a provider's JSON schema and runs it through jsii to emit Python classes with typed constructors. Every argument name, every enum, every Optional[str] in .gen/ (or in a prebuilt pip package) is a direct function of the schema version that was read. The second influence is at plan time: the synthesized cdk.tf.json carries a terraform.required_providers block, and Terraform independently resolves that constraint against the registry, downloads a provider binary, and records it in .terraform.lock.hcl.

Those two mechanisms do not talk to each other. Nothing in CDKTF verifies that the schema your Python was compiled against is the same schema the provider binary implements at apply time. You can synthesize a perfectly valid cdk.tf.json from 5.40 bindings and then have Terraform install 6.2 because the constraint was written as >= 5.40. The failure shows up as a Terraform-level argument error rather than a Python one, which is why it is often misdiagnosed as a bug in the generated JSON.

Three pinning surfaces in a CDKTF project Three pinning surfaces in a CDKTF project: comparison across Artifact, Pins what, Breaks when absent. Surface Artifact Pins what Breaks when absent Codegen cdktf.json Schema behind .gen Bindings drift Package requirements.txt Prebuilt class set Import mismatch Constraint cdk.tf.json required_providers Plan resolves new Build .terraform.lock.hcl Exact zip + hashes CI applies other
Each surface pins a different thing; a reproducible stack needs all four in agreement.

The practical consequence is that "pin the provider" is really four decisions, not one, and each has a different owner: the codegen constraint lives in cdktf.json, the prebuilt package pin lives in your Python manifest, the emitted constraint lands in cdk.tf.json, and the exact build lands in .terraform.lock.hcl. The rest of this guide sets all four deliberately and then adds a CI check that fails when they diverge.

Prerequisites

Prerequisites Prerequisites: layered from PATH down to Python. PATH pipenv requirements.txt cdktf.json Python
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ and a CDKTF project scaffolded with cdktf init --template=python.
  • cdktf-cli installed (npm install -g cdktf-cli) and the Terraform CLI on PATH.
  • pipenv or a requirements.txt for the Python runtime; provider binding packages such as cdktf-cdktf-provider-aws declared there.
  • Write access to commit cdktf.json, .terraform.lock.hcl, and the lockfile for your Python dependencies.
  • No special IAM permissions are required to pin versions; pinning is a synthesis-time concern, not an apply-time one.

Before you change anything, record the toolchain you are starting from. cdktf debug prints the CLI version, the Node runtime, and the versions of the cdktf, constructs, and jsii libraries resolved in the current project — the numbers you will need if a regenerated binding suddenly stops importing.

# Capture the current toolchain and provider set as a baseline
# Provider note: `cdktf provider list` shows which providers are prebuilt
# packages and which are generated locally into codeMakerOutput.
cdktf debug
cdktf provider list
terraform -chdir=cdktf.out/stacks/storage version

Implementation

1. Declare version constraints in cdktf.json

Implementation Implementation: 1. Declare version then 2. Pin the same then 3. Commit the then 4. Gate provider 1. Declare version 2. Pin the same 3. Commit the 4. Gate provider
Implementation: the stages run left to right — 1. Declare version, 2. Pin the same, 3. Commit the, 4. Gate provider.

The terraformProviders array drives binding generation. Use a ~> constraint to allow patch and minor updates while blocking the next major version, which is where breaking schema changes land.

{
  "language": "python",
  "app": "python main.py",
  "terraformProviders": [
    "aws@~> 5.40",
    "random@~> 3.6"
  ],
  "codeMakerOutput": ".gen",
  "context": {
    "excludeStackIdFromLogicalIds": "true"
  }
}
# Regenerate typed Python bindings into .gen/ from the pinned constraints
# Provider note: cdktf get resolves each constraint to a concrete version and
# writes prebuilt or locally generated bindings under codeMakerOutput.
cdktf get

The ~> 5.40 constraint resolves to the newest 5.x at or above 5.40, never 6.0. Prefer prebuilt provider packages (installed via pip) for large providers like AWS—they pin the version in your Python lockfile and skip the slow jsii codegen step entirely.

Three details about this step catch people out. First, the constraint syntax is Terraform's, not pip's: ~> 5.40 means >= 5.40, < 6.0, while ~> 5.40.0 means >= 5.40.0, < 5.41.0. The two-segment form is the one you usually want for a stack that should absorb bug fixes. Second, cdktf get caches aggressively — if you edit the constraint but the previously generated output still satisfies it, nothing is regenerated. Force it when you want to prove the constraint resolves the way you think:

# Force a full regeneration, ignoring the codegen cache
# Provider note: --force discards existing .gen output so a changed constraint
# is re-resolved against the registry instead of reusing stale bindings.
cdktf get --force

Third, cdktf provider add writes the constraint for you and decides between a prebuilt package and local codegen, which avoids hand-editing cdktf.json and getting the @ separator wrong. Treat codeMakerOutput as build output: add .gen/ to .gitignore and regenerate it in CI, so a stale committed binding can never mask a constraint change.

2. Pin the same version in your Python dependencies

When you use prebuilt provider packages, the pin lives in your Python dependency manifest, and that version must agree with the cdktf.json constraint to avoid two sources of truth.

# requirements.txt entry (exact pin for the runtime that synthesizes the stack)
# CLI: pip install -r requirements.txt
# Provider note: the prebuilt package version must satisfy the cdktf.json
# constraint so generated bindings and the imported package agree.
cdktf-cdktf-provider-aws==19.* ; python_version >= "3.9"

The prebuilt packages carry their own version line that is not the provider version: cdktf-cdktf-provider-aws 19.x wraps AWS provider 5.x, and the package major increments whenever the wrapped provider's major does. That indirection is the single most common source of confusion, so write the mapping into a comment next to the pin and check it whenever a bot proposes an upgrade.

# main.py: import the pinned, prebuilt provider bindings
# CLI: cdktf synth
from constructs import Construct
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket


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

        # Provider note: pinning the package version pins the schema this
        # resource is validated against at synthesis time.
        AwsProvider(self, "aws", region=region)
        S3Bucket(self, "artifacts", bucket="example-pinned-artifacts")


app = App()
StorageStack(app, "storage", region="us-east-1")
app.synth()

For stronger guarantees, resolve the manifest into a fully hashed lockfile with pip-compile and install with --require-hashes. That closes the last gap on the Python side: even a republished wheel with the same version string will fail the install rather than silently changing the classes your stack imports.

# Produce a hash-pinned lockfile, then install from it in CI
# Provider note: --require-hashes makes a re-published wheel a hard failure
# instead of a silent change to the generated provider classes.
pip-compile --generate-hashes -o requirements.lock requirements.in
pip install --require-hashes -r requirements.lock

3. Commit the Terraform dependency lock

Synthesis writes a .terraform.lock.hcl under the synthesized stack directory the first time Terraform initializes. This file pins the exact provider build and its checksums for terraform plan/apply, independent of the binding generation step.

# Generate Terraform JSON, then let Terraform record the provider lock
# State implication: the lock pins provider builds used against your remote
# state; committing it stops CI from silently upgrading providers on apply.
cdktf synth
terraform -chdir=cdktf.out/stacks/storage init
git add cdktf.out/stacks/storage/.terraform.lock.hcl

The lock records two kinds of hash per provider. The h1: entry is the hash of the extracted package for the platform you actually installed on; the zh: entries are the registry's signed hashes for every published platform archive. Only the h1: for your own platform is written by a plain init, which is exactly why a lock created on an Apple Silicon laptop rejects the Linux build in CI.

For multi-platform CI, add the relevant platforms so the lock contains checksums for both your laptop and the runner:

# Record checksums for Linux and macOS so CI and local agree on provider builds
terraform -chdir=cdktf.out/stacks/storage providers lock \
  -platform=linux_amd64 -platform=darwin_arm64

Because cdktf synth regenerates cdktf.out/ from scratch, keep the committed lock outside the generated tree and copy it in before init, or configure the stack directory as a persisted path in CI. A lock file that gets wiped on every synthesis is a lock file that pins nothing.

4. Gate provider drift in CI

Regenerate bindings in CI and fail the build if the resolved version differs from what is committed. A simple typed check parses the synthesized manifest and asserts the provider version.

# tests/test_provider_pin.py
# CLI: cdktf synth && pytest tests/test_provider_pin.py
import json
from pathlib import Path

EXPECTED_AWS_MAJOR = "5"


def test_aws_provider_major_is_pinned() -> None:
    manifest = json.loads(
        Path("cdktf.out/stacks/storage/cdk.tf.json").read_text()
    )
    required = manifest["terraform"]["required_providers"]["aws"]
    # Provider note: required_providers carries the source + version constraint
    # emitted from cdktf.json; assert it has not drifted to a new major.
    assert required["version"].lstrip("~> ").startswith(EXPECTED_AWS_MAJOR)

A stricter version of the same gate reads the committed lock and asserts the exact build, which catches the case where the constraint is unchanged but a new patch release has appeared upstream.

# tests/test_provider_lock.py
# CLI: pytest tests/test_provider_lock.py
import re
from pathlib import Path
from typing import Dict

LOCK = Path("stacks/storage/.terraform.lock.hcl")
_BLOCK = re.compile(
    r'provider\s+"registry\.terraform\.io/(?P<name>[^"]+)"\s*{\s*'
    r'version\s*=\s*"(?P<version>[^"]+)"',
    re.MULTILINE,
)


def locked_versions() -> Dict[str, str]:
    # State implication: these are the builds that will run against real state,
    # regardless of what the cdktf.json constraint allows.
    return {m.group("name"): m.group("version") for m in _BLOCK.finditer(LOCK.read_text())}


def test_locked_aws_build_is_exact() -> None:
    assert locked_versions()["hashicorp/aws"] == "5.44.0"

Wire both tests plus a working-tree check into the pipeline. If cdktf get produces a different binding set than the one the pin implies, the diff shows up as a dirty tree and the job stops before anything touches state.

# CI step: regeneration must be a no-op against the committed pins
# State implication: failing here prevents an unreviewed provider build from
# ever reaching terraform plan against production state.
cdktf get --force
cdktf synth
git diff --exit-code -- cdktf.json requirements.lock
pytest tests/test_provider_pin.py tests/test_provider_lock.py

Verification

Confirm the pin took effect by inspecting the synthesized manifest and the resolved provider build:

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# Show the version constraint emitted into the Terraform JSON
cdktf synth
python -c "import json,sys; \
m=json.load(open('cdktf.out/stacks/storage/cdk.tf.json')); \
print(m['terraform']['required_providers']['aws'])"

# Show the exact build Terraform locked
terraform -chdir=cdktf.out/stacks/storage version

A clean result shows the aws entry with your ~> 5.40 constraint in the JSON and a single matching provider version from terraform version. The pytest assertion above gives you the same guarantee as a CI gate.

The output of terraform version names the build that will actually run, which is the number that matters when you are debugging an argument error:

Terraform v1.7.5
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.44.0

terraform providers is the complementary view: it prints the requirement tree from the configuration alongside the providers your state already references, so a provider that lingers in state after a resource was removed is visible immediately.

# Compare requirements from configuration against requirements from state
# State implication: a provider listed only under "required by state" means
# resources still exist that you can no longer plan without that provider.
terraform -chdir=cdktf.out/stacks/storage providers

Finally, run cdktf get --force on a clean checkout and confirm the working tree stays clean. If regeneration is deterministic on an empty cache, the pin is real rather than an artifact of a warm local cache.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks cdktf.json watch this boundary mypy watch this boundary Edge Cases watch this boundary Python watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Binding version and cdktf.json constraint disagree. If you install a prebuilt cdktf-cdktf-provider-aws package whose underlying provider is 6.x but cdktf.json says aws@~> 5.40, synthesis can emit a 5.x constraint while your imported classes expect the 6.x schema. Keep one source of truth: when using prebuilt packages, prefer pinning in the Python manifest and let it drive, or remove the package and rely on cdktf get codegen—do not mix both for the same provider.

Forgetting to commit .terraform.lock.hcl. Pinning the constraint is not enough. Without the committed lock, terraform init in CI is free to select any provider build that satisfies the constraint, so two runs can apply different builds against the same remote state. Always commit the lock and update it deliberately with terraform providers lock.

Bumping a major without regenerating bindings. Moving from ~> 5.40 to ~> 6.0 changes resource arguments and attribute names. Update the constraint, run cdktf get (or bump the prebuilt package), then run cdktf synth and let mypy/the test suite surface removed or renamed arguments before you ever reach terraform plan.

A lock file built on one platform, used on another. The classic symptom is a job that passes locally and fails on the first CI run after a provider bump:

Error: Failed to install provider

Error while installing hashicorp/aws v5.44.0: the current package for
registry.terraform.io/hashicorp/aws 5.44.0 doesn't match any of the checksums
previously recorded in the dependency lock file

The lock has an h1: hash for darwin_arm64 only. Fix it with terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 and commit the result — never by deleting the lock.

Read-only lock enforcement in CI. Running terraform init -lockfile=readonly is the right hardening for a pipeline, but it turns any un-committed provider change into a hard stop:

Error: Provider lock file is not up-to-date

The lock file .terraform.lock.hcl needs to be updated, but the -lockfile=readonly
flag was set.

Treat this as the gate working. Regenerate the lock locally, review the version delta in the pull request, and commit it — do not drop the flag to make the job green.

Impossible constraint intersections. When one constraint comes from cdktf.json and another from a wrapped HCL module, Terraform reports a resolution failure rather than picking a winner:

Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider hashicorp/aws:
no available releases match the given constraints ~> 5.40, >= 6.0.0

Run terraform providers to see which requirement came from where, then either relax your own bound or upgrade the module.

Silent argument removal across a major. AWS provider 4.0 removed the inline acl argument from aws_s3_bucket. With pinned bindings, the type checker catches it first:

error: Unexpected keyword argument "acl" for "S3Bucket"  [call-arg]

Without pinning — where CI regenerates against a newer schema than your editor used — the same mistake surfaces much later as a Terraform-level error against the emitted JSON:

Error: Unsupported argument

  on cdk.tf.json line 42, in resource.aws_s3_bucket.artifacts:
  42:         "acl": "private"

An argument named "acl" is not expected here.

Operational Notes

Pinning is not a one-time task; it converts an unpredictable upgrade into a scheduled one. Give provider bumps their own cadence and their own pull requests. Bump one provider at a time, because a single PR that moves AWS, Kubernetes, and Random together makes it impossible to attribute a plan diff to a schema change.

Provider bump loop Provider bump loop: Bump constraint → cdktf get --force → synth + mypy → cdktf diff canary → Commit lock → repeat. Bump constraint cdktf get--force synth + mypy cdktf diffcanary Commit lock
A provider upgrade is a five-step loop; the lock file is committed only after the canary diff is read.

Run the loop against a non-production stack first. cdktf diff performs the synthesis and the plan in one step, so it is the cheapest way to see whether a patch-level provider bump is genuinely inert or whether it has changed a default. A bump that produces an empty diff on a canary stack and a large diff on production usually means the two stacks were never on the same provider build to begin with.

# Read the plan a provider bump produces before merging it
# State implication: cdktf diff runs terraform plan against the real backend,
# so it reads state but never writes it.
cdktf diff canary

Pin the CDKTF runtime itself alongside the providers. The cdktf Python library, the constructs library, and the cdktf-cli npm package must be compatible with each other; a CLI that is several minors ahead of the library can emit a cdk.tf.json your library never produced locally. Pin cdktf-cli in package.json and install it with npm ci in the pipeline rather than npm install -g at an unpinned version.

For air-gapped or egress-restricted environments, mirror the pinned providers instead of reaching the public registry on every run. terraform providers mirror writes a directory tree that a filesystem_mirror block in the CLI configuration can serve, which also makes provider downloads reproducible if a version is ever pulled from the registry.

# Vendor exactly the locked provider builds for offline initialization
# Provider note: the mirror contains only versions allowed by the constraint,
# so a stale mirror surfaces as a resolution error rather than a silent upgrade.
terraform -chdir=cdktf.out/stacks/storage providers mirror ./vendor/providers

Finally, record why a pin exists. A comment such as # held at 5.44.0: 5.45 changes default tag propagation in cdktf.json or the requirements file saves the next engineer from "upgrading" past a deliberate hold and re-discovering the incident that caused it.

FAQ

What is the difference between the cdktf.json constraint and .terraform.lock.hcl?

The cdktf.json terraformProviders constraint controls which provider schema your typed Python bindings are generated from at synthesis time. The .terraform.lock.hcl pins the exact provider build and checksums Terraform downloads at init for plan and apply. You need both: the constraint keeps your code reproducible, and the lock keeps your deployments reproducible.

Should I use exact pins (5.40.0) or range constraints (~> 5.40)?

Use ~> 5.40 for most stacks so you pick up patch and minor security fixes without major-version surprises, and rely on the committed lockfile for build-level determinism. Reserve exact pins for production stacks where every schema change must be reviewed explicitly before it can affect state.

Why does cdktf get keep changing my generated .gen directory?

That usually means the constraint resolved to a newer provider than last time, often because the constraint is too loose or the prebuilt package was upgraded. Tighten the ~> bound, commit the lockfile, and treat .gen as generated output—regenerate it in CI rather than editing it by hand.

How do I safely upgrade a provider major version?

Bump the constraint in cdktf.json (and the prebuilt package version), run cdktf get to regenerate bindings, then run cdktf synth and your type checks. Review the resulting cdktf diff carefully, since major versions frequently rename arguments or change defaults before you apply against real state.

Does the prebuilt package version match the Terraform provider version?

No, and assuming it does causes real outages. The cdktf-cdktf-provider-aws package has its own major line — 19.x wraps AWS provider 5.x — so a "major upgrade" of the pip package may be a minor provider change or vice versa. Check the wrapped provider version in the synthesized required_providers block rather than trusting the package number.

Should I commit the .gen directory to version control?

Prefer not to. Generated bindings for a large provider are tens of thousands of files, they make code review meaningless, and a stale committed copy hides constraint changes. Add codeMakerOutput to .gitignore, run cdktf get as an early CI step, and cache it by the hash of cdktf.json if regeneration time becomes a problem.