Managing IaC State for Python Projects
State is the ledger that maps your Python infrastructure code to the real resources it created; getting it wrong corrupts deployments, so this guide covers the backends, locking, encryption, and per-environment isolation that every Python IaC project needs, as part of the broader Python IaC Fundamentals & Strategy discipline.
Whether you run Pulumi or CDKTF, the same physics apply: a single mutable file (or remote object) records resource IDs, and two runs touching it at once will clobber each other. The differences are in where that ledger lives and how the tool acquires a lock before mutating it.
Problem Framing
Without managed state, your tool has no memory of what it built. The next run either tries to recreate everything or, worse, two engineers run pulumi up against the same stack simultaneously and one overwrites the other's resource IDs, leaving orphaned cloud resources and a state file that no longer reflects reality. Drift then accumulates silently until an apply deletes something it shouldn't. The fix is a remote, locked, encrypted backend with strict isolation between environments — the same discipline that underpins safe IaC design principles.
Prerequisites
- Python 3.9+ with your IaC toolchain installed (
pulumiCLI orcdktfCLI plus the Terraform binary). - Cloud credentials with permission to read/write the backend store (e.g. an S3 bucket and DynamoDB table, or a GCS bucket).
- For Pulumi: a chosen backend selected via
pulumi login. - For CDKTF: a configured Terraform backend (S3, GCS, or Terraform Cloud).
# Verify your toolchain and backend selection before doing anything else.
pulumi whoami # confirms which Pulumi backend you are logged into
cdktf --version # confirms the CDKTF CLI is on PATH
terraform version # CDKTF delegates state ops to this binary
Concept Explanation
What state actually stores
State is a serialized graph of every managed resource: its logical name, its provider-assigned ID, its last-known input values, and dependency edges. Pulumi keeps this as a checkpoint in its own backend; CDKTF synthesizes Terraform JSON and lets the Terraform binary own the .tfstate. In both cases the file contains resource IDs and frequently secret values, which is why encryption at rest is non-negotiable.
Locking prevents concurrent corruption
Before mutating state, the tool acquires a lock. Terraform uses a DynamoDB item (with S3 backends) or the native lock of Terraform Cloud; Pulumi Cloud and most Pulumi object backends acquire a per-stack lock automatically. A run that cannot acquire the lock blocks rather than racing.
from dataclasses import dataclass
@dataclass(frozen=True)
class BackendConfig:
"""Typed description of a remote, locked, encrypted state backend."""
bucket: str
region: str
lock_table: str # DynamoDB table for Terraform locking
kms_key_arn: str | None # encrypt state at rest
def backend_block(cfg: BackendConfig, env: str) -> dict[str, object]:
# CLI Context: feed into cdktf via add_override("terraform.backend", ...)
# State implication: the `key` is namespaced per environment so dev/staging/prod
# never share one state object — concurrent envs cannot corrupt each other.
return {
"s3": {
"bucket": cfg.bucket,
"key": f"iac/{env}/terraform.tfstate",
"region": cfg.region,
"dynamodb_table": cfg.lock_table,
"encrypt": True,
"kms_key_id": cfg.kms_key_arn,
}
}
Encryption and isolation
State leaks secrets if stored in plaintext. Enable bucket-level encryption (SSE-KMS on S3, CMEK on GCS) and restrict read access by IAM. Isolation means each environment gets its own state object — a distinct S3 key, GCS prefix, Pulumi stack, or Terraform workspace — so a destroy in dev can never touch prod.
Step-by-Step Implementation
1. Pick and provision a backend
Choose the store that matches your team's operational model. The trade-offs between S3+DynamoDB, GCS, Terraform Cloud, and Pulumi Cloud are weighed in detail in Choosing a State Backend for Python IaC.
# Provision an S3 backend + DynamoDB lock table once, out of band.
aws s3api create-bucket --bucket my-iac-state --region us-east-1
aws s3api put-bucket-versioning --bucket my-iac-state \
--versioning-configuration Status=Enabled
aws dynamodb create-table --table-name iac-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
# State implication: versioning lets you roll back a corrupted state object.
2. Wire the backend into your stack
For CDKTF, the backend is configured in cdktf.json or via add_override. Pulumi selects its backend with pulumi login and isolates with stacks. The CDKTF-specific mechanics — including remote execution — are covered in State Backend Configuration for CDKTF.
from constructs import Construct
from cdktf import TerraformStack
class StatefulStack(TerraformStack):
def __init__(self, scope: Construct, ns: str, env: str) -> None:
super().__init__(scope, ns)
# Provider note: backend must be set before any resource is synthesized.
self.add_override("terraform.backend", {
"s3": {
"bucket": "my-iac-state",
"key": f"iac/{env}/terraform.tfstate",
"region": "us-east-1",
"dynamodb_table": "iac-locks",
"encrypt": True,
}
})
# CLI Context: cdktf synth && terraform -chdir=cdktf.out/stacks/<ns> init
3. Isolate per environment
For Pulumi, one stack per environment is the idiomatic boundary; the patterns for organizing those stacks live in Pulumi Stack Architecture.
# Pulumi: each stack is an independent, locked state object.
pulumi stack init dev
pulumi stack init staging
pulumi stack init prod
# State implication: `pulumi up` only ever mutates the currently selected stack.
Verification
Confirm that state is remote, locked, and isolated before trusting it.
# Pulumi: list resources recorded in the selected stack's state.
pulumi stack select prod
pulumi stack --show-urns
# CDKTF/Terraform: confirm the backend is remote and inspect the lock table.
terraform -chdir=cdktf.out/stacks/prod state list
aws dynamodb scan --table-name iac-locks --max-items 5
# A populated state list plus a backend that is NOT "local" confirms remote state.
Troubleshooting
Error: Error acquiring the state lock / ConditionalCheckFailedException.
Cause: a previous run crashed without releasing the DynamoDB lock. Fix: confirm no run is active, then terraform force-unlock <LOCK_ID> (Pulumi: pulumi cancel). Never force-unlock while a deploy is genuinely running.
Symptom: two environments share one state object.
Cause: the same key/stack name reused across envs. Fix: namespace the S3 key per environment (or use one Pulumi stack each) and migrate as shown in Migrating IaC State Between Backends.
Symptom: secrets visible in plaintext state.
Cause: encryption disabled or secrets passed as plain config. Fix: enable SSE-KMS/CMEK on the bucket and use pulumi config set --secret or CDKTF TerraformVariable(sensitive=True).
Frequently Asked Questions
Do Pulumi and CDKTF use the same state format?
No. Pulumi serializes its own checkpoint format to a Pulumi backend, while CDKTF produces standard Terraform .tfstate managed by the Terraform binary. Both support remote storage, locking, and encryption, but the files are not interchangeable.
Can I keep state in Git? No. State contains resource IDs and often secrets, mutates on every run, and has no locking in Git — concurrent commits would corrupt it. Use a locked remote object store instead.
How many environments should share a backend bucket?
A single bucket is fine; isolation comes from a distinct key/prefix (or stack) per environment, not a separate bucket. Use IAM and KMS policies to fence prod access.
What happens if the lock table is deleted? Terraform falls back to no locking and warns. Recreate the table immediately; until then, serialize runs manually to avoid concurrent state writes.
Related
- Choosing a State Backend for Python IaC — compare S3+DynamoDB, GCS, Terraform Cloud, and Pulumi Cloud with a decision table.
- How to Migrate IaC State Between Backends — export, import, and cut over without downtime.
- State Backend Configuration for CDKTF — CDKTF-specific backend wiring and remote execution.
- Pulumi Stack Architecture — organizing Pulumi stacks and cross-stack references.