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.
Prerequisites
- Python 3.9+ and a CDKTF project scaffolded with
cdktf init --template=python. cdktf-cliinstalled (npm install -g cdktf-cli) and the Terraform CLI onPATH.pipenvor arequirements.txtfor the Python runtime; provider binding packages such ascdktf-cdktf-provider-awsdeclared 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.
Implementation
1. Declare version constraints in cdktf.json
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.
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"
# 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()
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
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
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)
Verification
Confirm the pin took effect by inspecting the synthesized manifest and the resolved provider build:
# 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.
Gotchas & Edge Cases
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.
Related
- CDKTF Architecture & Synthesis — the parent topic explaining how bindings and the dependency graph are generated.
- Terraform Provider Bridging — how provider JSON schemas become typed Python classes.
- Using Terraform Cloud with CDKTF Python Projects — where unpinned providers cause non-deterministic remote runs.
Frequently Asked Questions
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.