State Backend Configuration for CDKTF

Remote state management eliminates local drift and enforces concurrency controls across distributed infrastructure deployments. CDKTF synthesizes Python constructs into Terraform JSON, but the underlying state lifecycle remains governed by Terraform's backend semantics. Engineers must configure remote storage, enforce cryptographic integrity, and isolate credentials before synthesis begins.

Understanding how configuration maps to execution is critical. This page is part of the broader CDKTF Workflows & Terraform Synthesis workflow; review it to align backend initialization with your synthesis pipeline. For the engine-agnostic concepts that underpin every backend decision here — locking, encryption, and per-environment isolation across both Pulumi and Terraform — start with Managing IaC State for Python Projects.

Two guides sit underneath this page and take the two decisions you actually have to make. Configuring an S3 backend with DynamoDB locking in CDKTF walks the self-hosted route end to end: bootstrapping the bucket and lock table from a separate stack, the minimum IAM policy, and what the two DynamoDB items actually contain. Using Terraform Cloud with CDKTF Python projects covers the managed alternative: workspace mapping, TFE_TOKEN handling, remote execution, and rollback through state version history. This page is the layer above both — the mechanics that are true whichever one you pick.

Problem Framing

The backend is the one part of a CDKTF project that your Python code cannot compensate for. A typed props dataclass can reject a bad CIDR, a snapshot test can catch a renamed resource, and mypy can catch a misspelled argument — but none of them can stop two engineers writing serial 42 over each other, and none of them can recover a state document that vanished with a container. Those properties come from the storage layer, and they are either configured before the first apply or retrofitted afterwards under pressure.

What the backend choice actually buys What the backend choice actually buys: comparison across Local tfstate, Remote backend. Property Local tfstate Remote backend Concurrent writes Last writer wins Serialised by a lock History None Versioned objects Survives a CI runner No, deleted with it Yes Secret handling Plaintext on disk SSE-KMS plus IAM
Every column on the right is a property CDKTF cannot give you; it comes from the backend.

There is a second, subtler problem that only shows up once a project has more than one stack. CDKTF makes it trivially easy to instantiate three stacks in a loop, and equally easy to give all three the same key. The synthesis succeeds, terraform init succeeds, and the first apply succeeds. The second stack's apply then reads the first stack's state, concludes that its own resources do not exist and that the other stack's resources are no longer in configuration, and produces a plan that creates everything it owns while destroying everything the sibling owns. Because the key is a string built in Python, this is a code-review problem rather than a Terraform problem, and it is best solved with a test rather than a convention.

The third framing point is that a backend decision is expensive to reverse, but only in one direction. Moving from local state to a remote backend is a supported operation with a built-in prompt. Moving between two remote backends is also supported. What is not recoverable is a state document that was never written anywhere durable, or one that was overwritten by a process that read an older serial. That asymmetry is the reason the recommendation is always the same: configure the remote backend before the first cdktf deploy, not after the estate is large enough to make the question interesting.

Prerequisites

  • Terraform 1.6 or newer on the PATH, and 1.10+ if you intend to use the S3 backend's use_lockfile instead of a DynamoDB table. CDKTF shells out to whatever terraform binary it finds, so the version that matters is the one in the runner image, not the one on your laptop.
  • CDKTF 0.20 or newer with the Python template, which supplies S3Backend, GcsBackend, AzurermBackend, CloudBackend and LocalBackend as importable classes from cdktf.
  • The storage resources already created. Terraform will not bootstrap its own backend: the S3 bucket, the GCS bucket, or the Azure storage container must exist before terraform init runs, which is why the bootstrap usually lives in its own tiny stack with local state, as described in configuring an S3 backend with DynamoDB locking in CDKTF.
  • Credentials that can reach both the backend and the providers. These are frequently different identities: the backend needs s3:GetObject, s3:PutObject, s3:ListBucket and dynamodb:GetItem/PutItem/DeleteItem, while the provider needs whatever the resources require.
  • A decision about isolation before you write the first key. Choosing between one bucket per account, one key prefix per environment, and Terraform workspaces after twenty stacks exist is significantly harder than choosing now — the trade-offs are laid out in choosing a state backend for Python IaC.

Remote State Fundamentals for Python IaC

Local state files introduce severe risks in collaborative environments. They lack atomic locking, audit trails, and encryption at rest. Remote backends centralize state, enforce mutual exclusion during writes, and provide versioned history for rollback operations.

Remote State Fundamentals for Python IaC Remote State Fundamentals for Python IaC: Remote State Fundament with 4 facets. Remote State Fundament Remote State key element Python IaC key element CDKTF key element Terraform key element
Remote State Fundamentals for Python IaC: how Remote State, Python IaC, CDKTF relate in this pattern.

It helps to know exactly what you are protecting. A Terraform state document is JSON with a small, stable envelope: version (the state format, currently 4), terraform_version, a monotonically increasing serial, a lineage UUID that identifies this state's ancestry, an outputs map, and a resources array. Each resource entry carries its provider address (provider["registry.terraform.io/hashicorp/aws"]), a mode of managed or data, a schema_version, and one instances entry per index, each holding the full set of attributes the provider last read back — including computed values you never wrote, such as an RDS endpoint or an autoscaling group ARN.

Two of those fields do most of the safety work. serial increments on every successful write, so a backend that supports conditional writes can reject a save from a process that read an older snapshot. lineage never changes once created, which is how Terraform detects that you are about to overwrite one state's history with an unrelated one — terraform state push refuses that unless you pass -force. Losing either field, which is what happens when someone reconstructs a state file by hand, removes the guard rails that make concurrent operation survivable.

Local state fails on all three axes at once. There is no lock, so two cdktf deploy runs can each read serial 41, each apply, and each write serial 42 — the second write silently discards the first engineer's resources from the record, and the next plan proposes to create resources that already exist. There is no history, so a bad terraform state rm is unrecoverable. And on an ephemeral CI runner the file is deleted with the container, which is the fastest way to orphan an entire estate: the infrastructure exists, but nothing in your repository knows about it any more. Recovering from that means the import work described in importing existing infrastructure, resource by resource.

CDKTF passes backend directives directly to the Terraform binary during cdktf deploy or cdktf diff. The synthesis phase validates schema compatibility before state operations execute. See CDKTF Architecture & Synthesis for pipeline execution boundaries.

# CLI: scaffold a typed project, then confirm which backend the synth output declares
cdktf init --template=python --local=false
cdktf synth
python -c "import json;print(json.load(open('cdktf.out/stacks/network/cdk.tf.json'))['terraform']['backend'])"

Map backend parameters to Python TypedDict structures. This enforces compile-time validation and prevents malformed JSON from reaching the Terraform binary. Always inject credentials via environment variables or secret managers.

How CDKTF Emits and Initialises the Backend

The single most useful mental model is that CDKTF never touches state. Your Python program is a code generator: App.synth() walks the construct tree and writes cdktf.out/stacks/<stack>/cdk.tf.json. Everything after that — reading the remote object, acquiring the lock, computing the plan, writing the new serial — is the Terraform binary running with that directory as its working directory. The backend block is just one more key in the generated JSON.

From a Python program to a locked state write From a Python program to a locked state write: main.py → cdktf CLI → terraform → S3 + DynamoDB. main.py cdktf CLI terraform S3 + DynamoDB app.synth() terraform init read tfstate PutItem lock PutObject state DeleteItem
Synthesis writes cdk.tf.json; the Terraform binary owns every state and lock call after that.

That means a backend can be declared two ways, and both end up in the same place. The typed route uses the constructs shipped in the cdktf package — S3Backend, GcsBackend, AzurermBackend, CloudBackend, RemoteBackend, HttpBackend and LocalBackend — instantiated with the stack as scope. The escape hatch is TerraformStack.add_override("terraform.backend", {...}), which splices raw JSON in and is the only option for backends with no construct, such as oss or consul. Prefer the construct when one exists: it is checked by mypy, and a typo in dynamodb_table fails at synth rather than at terraform init.

# backends.py — one typed factory that returns the right backend for an environment
# CLI: cdktf synth --stack network-prod
from dataclasses import dataclass
from typing import Literal

from cdktf import AzurermBackend, GcsBackend, S3Backend, TerraformStack

Cloud = Literal["aws", "gcp", "azure"]


@dataclass(frozen=True)
class BackendSpec:
    cloud: Cloud
    container: str          # bucket name, or Azure storage container
    prefix: str             # env/region/stack, no leading slash
    region: str
    lock_table: str = ""    # DynamoDB table, AWS only


def attach_backend(stack: TerraformStack, spec: BackendSpec) -> None:
    """Attach exactly one backend to `stack`. Must run before app.synth()."""
    if spec.cloud == "aws":
        # State implication: `key` is the object path; changing it orphans the old
        # state object rather than moving it. Migration is explicit, never implicit.
        S3Backend(
            stack,
            bucket=spec.container,
            key=f"{spec.prefix}/terraform.tfstate",
            region=spec.region,
            dynamodb_table=spec.lock_table,
            encrypt=True,
        )
    elif spec.cloud == "gcp":
        # Provider note: GcsBackend takes a directory `prefix`, not a full object key.
        GcsBackend(stack, bucket=spec.container, prefix=spec.prefix)
    else:
        AzurermBackend(
            stack,
            storage_account_name=spec.container,
            container_name="tfstate",
            key=f"{spec.prefix}.tfstate",
            use_azuread_auth=True,
        )

The backend configuration is deliberately not stored in state. It is recorded in cdktf.out/stacks/<stack>/.terraform/terraform.tfstate — a small local file that caches which backend this working directory was initialised against. When the generated block stops matching that cache, terraform init stops and prints Error: Backend configuration changed, followed by the instruction to re-run with -reconfigure (discard the old association) or -migrate-state (copy the existing state across). Knowing which of those two flags you want is the difference between a clean move and an empty state.

The practical CI consequence is that cdktf.out/ must be treated as build output, not as a cache. It belongs in .gitignore, and a fresh runner will always re-run terraform init because no .terraform directory exists yet. A long-lived self-hosted runner that keeps cdktf.out/ between jobs is the usual source of a surprise Backend configuration changed error after somebody edits a bucket name.

Provider-Specific Backend Configuration Patterns

Cloud providers implement state locking and storage differently. AWS relies on S3 for storage and DynamoDB for conditional writes. GCP uses Cloud Storage with object generation locking. Azure utilizes Blob Storage with lease-based concurrency controls.

Provider Specific Backend Configuration Patterns Provider Specific Backend Configuration Patterns: Provider then Cloud then AWS then DynamoDB then GCP Provider Cloud AWS DynamoDB GCP
Provider Specific Backend Configuration Patterns: the stages run left to right — Provider, Cloud, AWS, DynamoDB, GCP.

The differences matter mainly when a lock refuses to clear, because the error you get back is the provider's, not Terraform's. On AWS the lock is a single DynamoDB item whose partition key is <bucket>/<key>; acquisition is a PutItem with an attribute_not_exists(LockID) condition, so contention surfaces as ConditionalCheckFailedException: The conditional request failed wrapped in Terraform's Error acquiring the state lock. A second item keyed <bucket>/<key>-md5 stores the digest of the last state written, which is what produces the alarming but usually transient message Error: state data in S3 does not have the expected content when S3's read-after-write consistency lags a rewrite.

Lock primitive by backend Lock primitive by backend: comparison across Lock primitive, Failure signal. Backend Lock primitive Failure signal S3 + DynamoDB Conditional PutItem on LockID ConditionalCheckFailedException S3 use_lockfile Conditional write of .tflock PreconditionFailed on the object GCS Generation-match on default.tflock 412 Precondition Failed AzureRM Renewable 60-second blob lease There is already a lease present Terraform Cloud Server-side run queue Run queued behind an active run
Every backend serialises writes, but each one fails with a different provider-level error.

Terraform 1.10 added use_lockfile to the S3 backend, which places a .tflock object next to the state and relies on S3 conditional writes instead of a separate table; from 1.11 the dynamodb_table argument emits Warning: Deprecated Parameter. The construct still accepts both. Migrating is worth scheduling — one fewer resource to bootstrap, one fewer IAM statement — but do it as its own change, with every pipeline drained, because a window where half your runners use the table and half use the lock file is a window with no mutual exclusion at all.

GCS takes a prefix rather than a full object key and derives <prefix>/default.tfstate. Its lock is a default.tflock object written with an x-goog-if-generation-match: 0 precondition, so a contended lock returns HTTP 412 and requires roles/storage.objectAdminobjectCreator alone can write state but cannot delete the lock, which produces a run that appears to hang at the end of a successful apply. AzureRM leases the state blob for 60 seconds and renews it for the duration of the operation; a stale lease reports There is already a lease present, and the lease expires on its own within a minute, so force-unlocking Azure state is rarely the right first move.

Provider bridging introduces state serialization nuances. Custom providers may emit non-standard output schemas that require explicit type mapping during cross-stack references. Consult Terraform Provider Bridging for compatibility matrices.

# backend_config.py
from typing import TypedDict, Optional, Literal
from pydantic import BaseModel, Field, SecretStr
import os

class S3BackendConfig(TypedDict, total=False):
    bucket: str
    key: str
    region: str
    dynamodb_table: str
    encrypt: bool

class BackendCredentials(BaseModel):
    provider: Literal["aws", "gcp", "azure", "tfc"]
    token: Optional[SecretStr] = Field(default=None)

    @classmethod
    def from_env(cls) -> "BackendCredentials":
        return cls(
            provider=os.getenv("TF_BACKEND_PROVIDER", "aws"),
            token=SecretStr(os.getenv("TFE_TOKEN", "")) if os.getenv("TFE_TOKEN") else None,
        )

def resolve_s3_backend() -> S3BackendConfig:
    return {
        "bucket": os.getenv("TF_STATE_BUCKET", "infra-state-prod"),
        "key": os.getenv("TF_STATE_KEY", "cdktf/terraform.tfstate"),
        "region": os.getenv("AWS_DEFAULT_REGION", "us-east-1"),
        "dynamodb_table": os.getenv("TF_LOCK_TABLE", "cdktf-locks"),
        "encrypt": True,
    }

In a CDKTF stack, apply the S3 backend configuration via add_override:

# CLI: cdktf synth --stack production
from constructs import Construct
from cdktf import TerraformStack
from backend_config import resolve_s3_backend

class ProductionStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)
        backend_config = resolve_s3_backend()
        # State implication: overrides are merged into cdk.tf.json verbatim, so a
        # misspelled key reaches `terraform init` instead of failing at synth time.
        self.add_override("terraform.backend", {"s3": backend_config})

Isolating State per Environment and per Stack

A backend is not one decision but three, stacked: which bucket, which key, and which workspace. Getting the layering wrong is what produces the two classic outages — a staging apply that mutates production because both stacks shared a key, and a production apply blocked for twenty minutes because every stack in the estate serialises on one lock row.

Choosing a state key layout Choosing a state key layout: choose among 3 options. How is this CDKTF stackisolated? account One bucket per AWSaccount stack Key prefixenv/region/stack branch TF_WORKSPACE underenv:/
Isolation can come from the bucket, from the key prefix, or from a Terraform workspace.

The outermost boundary should be the account or subscription, not a path prefix. A separate state bucket per AWS account means a compromised staging pipeline cannot read the production state document at all, and no IAM policy mistake can bridge the two. Within a bucket, make the key deterministic and derived, never hand-typed: f"{env}/{region}/{stack_name}/terraform.tfstate" computed in Python guarantees that a new stack cannot collide with an existing one, which is exactly the class of mistake reviewers miss in HCL.

Terraform workspaces are the third, and the one most often misused. With the S3 backend, selecting a workspace does not change key — it inserts a prefix, so workspace pr-482 writes to env:/pr-482/<key> while the default workspace writes to <key> unprefixed. That asymmetry is useful for short-lived per-branch environments and actively harmful as a production/staging boundary, because both live in one bucket under one IAM policy. Use workspace_key_prefix to move them out of the default env: namespace if you adopt them.

# main.py — one backend per stack, key derived from the stack identity
# CLI: TF_STATE_BUCKET=tf-state-prod cdktf deploy network-prod
import os
from typing import Final

from cdktf import App
from backends import BackendSpec, attach_backend
from stacks.network import NetworkStack

ENV: Final[str] = os.environ["DEPLOY_ENV"]        # dev | staging | prod
REGION: Final[str] = os.environ.get("AWS_REGION", "eu-west-1")

app = App()
for name in ("network", "data", "platform"):
    stack = NetworkStack(app, f"{name}-{ENV}", region=REGION)
    # State implication: one lock row per stack, so a slow data-tier apply never
    # blocks a networking change. Sharing one key would serialise all three.
    attach_backend(stack, BackendSpec(
        cloud="aws",
        container=os.environ["TF_STATE_BUCKET"],
        prefix=f"{ENV}/{REGION}/{name}",
        region=REGION,
        lock_table=os.environ.get("TF_LOCK_TABLE", "tf-locks-prod"),
    ))
app.synth()

Splitting one CDKTF app into several stacks this way has consequences beyond state; the dependency and ordering rules are covered in splitting a CDKTF app into multiple stacks.

Terraform Cloud & Enterprise Backend Integration

Terraform Cloud (TFC) abstracts storage and locking into managed workspaces. Configuration requires explicit hostname resolution, organization mapping, and workspace tagging. CLI-driven runs synthesize locally but push state remotely. Remote execution shifts compute entirely to TFC runners.

Terraform Cloud & Enterprise Backend Integration Terraform Cloud & Enterprise Backend Integration: TFE_TOKEN then cdktf.json then Terraform Cloud then API then Python TFE_TOKEN cdktf.json Terraform Cloud API Python
Terraform Cloud & Enterprise Backend Integration: the stages run left to right — TFE_TOKEN, cdktf.json, Terraform Cloud, API, Python.

API tokens must follow least-privilege scoping. Use TFE_TOKEN for authentication and restrict permissions to specific workspaces. Never embed plaintext tokens in cdktf.json or Python modules.

The distinction that catches teams out is execution mode, not authentication. A workspace set to local execution uses Terraform Cloud purely as a state store: your runner computes the plan, and every provider credential must be present on that runner. A workspace set to remote execution uploads the configuration directory as a run, and the plan happens on HashiCorp's workers — which means provider credentials must be set as workspace environment variables instead, and anything your Python program reads from the local filesystem at plan time will not be there. Switching a workspace from local to remote without moving the credentials produces a plan that fails deep in provider configuration with NoCredentialProviders: no valid providers in chain.

{
  "language": "python",
  "app": "python src/main.py",
  "terraformProviders": ["hashicorp/aws@~> 6.0"],
  "terraformModules": [],
  "codeMakerOutput": ".gen",
  "projectId": "cdktf-state-backends",
  "context": {
    "stackName": "production-networking"
  }
}

Configure the remote backend in your stack via add_override:

# CLI: TFE_TOKEN=$(pass tfc/token) cdktf deploy production-networking
self.add_override("terraform.backend", {
    "remote": {
        "hostname": "app.terraform.io",
        "organization": "acme-infra",
        "workspaces": {"name": "cdktf-prod-vpc"},
    }
})

Two token mechanisms coexist and are frequently confused. The CDKTF CLI reads TFE_TOKEN; the Terraform binary reads the host-scoped TF_TOKEN_app_terraform_io variable or a credentials block in the CLI configuration file. Setting only one of them gives you a run that authenticates during synthesis and then fails at terraform init with Error: Required token could not be found. In CI, export both from the same secret. A missing or wrong token surfaces as Failed to request discovery document: 401 Unauthorized, and a workspace that does not exist yet surfaces as a resource not found error against the organization — Terraform Cloud will not create the workspace for you unless the workspace block uses tags and auto-creation is enabled for the project.

Enable state encryption at rest and in transit. Validate remote schemas against local stack outputs before deployment. Advanced run strategies and workspace tagging require careful alignment with CI triggers. Reference Using Terraform Cloud with CDKTF Python projects for execution policies.

Type-Safe State Access & Security Boundaries

Cross-stack references in CDKTF rely on cdktf.DataTerraformRemoteState (for generic backends) or provider-specific remote state data sources. Untyped outputs cause runtime AttributeError exceptions during synthesis. Define strict TypedDict or dataclass contracts for expected outputs.

Type Safe State Access & Security Boundaries Type Safe State Access & Security Boundaries: Type Safe State Access with 4 facets. Type Safe State Access AttributeError key element TypedDict key element dataclass key element Safe State key element
Type Safe State Access & Security Boundaries: how AttributeError, TypedDict, dataclass relate in this pattern.
# CLI: cdktf synth --stack platform-prod
from typing import TypedDict, Dict, Any
from dataclasses import dataclass
from cdktf import TerraformStack, DataTerraformRemoteState

class VpcOutputs(TypedDict):
    vpc_id: str
    public_subnet_ids: list[str]
    nat_gateway_ip: str

@dataclass(frozen=True)
class StateAccessConfig:
    workspace: str
    organization: str
    hostname: str = "app.terraform.io"

def fetch_remote_state(
    stack: TerraformStack, config: StateAccessConfig
) -> DataTerraformRemoteState:
    # State implication: this reads the producing stack's ENTIRE state document,
    # not just the outputs you name — scope the reader's credentials accordingly.
    return DataTerraformRemoteState(
        stack,
        "prod_vpc_state",
        backend="remote",
        config={
            "hostname": config.hostname,
            "organization": config.organization,
            "workspaces": {"name": config.workspace},
        },
    )

The values that come back are not Python strings. DataTerraformRemoteState hands you tokens, resolved during synthesis into Terraform interpolation expressions, and the typed accessors are get_string(name), get_number(name) and get_list(name). Calling .upper() or slicing one of them fails with AttributeError: 'str' object has no attribute ... only if you are lucky; more often it silently produces a literal ${data.terraform_remote_state...} inside a resource argument, which reaches the provider as a nonsense string. Wrap the accessors once, in a typed function, and let the rest of the program work with the wrapper.

That reading pattern also carries the sharpest security edge on this page. Remote state access is all-or-nothing: a consumer that reads the network stack's vpc_id is granted the whole state document, including any attribute a provider marked sensitive and stored in plaintext — RDS passwords, generated access keys, private keys from tls_private_key. If the producing stack holds secrets, publish the contract deliberately instead: write the two or three values you intend to share into SSM Parameter Store or Secrets Manager and read those, so the blast radius of a compromised consumer is three parameters and not the entire estate.

Enforce IAM boundaries at the credential level, following the reasoning in enforcing IAM least privilege in Python IaC. Turn on SSE-KMS with a customer-managed key on the state bucket so that reading state requires a kms:Decrypt grant as well as s3:GetObject, attach a bucket policy denying requests where aws:SecureTransport is false, and mask secrets in CI logs using runner-native masking commands. Configure lock_timeout and exponential backoff for concurrent pipeline executions.

Step-by-Step: Migrating a CDKTF Stack from Local to Remote State

Most projects start on local state and move once they get a second engineer. The migration is short, but it is one of the few operations where a mistake is not recoverable by re-running, so do it deliberately.

Local-to-remote state migration Local-to-remote state migration: snapshot local state then add S3Backend then cdktf synth then init -migrate-state then cdktf diff snapshot localstate copy the file add S3Backend typed construct cdktf synth emit cdk.tf.json init-migrate-state copy and confirm cdktf diff expect no changes
The migration is a copy plus a confirmation prompt; the empty diff at the end is the proof.

1. Snapshot the existing state. CDKTF keeps local state at cdktf.out/stacks/<stack>/terraform.tfstate. Copy it somewhere outside the build directory before touching anything, because the next cdktf synth regenerates that directory.

# CLI: take an immutable copy of local state before migrating
cp cdktf.out/stacks/network/terraform.tfstate ./network-$(date +%s).tfstate.bak
terraform -chdir=cdktf.out/stacks/network state list | wc -l

2. Add the backend construct and re-synthesize. Attach S3Backend inside the stack constructor, then run cdktf synth. The generated cdk.tf.json now carries a terraform.backend.s3 block that the previous .terraform cache knows nothing about.

3. Run init with -migrate-state. CDKTF does not expose the flag, so call Terraform directly in the synthesized directory. Terraform prints Initializing the backend... followed by Do you want to copy existing state to the new backend? — answering yes uploads the local document and stamps the digest item. Answering no leaves the remote backend empty and your next apply will propose creating every resource you already own.

# CLI: migrate the local state document into the S3 backend, then confirm
cdktf synth
terraform -chdir=cdktf.out/stacks/network init -migrate-state
aws s3 ls s3://tf-state-prod/prod/eu-west-1/network/

4. Verify by diffing, not by reading. The only acceptable outcome is an empty plan. Run cdktf diff and confirm the summary reads No changes. Your infrastructure matches the configuration. A non-empty diff after a migration means the state did not arrive intact — stop, restore the backup, and investigate rather than applying.

# CLI: the migration is proven by an empty plan and a matching resource count
cdktf diff --stack network
terraform -chdir=cdktf.out/stacks/network state list | wc -l
test ! -f cdktf.out/stacks/network/terraform.tfstate && echo "local state gone"

5. Delete the local copy last. Keep the backup for at least one deploy cycle. Once a second successful apply has written a new serial to S3, the backup is genuinely redundant and should be destroyed rather than left in a home directory. The same sequence, generalised across engines and backends, is written up in migrating IaC state between backends.

CI/CD Pipeline Integration & Testing Boundaries

Ephemeral runners require strict state isolation per pull request. Map TF_WORKSPACE dynamically to branch names or PR IDs. Run cdktf synth to validate configuration, then execute cdktf diff for plan inspection.

CI/CD Pipeline Integration & Testing Boundaries CI/CD Pipeline Integration & Testing Boundaries: TF_WORKSPACE then CD Pipeline then Testing Boundaries then PR IDs TF_WORKSPACE CD Pipeline Testing Boundaries PR IDs
CI/CD Pipeline Integration & Testing Boundaries: the stages run left to right — TF_WORKSPACE, CD Pipeline, Testing Boundaries, PR IDs.

Three settings turn a working local setup into a reliable pipeline. Assume the deploy role through OIDC rather than shipping long-lived keys to the runner, so the credentials expire with the job. Pass a lock timeout — CDKTF forwards nothing, but Terraform honours TF_CLI_ARGS_plan and TF_CLI_ARGS_apply, so exporting TF_CLI_ARGS_apply="-lock-timeout=300s" makes a run wait five minutes for a busy lock instead of failing immediately. And set a concurrency group per stack in the workflow definition so the pipeline queues at the CI layer, where a queued job is visible, rather than at the DynamoDB layer, where it looks like a failure. The workflow-level details are in running CDKTF pipelines in GitHub Actions.

# test_state_backend.py
# CLI: pytest test_state_backend.py -q
import os
import pytest
from unittest.mock import patch
from backend_config import resolve_s3_backend, BackendCredentials

@pytest.fixture
def mock_env():
    with patch.dict(os.environ, {
        "TF_STATE_BUCKET": "test-bucket",
        "TF_STATE_KEY": "test/key.tfstate",
        "AWS_DEFAULT_REGION": "us-west-2",
        "TF_LOCK_TABLE": "test-locks",
    }, clear=True):
        yield

def test_backend_resolution(mock_env) -> None:
    config = resolve_s3_backend()
    assert config["bucket"] == "test-bucket"
    assert config["encrypt"] is True
    assert "dynamodb_table" in config

def test_backend_credentials_from_env() -> None:
    with patch.dict(os.environ, {"TF_BACKEND_PROVIDER": "aws"}, clear=True):
        creds = BackendCredentials.from_env()
        assert creds.provider == "aws"
        assert creds.token is None  # No TFE_TOKEN in env

Implement pytest fixtures with unittest.mock to isolate backend calls during unit testing. Enforce state backup policies before destructive operations. The higher-value test, though, asserts on the synthesized JSON rather than on the helper: read cdk.tf.json after Testing.synth(stack) and assert that terraform.backend.s3.key equals the key you expect for that environment. That catches the failure that actually hurts — two stacks accidentally sharing one state object — before it reaches a runner.

# test_backend_keys.py — no two stacks may share a state key
# CLI: pytest test_backend_keys.py -q
import json
from cdktf import App, Testing

from main import build_app


def test_state_keys_are_unique() -> None:
    app: App = build_app(env="prod", region="eu-west-1")
    keys: list[str] = []
    for stack in app.node.children:
        rendered = json.loads(Testing.synth(stack))
        # State implication: a duplicate key means two stacks overwrite each other.
        keys.append(rendered["terraform"]["backend"]["s3"]["key"])
    assert len(keys) == len(set(keys)), f"duplicate state keys: {keys}"

Verification

A backend is not verified by reading the configuration back. It is verified by observing that the remote object moved, that the lock was taken and released, and that a fresh working directory produces an empty plan. Those three observations are cheap and they distinguish "the JSON contains a backend block" from "state is actually being written where you think it is".

Proving a backend change landed Proving a backend change landed: cdktf synth → terraform init → cdktf diff → read serial → lock released → repeat. cdktf synth terraform init cdktf diff read serial lock released
Verification is a loop: nothing is confirmed until the serial moved and the lock item is gone.

The first check is that the object exists and its serial advanced across an apply. terraform state pull prints the current document to stdout without touching the working directory, so it is safe to run at any time and is the least ambiguous evidence available. If the serial is unchanged after a successful apply, either the apply changed nothing or you are reading a different object than the one being written.

# CLI: confirm the remote object exists, then watch the serial advance
terraform -chdir=cdktf.out/stacks/network state pull | jq '{serial, lineage, resources: (.resources | length)}'
aws s3api head-object --bucket tf-state-prod --key prod/eu-west-1/network/terraform.tfstate \
  --query '{size: ContentLength, sse: ServerSideEncryption, modified: LastModified}'
aws dynamodb scan --table-name tf-locks-prod --query 'Items[].LockID.S'

An empty result from that final scan is the second check: no lock rows means no run is holding the state, so a stuck pipeline is stuck for some other reason. A row that persists after every job has finished is an orphaned lock, and it is the only situation in which terraform force-unlock is the right call.

The third check belongs in CI rather than in a terminal, because it is the one that catches the silent failure. Assert on the synthesized JSON that every stack declares a remote backend and that no two stacks share a key. A stack with no terraform.backend block does not fail — it quietly writes state to a file inside cdktf.out/, which the runner then deletes, and the next run plans to create the entire estate from scratch.

# tools/assert_backends.py — fail the build before a stack can write local state
# CLI: python tools/assert_backends.py
from __future__ import annotations

import json
import sys
from pathlib import Path

ALLOWED: frozenset[str] = frozenset({"s3", "gcs", "azurerm", "remote", "cloud"})


def audit(out_dir: Path = Path("cdktf.out/stacks")) -> int:
    keys: dict[str, str] = {}
    failures: list[str] = []
    for cfg in sorted(out_dir.glob("*/cdk.tf.json")):
        doc = json.loads(cfg.read_text())
        backend: dict[str, dict[str, str]] = doc.get("terraform", {}).get("backend", {})
        stack = cfg.parent.name
        if not backend:
            # State implication: no backend block means state lands in cdktf.out/ and
            # is destroyed with the runner, orphaning every resource it created.
            failures.append(f"{stack}: no backend declared")
            continue
        kind, settings = next(iter(backend.items()))  # exactly one backend per stack
        if kind not in ALLOWED:
            failures.append(f"{stack}: backend {kind!r} is not remote")
        key = settings.get("key") or settings.get("prefix") or stack
        if key in keys:
            failures.append(f"{stack}: shares state key {key!r} with {keys[key]}")
        keys[key] = stack
    for line in failures:
        print(line, file=sys.stderr)
    return 1 if failures else 0


if __name__ == "__main__":
    raise SystemExit(audit())

Run that script in the same CI step as cdktf synth, before any plan. It costs milliseconds, needs no credentials, and it is the only automated guard against the two backend mistakes that are unrecoverable rather than merely annoying.

Troubleshooting

Common Mistakes Common Mistakes: Where it breaks with 4 facets. Where it breaks AttributeError watch this boundary TFE_TOKEN watch this boundary Ignoring Pytho watch this boundary AWS IAM watch this boundary
Common Mistakes: the boundaries where things break and what to check.
  • Hardcoding backend credentials in source control instead of injecting via environment variables or secret managers.
  • Omitting state locking tables, which causes concurrent write corruption during parallel CI/CD runs.
  • Ignoring Python 3.9+ type hints for cross-stack references, triggering runtime AttributeError during synthesis.
  • Using local state in ephemeral CI runners, resulting in permanent state loss and untrackable drift.
  • Failing to scope TFE_TOKEN or AWS IAM roles to specific workspaces, violating least-privilege boundaries.
  • Reusing one state key across environments so a staging apply plans destruction of production resources.
  • Force-unlocking on reflex. terraform force-unlock is correct only after you have confirmed no apply is running; used during a live apply it lets a second writer in on top of the first.

The errors below are the ones worth recognising on sight, because each maps to exactly one cause.

Error text Cause Fix
Error acquiring the state lock ... ConditionalCheckFailedException Another apply holds the DynamoDB lock row, or a crashed run left it behind Wait, or terraform force-unlock <ID> after confirming no run is active
Error: Backend configuration changed The generated backend block no longer matches the cached .terraform association Re-run terraform init -migrate-state or -reconfigure
Error: Failed to get existing workspaces: S3 bucket does not exist Wrong bucket name or wrong region on the backend Correct bucket/region; the bucket must pre-exist
AccessDenied: Access Denied status code: 403 on init Missing s3:ListBucket on the bucket ARN itself Add ListBucket on the bucket, not only object actions
Error: state data in S3 does not have the expected content The stored MD5 digest does not match the fetched object Retry after a minute; if persistent, the digest item is stale
Error: Required token could not be found Terraform Cloud credentials missing for the Terraform binary Export TF_TOKEN_app_terraform_io as well as TFE_TOKEN

Five situations come up often enough to be worth working through in full, because in each one the obvious remedy is the wrong one.

A plan proposes to create everything you already own. The symptom is a first line reading Plan: 47 to add, 0 to change, 0 to destroy on a stack that has been deployed for months. The cause is almost never drift; it is that Terraform read an empty or different state document. Check three things in order: whether terraform state pull | jq '.resources | length' returns 0, whether the key in cdk.tf.json matches what is actually in the bucket, and whether a TF_WORKSPACE value is set that inserts an env:/<name>/ prefix. Do not apply to "fix" it — applying creates duplicate infrastructure and leaves the original resources unmanaged.

Error: Error acquiring the state lock that never clears. The message includes an ID, a Who, a Created timestamp and an Operation. Read them before acting. If Created is minutes old and Who names a running CI job, the correct action is to wait or to raise the lock timeout with TF_CLI_ARGS_apply="-lock-timeout=300s". Only when the named process is provably dead — the job is cancelled, the runner is gone — is terraform -chdir=cdktf.out/stacks/<name> force-unlock <ID> correct. Force-unlocking a live apply lets a second writer in on top of the first and is how a state document ends up describing infrastructure that does not exist.

State disappeared after a CI run that reported success. The stack had no backend block, so Terraform used the implicit local backend and wrote terraform.tfstate inside cdktf.out/, which the runner discarded. The tell is that the job log contains no Initializing the backend... line naming a remote type. There is no recovery step that restores the document; the fix is the audit script in the Verification section, and the remediation for the orphaned resources is a re-import as described in importing existing AWS resources into CDKTF Python.

Error: Failed to load state: unexpected end of JSON input. A truncated or partially written state object, usually from a process killed mid-write against a backend with no locking, or from a hand-edited file pushed back with terraform state push. The fix is object versioning, not repair: aws s3api list-object-versions --bucket <b> --prefix <key> lists prior versions, and copying the last good version back over the current one restores the document. If the bucket has no versioning enabled, enable it now — this is the failure mode it exists for.

Two stacks fight over the same resources after a rename. Renaming a CDKTF stack changes the derived key, so the new name points at an empty object while the old object still holds the real state. Terraform reports Error: Backend configuration changed if the working directory is warm, and silently plans a full create if it is not. The fix is to copy the state to the new key first — terraform -chdir=... init -migrate-state, or a plain aws s3 cp between the two keys followed by init -reconfigure — and only then delete the old object, after one clean cdktf diff proves the new location is authoritative.

Key Takeaways

Remote state configuration is the most consequential infrastructure decision you make when starting a CDKTF project—get it wrong and you face data loss or corruption later. The patterns here (S3 + DynamoDB via add_override or the typed S3Backend construct, derived per-stack keys, isolation at the account boundary, and a migration proven by an empty diff) are battle-tested. Set them up before writing your first resource construct, and assert on the synthesized backend block in tests so the layout cannot drift.

FAQ

How do I enforce Python 3.9+ type safety when reading remote state outputs in CDKTF?

Define TypedDict or @dataclass contracts that mirror the expected output schema, and funnel every read through get_string, get_number or get_list inside one typed helper. Validate the shape during synthesis so a renamed output fails the build rather than reaching the provider as a literal interpolation string. This prevents silent failures when the producing stack changes its outputs.

Use DynamoDB conditional writes for AWS, GCS object generation preconditions for Google Cloud, and Terraform Cloud's native run queue for managed environments. Export TF_CLI_ARGS_apply="-lock-timeout=300s" so runs queue instead of failing, and add a per-stack CI concurrency group so contention is visible in the pipeline UI. One lock row per stack, never one shared across an estate.

Can I migrate from local state to a remote backend without destroying resources?

Yes. Copy cdktf.out/stacks/<stack>/terraform.tfstate somewhere safe, attach the backend construct, run cdktf synth, then terraform -chdir=cdktf.out/stacks/<stack> init -migrate-state and answer yes at the copy prompt. Confirm with cdktf diff: anything other than No changes means the migration did not complete and you should restore the backup.

Should I use dynamodb_table or the newer use_lockfile for S3 state locking?

use_lockfile (Terraform 1.10+) removes the DynamoDB dependency entirely by using an S3 conditional write on a .tflock object, and dynamodb_table now emits a deprecation warning. Move when you can, but flip every runner in one change — a period where some runs use the table and others use the lock file leaves you with no mutual exclusion.

Why does cdktf deploy say the backend changed when I only renamed a stack?

The state key is usually derived from the stack name, so renaming the stack changes the key and therefore the backend block. Terraform compares that against the cached association in .terraform/terraform.tfstate and refuses to continue. Either keep the old key explicitly, or run terraform init -migrate-state to copy the document to the new object path.

How do I securely handle backend credentials in CDKTF Python projects?

Inject them exclusively via os.environ or a runtime secret manager, and prefer OIDC role assumption in CI so nothing long-lived exists on the runner. Encrypt the state bucket with a customer-managed KMS key so decryption is a separate grant from object read, and mask values in CI logs using runner-specific masking commands. Never commit plaintext tokens to cdktf.json or Python source files.