Setting Up Dev Environments
An infrastructure repository has a stricter definition of "works on my machine" than an application repository does. When an application's dependencies drift, a test fails. When an IaC repository's dependencies drift, a plan changes — and a changed plan is a proposal to modify or destroy live resources that nobody asked for. This topic, part of Python IaC fundamentals and strategy, covers what has to be pinned, how to isolate it, how credentials reach the provider without ever touching the repository, and how to prove that a laptop and a CI runner produce the same plan for the same commit.
Problem Framing
The failure this page exists to prevent has a specific shape. An engineer opens a pull request that renames a tag. The CI plan shows one in-place update. A colleague reviews it, approves, and merges. The deploy job runs on a runner that resolved a slightly newer provider package than the pull-request job did, and the newer provider changed a default the older one left unset. The apply now includes a replacement of a database instance that nobody's diff ever showed. The code was fine. The environment was not.
Every part of that story traces back to one property: the plan is a function of the code and the toolchain that evaluates it. Python IaC frameworks lean on a deep stack — interpreter, framework SDK, generated provider bindings, the provider plugin binary itself, and one or two external CLIs written in other languages. Only the middle of that stack is covered by requirements.txt. The rest is pinned by other files, or by nothing at all, and the parts pinned by nothing are where drift lives.
The second problem is credential shape. Infrastructure code runs with the most powerful credentials in the organisation. A developer needs enough access to run a preview against a real account, because a preview that cannot read state tells you nothing; but a developer must not hold standing permission to modify production. That means at least two identities, a mechanism for assuming them that does not involve pasting keys into a file, and a hard rule that no credential material is ever written inside the repository tree.
The third is feedback latency. If the only way to find out whether a change synthesizes is to push and wait four minutes for a runner, engineers batch changes, and batched infrastructure changes are exactly the ones that go wrong. A properly configured environment answers "does this synthesize, type-check, and pass policy" in seconds, locally, with no cloud calls at all.
Set against those three problems, environment setup stops looking like housekeeping. It is the control that makes review meaningful, the boundary that keeps credentials scoped, and the loop that keeps changes small.
Prerequisites
- A specific Python minor version chosen and written down —
python3.11, not "Python 3" - The framework CLI installed outside the virtual environment: the
pulumibinary, ornode>=18plusterraform>=1.2for CDKTF, whose CLI is a Node program that shells out to Terraform - A dependency manager decision:
uv,pip-tools, or Poetry, covered in depth in managing Python IaC dependencies with Poetry and pip-tools - A cloud identity a developer can assume for read and preview operations, separate from the identity that deploys
git, pluspre-commitif you intend to gate anything locally
# CLI: confirm every layer of the toolchain before writing a line of stack code
python3.11 --version # 3.11.9
pulumi version # v3.128.0
terraform version # Terraform v1.9.5
node --version # v20.15.1 (cdktf CLI runtime only)
cdktf --version # 0.20.8
# Provider note: the pulumi CLI and the pulumi Python SDK version independently.
# A CLI older than the SDK fails at load time, not at apply time.
Environment Bootstrapping & Dependency Isolation
Establish a reproducible foundation by isolating Python runtimes and IaC dependencies before provisioning resources. Modern dependency resolution aligns directly with Python IaC Fundamentals & Strategy to prevent version drift across distributed engineering teams.
Deterministic Lockfiles & Runtime Pinning
Use uv or pip-tools to enforce exact dependency resolution across all developer machines. Pin Pulumi and CDKTF SDK versions explicitly to avoid silent breaking changes during minor Python releases. Always verify minor version compatibility before upgrading the base interpreter—Pulumi provider packages and CDKTF provider bindings are closely tied to the framework version they were generated against. For a full walkthrough of lockfiles, separating dev and runtime dependencies, and exporting pinned requirements for CI runners, see Managing Python IaC Dependencies with Poetry and pip-tools.
The reason a range like pulumi-aws>=6.0 is dangerous in this context is worth spelling out. Provider packages are generated from a Terraform provider schema, and a minor bump of that schema can introduce a new optional argument with a non-null default. Your program does not mention the argument, the older package did not emit it, the newer one does — and the diff engine sees a changed attribute on every resource of that type. The code did not change; the schema did. Exact pins turn that into a deliberate, reviewable upgrade with its own plan output attached.
Pre-commit Hooks & Static Analysis Pipelines
Integrate ruff, mypy, and language servers into local Git workflows to intercept syntax and type errors before infrastructure execution. Configure hooks to run on staged files only, ensuring fast feedback during rapid iteration. Fail fast on untyped function signatures to maintain strict contract enforcement.
Type checking earns more here than in most Python code, because the frameworks are aggressively typed at the boundary. mypy will reject passing a str where a resource expects an Input[str] that it will later resolve, and it will catch the single most common Pulumi mistake — using an Output value as if it were a plain string:
# .pre-commit-config.yaml — fast local gates, staged files only
# CLI: pre-commit install && pre-commit run --all-files
repos:
- repo: local
hooks:
- id: ruff
name: ruff (lint + autofix)
entry: .venv/bin/ruff check --fix
language: system
types: [python]
- id: ruff-format
name: ruff (format)
entry: .venv/bin/ruff format
language: system
types: [python]
- id: mypy
name: mypy --strict on infra/
entry: .venv/bin/mypy --strict
language: system
types: [python]
files: ^infra/
pass_filenames: false
- id: detect-secrets
name: block committed credentials
entry: .venv/bin/detect-secrets-hook --baseline .secrets.baseline
language: system
Running the hooks as language: system against .venv is deliberate. The alternative — letting pre-commit build its own isolated environment per hook — means mypy type-checks your code against a different set of SDK versions than the ones you deploy with, and the resulting "unused type: ignore" and missing-stub noise is what makes teams disable the hook within a month. Point the hooks at the same virtual environment the plan runs in, and the type checker sees the same Input[str] signatures the engine will.
#!/usr/bin/env bash
# CLI: ./scripts/bootstrap.sh
set -euo pipefail
# Create isolated runtime and activate it
python3.11 -m venv .venv
source .venv/bin/activate
# Install modern resolver
pip install uv
# Compile exact lockfile from pyproject.toml
uv pip compile pyproject.toml -o requirements.txt
# Install pinned dependencies with strict verification
uv pip install -r requirements.txt
What Actually Has to Be Pinned
A green pip install -r requirements.txt proves that the Python layer matches. It says nothing about the four other layers that participate in producing a plan.
The layer that surprises people is the provider plugin binary. In Pulumi, pulumi_aws is a Python package that talks over gRPC to a separate pulumi-resource-aws executable downloaded into ~/.pulumi/plugins. The package and the plugin are versioned together but installed by different mechanisms, so a runner with a warm plugin cache can be running an older binary than the package expects. Pin it explicitly and install it as part of the bootstrap:
# CLI: pin and pre-install the resource plugin, don't let the runtime fetch it lazily
pulumi plugin install resource aws 6.42.0
pulumi plugin ls --json | jq -r '.[] | [.name, .version, .installTime] | @tsv'
# Provider note: plugins live under $PULUMI_HOME/plugins (default ~/.pulumi).
# Set PULUMI_HOME to a workspace path in CI so the cache is per-job, not shared.
Terraform, and therefore CDKTF, records the equivalent information in .terraform.lock.hcl — including a checksum per platform. That file must be committed, and it must contain hashes for every platform your team uses, or an ARM laptop will fail against a lockfile generated on an x86 runner. Generate the full set once:
# CLI: record provider checksums for every platform the team and CI use
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64 \
-platform=darwin_amd64
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
# Provider note: with the cache set, `cdktf get` and `terraform init` reuse one
# copy of each provider across every stack instead of re-downloading per stack.
CDKTF adds one more artifact of its own. cdktf get reads the terraformProviders list in cdktf.json and generates Python bindings into the directory named by codeMakerOutput (conventionally imports/). Those generated modules are an input to your program exactly like a dependency. Either commit them, or run cdktf get in the bootstrap and treat the provider list in cdktf.json as the pin — but never leave it ambiguous, because a program that imports imports.aws will fail with ModuleNotFoundError: No module named 'imports.aws' on any machine that skipped the step.
Isolation Boundaries: venv, Devcontainer, or Image
Isolation is not a single decision. Each level up removes a class of drift and costs somebody a build step to maintain, and choosing more isolation than the team needs produces an image nobody rebuilds and everybody works around.
A virtual environment plus a lockfile is correct for a repository with one or two maintainers on similar machines. It pins Python packages and nothing else, so the external CLIs remain whatever each engineer installed — acceptable when there are few enough engineers to notice a mismatch quickly.
A devcontainer becomes worth it once the CLI versions matter, which in practice is as soon as CDKTF is involved, because now Node, Terraform and the cdktf CLI all have to agree. The container definition is the only place where "everyone runs Terraform 1.9.5" can actually be true.
A purpose-built image, pinned by digest rather than tag, is the right answer when the same environment has to serve CI. Pinning by tag is a common half-measure that fails silently: python:3.11-slim is rebuilt regularly, so two runs a month apart execute different base layers. Reference the digest and upgrade it deliberately, the same way you upgrade a dependency.
Whatever level you pick, keep the activation implicit. A .envrc handled by direnv that activates .venv and exports PULUMI_HOME costs nothing and removes an entire category of "I forgot to activate" incidents where a command runs against the system interpreter and reports a wildly wrong plan.
State Backend Configuration & Workspace Management
Configure remote state storage and environment-specific workspaces to enable safe parallel development and prevent destructive collisions. Unlike declarative HCL approaches detailed in Python vs Terraform vs Ansible, Python-native IaC frameworks expose state management through programmatic APIs that integrate seamlessly with existing CI pipelines.
Remote State Routing & Encryption
Initialize S3 or GCS backends with server-side encryption and strict IAM bucket policies to protect sensitive infrastructure state. Enforce distributed locking mechanisms to serialize concurrent deployments and eliminate race conditions. Always validate encryption key rotation schedules before promoting environments.
The developer-environment consequence of a remote backend is that a preview is no longer a local operation: it reads the checkpoint over the network and, depending on flags, refreshes resources against the provider API. That is why the developer identity needs read access to the state bucket and to the resources themselves. Granting only s3:GetObject on the state prefix and nothing else produces the confusing failure where pulumi preview succeeds but every resource shows as a create, because the engine could not read what already exists. The mechanics of backends, locking and migration are covered in managing IaC state.
Stack/Workspace Isolation Patterns
Map Pulumi stacks and CDKTF workspaces directly to environment tiers, implementing strict configuration layering. Sanitize sensitive outputs by routing secrets through external vaults rather than plaintext state files. Isolate state files per environment to contain blast radius during failed deployments.
Give every engineer a personal stack — dev-<name> — that they can create and destroy freely, and make it the default target so that a mistyped command cannot land on shared infrastructure. The Automation API makes that ergonomic, because the workspace can be created from Python rather than a sequence of CLI calls:
# workspace.py — create or select an isolated per-engineer stack
# CLI: python -m workspace
import getpass
from pulumi.automation import ConfigValue, Stack, create_or_select_stack
def init_dev_workspace(stack_name: str, work_dir: str = "./infra") -> Stack:
"""Create and configure an isolated IaC workspace with environment-specific routing."""
stack = create_or_select_stack(stack_name=stack_name, work_dir=work_dir)
# Explicitly route state and configure region
stack.set_config("aws:region", ConfigValue(value="us-west-2"))
stack.set_config("app:env", ConfigValue(value="dev"))
return stack
def personal_stack() -> Stack:
"""Default every local run to dev-<user> so a typo cannot target prod."""
# State implication: create_or_select_stack writes a new checkpoint on first
# use; destroying the stack afterwards is what keeps sandbox spend bounded.
return init_dev_workspace(f"dev-{getpass.getuser()}")
if __name__ == "__main__":
print(personal_stack().name)
Deeper patterns for structuring stacks and passing values between them are covered in Pulumi stack architecture.
Provider Initialization & Credential Routing
Securely route cloud credentials and initialize provider plugins without embedding secrets in source control. Apply modular composition techniques outlined in IaC Design Principles to maintain strict separation between authentication logic, provider configuration, and resource definitions.
Credential Sourcing & OIDC Integration
Implement AWS IAM Roles Anywhere, GitHub OIDC federation, or GCP Workload Identity Federation to eliminate long-lived access keys. Configure local developer fallback chains using aws-vault or gcloud auth application-default login for interactive debugging. Never hardcode credentials; rely exclusively on environment variable injection or cloud metadata endpoints.
The mechanism underneath OIDC federation is worth understanding, because its failure messages are opaque otherwise. The CI platform mints a short-lived JSON Web Token describing the workflow — repository, branch, environment — and the cloud provider exchanges it for temporary credentials, but only if the role's trust policy matches those claims. A trust policy whose condition reads repo:acme/infra:ref:refs/heads/main will refuse a pull-request job, which presents repo:acme/infra:pull_request, with Not authorized to perform sts:AssumeRoleWithWebIdentity. That is the correct behaviour: the read-only preview role should accept pull-request claims, and the deploy role should accept only the main-branch claim.
# CLI: local developer chain — no long-lived keys anywhere on disk
aws-vault exec acme-dev -- pulumi preview --stack dev-mreid
aws sts get-caller-identity --query 'Arn' --output text
# arn:aws:sts::123456789012:assumed-role/PreviewReadOnly/aws-vault
# Provider note: the AWS provider resolves credentials through boto3's standard
# chain, so anything aws-vault exports into the process is picked up unchanged.
The corresponding rule for the repository is absolute: no .env, no credentials.json, no terraform.tfvars containing a key, and a detect-secrets hook to enforce it. A committed credential is not a mistake you fix with a revert, because the object stays in the git history and must be rotated.
Provider Plugin Version Pinning & Caching
Explicitly declare plugin registry downloads in Pulumi.yaml or cdktf.json to guarantee deterministic provider initialization. Implement local caching strategies to reduce network latency during pipeline execution. Maintain offline provider bundles for air-gapped environments to ensure uninterrupted deployment cycles.
One Pulumi.yaml setting deserves particular attention, because getting it wrong produces the single most common first-day error in a Python Pulumi repository. The runtime block must point at the virtual environment; otherwise the CLI invokes the system interpreter, which has none of your dependencies installed:
# Pulumi.yaml — bind the language host to the project virtualenv
# CLI: pulumi preview --stack dev
name: network-core
runtime:
name: python
options:
virtualenv: .venv
description: Core VPC and subnet layout
Local Testing & Validation Workflows
Implement rapid feedback loops using unit testing, dry-run previews, and policy-as-code checks. This replaces fragile shell-based validation and directly supports teams Migrating legacy bash scripts to Python IaC by leveraging pytest, mocking frameworks, and programmatic diff analysis.
Unit Testing Infrastructure Code
Mock cloud SDK responses using unittest.mock or moto to isolate resource property assignments from live API calls. Write parameterized test cases to validate reusable module configurations across multiple input permutations. Assert critical security properties like encryption flags and public access blocks before merging.
The fastest of these loops needs no cloud credentials at all. Pulumi ships pulumi.runtime.set_mocks, which replaces the engine with an in-process fake, so a whole stack evaluates in milliseconds and every resource's resolved inputs are assertable. CDKTF's Testing.synth(app) does the equivalent by returning the Terraform JSON as a string. Either way the test is pure: no network, no state, no ordering dependency between tests.
# tests/test_network.py — evaluate the program with a mocked engine
# CLI: pytest tests/test_network.py -v --tb=short
import pytest
from unittest.mock import MagicMock, patch
from my_infra.network import create_vpc
@pytest.fixture
def mock_provider() -> MagicMock:
"""Provide a mocked cloud provider context for safe testing."""
return MagicMock()
def test_vpc_cidr_assignment(mock_provider: MagicMock) -> None:
"""Verify VPC configuration properties before deployment."""
with patch("my_infra.network.Provider", return_value=mock_provider):
vpc = create_vpc(cidr_block="10.0.0.0/16", provider=mock_provider)
assert vpc.cidr_block == "10.0.0.0/16"
assert vpc.enable_dns_support is True
Wire the whole local loop into one target so nobody has to remember the order, and so the CI job can invoke exactly the same entry point. The broader strategy for what to unit-test versus what to leave for integration is covered in testing Python IaC.
# CLI: make verify — the entire local gate, no cloud credentials required
ruff check . && ruff format --check .
mypy --strict infra/
pytest -q
cdktf synth # or: pulumi preview --stack dev-$USER --diff
Policy Enforcement & Preview Automation
Integrate Checkov or OPA to enforce organizational guardrails during the preview phase. Parse automated resource diffs to detect unauthorized scaling or network exposure changes. Attach pre-deployment cost estimation hooks (e.g., infracost) to prevent budget overruns during rapid prototyping.
Run the same policy scan locally that CI will run, and run it from the same pinned version. A scan that only exists in the pipeline trains engineers to discover findings after review has already started; a scan available as make verify gets run before the branch is pushed. Wiring that gate end to end is covered in security and compliance basics.
CI/CD Handoff & Environment Parity
Bridge local development with pipeline execution by standardizing artifact generation, containerized runners, and drift detection. Ensure identical execution contexts across developer workstations and CI agents to eliminate environment-specific provisioning failures.
Containerized IaC Runners
Construct multi-stage Dockerfiles that cache provider plugins and lockfile dependencies to accelerate build times. Compile multi-arch images to guarantee consistent execution across ARM developer laptops and x86 CI runners. Pin the base OS image to a specific digest to prevent supply chain contamination.
Order the layers so that the slow, rarely changing steps cache well: base image, then external CLIs, then the lockfile install, then the source. Copying the source before installing dependencies is the classic mistake — it invalidates the dependency layer on every commit and turns a ten-second build into a three-minute one.
# CLI: resolve the digest once, then build against it — never against a tag
docker buildx imagetools inspect python:3.11-slim --raw | sha256sum
docker build --build-arg PYTHON_BASE_DIGEST="sha256:<digest-from-above>" \
--platform linux/amd64,linux/arm64 -t acme/iac:2026-08 .
# Dockerfile, layered slowest-changing first:
# ARG PYTHON_BASE_DIGEST
# FROM python:3.11-slim@${PYTHON_BASE_DIGEST}
# COPY --from=pulumi/pulumi-base:3.128.0 /pulumi/bin/pulumi /usr/local/bin/pulumi
# COPY requirements.txt /app/requirements.txt
# RUN pip install --no-cache-dir -r /app/requirements.txt \
# && pulumi plugin install resource aws 6.42.0
# COPY . /app
# Provider note: baking the plugin into the image means the runner never
# downloads a provider at deploy time, which removes both a latency spike and
# a dependency on the registry being reachable.
Drift Detection & Automated Remediation
Schedule nightly preview jobs to compare live infrastructure against committed state definitions. Implement state reconciliation workflows that automatically flag unauthorized manual changes or resource mutations. Route drift alerts directly to incident management platforms for rapid triage and rollback.
Drift reporting also doubles as an environment-parity check. If the nightly job on the runner produces a non-empty diff for a commit that an engineer previewed as clean that afternoon, the infrastructure did not drift — the toolchains did. Treat that as a build-environment bug and diff the two versions before touching any resource.
Step-by-Step: Bootstrapping a New Repository
Five commands, each producing a committed artifact. A new contributor runs the same five and lands on an identical dependency set.
1. Create the interpreter boundary
# CLI: ./scripts/bootstrap.sh (step 1)
python3.11 -m venv .venv
source .venv/bin/activate
python -c "import sys; assert sys.version_info[:2] == (3, 11), sys.version"
2. Declare and lock dependencies
# CLI: regenerate the lockfile whenever pyproject.toml changes
uv pip compile pyproject.toml -o requirements.txt --generate-hashes
uv pip compile pyproject.toml --extra dev -o requirements-dev.txt --generate-hashes
uv pip sync requirements-dev.txt
# Provider note: --generate-hashes makes the install fail closed if an artifact
# on the index was republished under the same version.
3. Pin the plugin layer
# CLI: install the exact plugin the pinned SDK expects
pulumi plugin install resource aws 6.42.0
# CDKTF equivalent — regenerate bindings and record per-platform checksums
cdktf get
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64
4. Add the local gate
# CLI: install the hooks so the gate runs on commit, not on push
pre-commit install
pre-commit run --all-files
5. Prove the environment reproduces
Write the check as code so it can run identically on a laptop and a runner. This is the artifact that answers "are these two machines the same".
# tools/env_report.py — a comparable fingerprint of every pinned layer
# CLI: python -m tools.env_report > env-$(hostname).json
import json
import platform
import subprocess
import sys
from dataclasses import dataclass, asdict
from typing import Dict
@dataclass(frozen=True)
class EnvReport:
python: str
machine: str
pulumi_cli: str
packages: Dict[str, str]
def _cli(*args: str) -> str:
try:
return subprocess.run(args, capture_output=True, text=True,
check=True).stdout.strip()
except (FileNotFoundError, subprocess.CalledProcessError):
return "MISSING"
def collect() -> EnvReport:
from importlib.metadata import distributions
pkgs = {d.metadata["Name"].lower(): d.version for d in distributions()}
watched = ("pulumi", "pulumi-aws", "cdktf", "cdktf-cdktf-provider-aws")
return EnvReport(
python=platform.python_version(),
machine=platform.machine(),
pulumi_cli=_cli("pulumi", "version"),
packages={k: pkgs.get(k, "MISSING") for k in watched},
)
if __name__ == "__main__":
report = collect()
json.dump(asdict(report), sys.stdout, indent=2, sort_keys=True)
Run it in both places and diff the two files. Any difference is a parity bug, and finding it here costs a minute; finding it during an apply costs an incident.
Verification
An environment is verified when a clean checkout on a machine that has never seen the repository reaches a working plan without a human improvising. Test it the way a new hire would experience it.
# CLI: cold-start verification in a throwaway directory
git clone [email protected]:acme/infra.git /tmp/coldstart && cd /tmp/coldstart
./scripts/bootstrap.sh
make verify
pulumi preview --stack dev-$USER --diff --non-interactive
# Expect: "Resources: 14 unchanged" against an already-deployed sandbox stack,
# and a non-zero exit only if ruff, mypy, pytest or the policy scan failed.
Three signals confirm the setup rather than merely the absence of errors. First, python -c "import pulumi, sys; print(sys.executable)" must print a path inside .venv — if it prints /usr/bin/python3, the CLI is not using your environment and every subsequent result is unreliable. Second, pulumi plugin ls must show the pinned plugin version and no second copy of the same provider. Third, the env_report.py output from a laptop and from a CI runner must be identical apart from machine; an arm64/x86_64 difference is expected, a package version difference is not.
Finally, verify the credential boundary in the direction that matters: confirm a developer cannot deploy to production. aws-vault exec acme-dev -- aws sts assume-role --role-arn arn:aws:iam::999999999999:role/Deploy --role-session-name t should fail with AccessDenied. A permissions model nobody has tested in the deny direction has not been tested.
Troubleshooting
The CLI cannot find the SDK that is definitely installed
ModuleNotFoundError: No module named 'pulumi' from a shell where pip show pulumi succeeds. The Pulumi.yaml runtime block is missing its virtualenv option, so the language host launched the system interpreter. Add options: { virtualenv: .venv } under runtime: name: python, and note that the path is relative to the directory containing Pulumi.yaml, not to your shell's working directory. The same symptom with the opposite cause appears when PULUMI_HOME points at a directory another project owns.
Terraform refuses to install a provider on an Apple Silicon laptop
Error: Failed to install provider — Provider registry.terraform.io/hashicorp/aws v5.31.0 does not have a package available for your current platform, darwin_arm64. The lockfile was generated on a Linux runner and records checksums for linux_amd64 only. Regenerate it with every platform listed — terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 — and commit the result. Do not work around it with TFENV_ARCH or by deleting .terraform.lock.hcl; deleting the lockfile silently permits a different provider version and reintroduces exactly the drift the file exists to prevent.
Checksums do not match after a dependency update
Error: Failed to install provider — the local package for registry.terraform.io/hashicorp/aws 5.31.0 doesn't match any of the checksums recorded in the dependency lock file. Usually a partially populated TF_PLUGIN_CACHE_DIR from an interrupted download, occasionally a provider republished upstream. Clear the cache directory for that provider and re-run terraform init. If it recurs on a clean cache, the upstream artifact changed and the lockfile entry must be regenerated deliberately, with the new hash reviewed as part of the pull request.
A CDKTF program cannot import its generated bindings
ModuleNotFoundError: No module named 'imports.aws' on a fresh clone. The generated bindings are produced by cdktf get from the terraformProviders list in cdktf.json, and the imports/ directory is usually gitignored. Run cdktf get in the bootstrap script and cache the output directory in CI, keyed on a hash of cdktf.json. If the import still fails after a successful cdktf get, check codeMakerOutput — it must match the package name your code imports.
The plan differs between a laptop and the runner for the same commit
Nothing in the repository changed, but the runner shows resource updates the local preview did not. Compare the two env_report.py outputs. The usual culprit is a provider package resolved from a range, or a stale plugin in a shared runner cache that an exact-pinned package happily talked to. Pin the package exactly, install the plugin explicitly rather than lazily, and make the runner cache key include the lockfile hash so a dependency change cannot hit a warm cache from the previous version.
OIDC works on main and fails on every pull request
An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity. The role trust policy pins token.actions.githubusercontent.com:sub to repo:acme/infra:ref:refs/heads/main, and a pull-request job presents repo:acme/infra:pull_request. Add the pull-request subject to the preview role only, keep the deploy role restricted to the main-branch subject, and check that the workflow requests permissions: id-token: write — without it no token is minted at all and the error arrives before any trust evaluation.
Key Takeaways
A well-configured dev environment eliminates the most common class of IaC failures: version drift between local and CI execution contexts. Invest in lockfiles, isolated virtual environments, and pre-commit hooks early—these pay dividends every time a pipeline runs. The test and preview workflow described here provides a reliable gate that catches misconfigurations before they reach production.
FAQ
What does a good Python IaC dev environment include?
A pinned interpreter, a locked dependency set via Poetry or pip-tools, mypy, and the provider CLIs, all reproducible across machines and CI. The test is not that it installs — it is that a cold clone reaches a clean preview without anyone improvising a missing step.
Virtualenv per project or shared?
One isolated environment per project — infrastructure projects pin different provider SDK versions, and a shared environment guarantees conflicts. The cost of a second virtual environment is a few hundred megabytes; the cost of a shared one is an unexplained plan diff.
How do I keep local and CI environments identical?
Lock dependencies to exact versions and install from the lockfile in both places, so a green local run means a green pipeline. Extend the same discipline past Python: pin the provider plugin binary, commit .terraform.lock.hcl with every platform your team uses, and reference the CI base image by digest rather than tag.
Do developers need cloud credentials at all?
For fast local work, no. Unit tests with mocked engines and cdktf synth require no credentials whatsoever, and that loop should cover most edits. A meaningful pulumi preview does need read access, because it compares against real state — grant a read-only preview role, and keep deploy permissions in a role only the pipeline can assume.
Why does my plan change when I have not changed any code?
Almost always a version moved underneath you: a provider package resolved from a range, a plugin binary picked up from a warm cache, or a base image rebuilt behind a floating tag. Diff the environment fingerprints from the two runs before you touch a single resource — the change is in the toolchain, not the infrastructure.
Should generated CDKTF bindings be committed to the repository?
Either commit them or generate them in the bootstrap, but decide explicitly and write it in the README. Committing makes clones self-contained at the cost of large, noisy diffs on every provider upgrade; generating keeps the repository small but makes cdktf get a hard prerequisite for every clone and every CI job.
Related
- Managing Python IaC Dependencies with Poetry and pip-tools — pin provider SDKs, generate lockfiles, and export reproducible requirements for Pulumi and CDKTF runtimes.
- Migrating Legacy Bash Scripts to Python IaC — replace fragile shell provisioning with typed, declarative resource definitions.
- Security & Compliance Basics — the policy gates and credential rules that the local environment has to run before a push.
- Python IaC Fundamentals & Strategy — the strategic framing for tool selection, state, testing, and security.