Splitting a CDKTF App into Multiple Stacks
A single TerraformStack is the right default until the day a routine change to a queue makes Terraform refresh four hundred resources and a typo in a security group threatens the database. Splitting is the fix, and it is not free. This guide sits in CDKTF architecture and synthesis under CDKTF workflows and Terraform synthesis, and covers when a split pays for itself, how to wire the pieces back together, and how to relocate a live resource across the new boundary without destroying it.
Context
One TerraformStack produces one cdk.tf.json, one state file, one lock, one plan, one apply. Everything inside shares a failure domain: a provider error while creating a subnet aborts the apply that was also going to update the autoscaling policy. Everything inside also shares a refresh — Terraform reads the live state of every managed resource before it can tell you what a one-line change does.
Four signals say it is time to split. Blast radius: unrelated resources that fail together should not live together. Apply duration: refresh time grows roughly linearly with resource count, and a plan nobody waits for is a plan nobody reads. Change cadence: a VPC that changes twice a year and a task definition that changes twice a day have no business sharing a lock. Permissions: if two groups of resources need different IAM roles to manage, one state file forces the union of both.
Against that, splitting buys you real costs. Values that were plain Python attributes become references resolved through state. Deployment gains an order. Rollback is no longer one operation. Drift can now exist between stacks — a consumer holding an output that the producer has since changed. Split along a seam where the coupling is genuinely thin, not wherever the file happens to get long.
Prerequisites
- Python 3.9+ with
cdktf>=0.20and prebuilt or generated AWS provider bindings - A remote backend both stacks can reach: cross-stack references are read through the producer's state, so a purely local state file makes the pattern awkward
- Terraform 1.5+ on
PATH, and credentials that can runplanagainst both stacks - An existing single-stack program that applies cleanly, plus a current state backup
# CLI: snapshot state before any structural change
terraform -chdir=cdktf.out/stacks/monolith state pull > monolith.backup.tfstate
terraform -chdir=cdktf.out/stacks/monolith state list | wc -l
Implementation
1. Declare several stacks in one app
An App can hold any number of stacks. Each is instantiated with its own id, and that id becomes the directory under cdktf.out/stacks/ and the name you pass to the CLI. Keep the constructor arguments explicit — a stack that reads global configuration out of the environment is a stack you cannot instantiate twice.
# main.py — one app, two stacks, one backend per stack
# CLI: cdktf synth && cdktf list
from dataclasses import dataclass
from constructs import Construct
from cdktf import App, S3Backend, TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.subnet import Subnet
from cdktf_cdktf_provider_aws.security_group import SecurityGroup
@dataclass(frozen=True)
class Env:
name: str
region: str
cidr: str
class NetworkStack(TerraformStack):
"""Rarely changes. Owned by the platform team."""
def __init__(self, scope: Construct, id_: str, *, env: Env) -> None:
super().__init__(scope, id_)
AwsProvider(self, "aws", region=env.region)
S3Backend(
self,
bucket="acme-tfstate",
key=f"{env.name}/network.tfstate",
region=env.region,
dynamodb_table="acme-tfstate-locks",
encrypt=True,
)
# State implication: a distinct key means a distinct state file and a
# distinct lock, so a network apply no longer blocks a service apply.
self.vpc: Vpc = Vpc(self, "main", cidr_block=env.cidr, enable_dns_hostnames=True)
self.private: Subnet = Subnet(
self,
"private-a",
vpc_id=self.vpc.id,
cidr_block="10.30.0.0/20",
availability_zone=f"{env.region}a",
)
TerraformOutput(self, "vpc_id", value=self.vpc.id)
TerraformOutput(self, "private_subnet_id", value=self.private.id)
class ServicesStack(TerraformStack):
"""Changes daily. Owned by the application teams."""
def __init__(self, scope: Construct, id_: str, *, env: Env, vpc_id: str) -> None:
super().__init__(scope, id_)
AwsProvider(self, "aws", region=env.region)
S3Backend(
self,
bucket="acme-tfstate",
key=f"{env.name}/services.tfstate",
region=env.region,
dynamodb_table="acme-tfstate-locks",
encrypt=True,
)
SecurityGroup(self, "api", name="api-ingress", vpc_id=vpc_id)
app = App()
prod = Env(name="prod", region="eu-west-1", cidr="10.30.0.0/16")
network = NetworkStack(app, "network", env=prod)
ServicesStack(app, "services", env=prod, vpc_id=network.vpc.id)
app.synth()
2. Understand what the reference across the boundary costs
Passing network.vpc.id into another stack is legal, and CDKTF handles it — but not by inlining the value. At synth time it detects that the token belongs to a different stack, adds a generated TerraformOutput to the producer, adds a terraform_remote_state data source to the consumer, and records a stack-level dependency. The consumer's JSON ends up holding something like:
{
"data": {
"terraform_remote_state": {
"cross-stack-reference-input-network": {
"backend": "s3",
"config": { "bucket": "acme-tfstate", "key": "prod/network.tfstate",
"region": "eu-west-1" }
}
}
},
"resource": {
"aws_security_group": {
"api": {
"name": "api-ingress",
"vpc_id": "${data.terraform_remote_state.cross-stack-reference-input-network.outputs.cross-stack-output-aws_vpcmainid}"
}
}
}
}
That is convenient and tightly coupled. The consumer now needs read access to the producer's state file, the producer must be applied first, and the output name is generated — refactor the producing resource and the generated name moves with it. The alternative is to declare the seam yourself with an explicit remote state data source and a named output, which is more typing and far more stable:
# services.py — read the boundary explicitly instead of implicitly
# CLI: cdktf synth services
from cdktf import DataTerraformRemoteStateS3
network_state = DataTerraformRemoteStateS3(
self,
"network-state",
bucket="acme-tfstate",
key="prod/network.tfstate",
region="eu-west-1",
)
# Provider note: this reads the producer's state at plan time, so the caller
# needs s3:GetObject on that key — it is a real permission, not a build detail.
subnet_id: str = network_state.get_string("private_subnet_id")
The explicit form makes the contract between the two stacks a named output you control, which means you can deprecate it, keep it while renaming the underlying resource, and grep for its consumers. The implicit form is fine for two stacks owned by one team and unpleasant for eight stacks owned by four.
3. Deploy stacks selectively
cdktf list prints the stack ids in the app. cdktf deploy takes any subset of them and resolves declared dependencies for you; it refuses to deploy a consumer whose producer is neither deployed nor already applied, unless you explicitly opt out of that check.
# CLI: inspect, then deploy in dependency order
cdktf list
cdktf diff services
cdktf deploy network services --auto-approve
# Deploy only the fast-moving stack, trusting that network is already applied
cdktf deploy services --ignore-missing-stack-dependencies
# Everything, when a release genuinely spans the boundary
cdktf deploy '*'
Where the dependency is real but not expressible as a value — a stack that must exist first without exporting anything — declare it directly:
# ordering.py — an ordering constraint with no data flowing across it
# CLI: cdktf deploy '*'
services = ServicesStack(app, "services", env=prod, vpc_id=network.vpc.id)
services.add_dependency(network)
# State implication: no output or data source is created; this only fixes the
# order in which cdktf deploy processes the two state files.
4. Relocate a live resource across the boundary
The security group in the example above belongs with the network, not with the services. Moving it in Python is a two-line edit; moving it in state is the part that decides whether the resource survives. Because the two stacks have separate state files, terraform state mv on its own does not reach across — the resource must leave one state and be adopted by the other.
# CLI: move aws_security_group.api from services into network, no downtime
# 1. Record the real ID and back up both states before touching anything.
terraform -chdir=cdktf.out/stacks/services state pull > services.backup.tfstate
terraform -chdir=cdktf.out/stacks/network state pull > network.backup.tfstate
terraform -chdir=cdktf.out/stacks/services state show aws_security_group.api | grep '^ *id'
# 2. Edit the Python so the resource is declared in NetworkStack, then synth.
cdktf synth
# 3. Forget it in the old stack — this deletes the state entry, not the resource.
terraform -chdir=cdktf.out/stacks/services state rm aws_security_group.api
# 4. Adopt it in the new stack at its new address.
terraform -chdir=cdktf.out/stacks/network import aws_security_group.api sg-0a1b2c3d4e5f6a7b8
# 5. Both plans must now be empty.
terraform -chdir=cdktf.out/stacks/services plan -detailed-exitcode
terraform -chdir=cdktf.out/stacks/network plan -detailed-exitcode
Step 3 is the dangerous one. Between the state rm and a successful import, the resource is unmanaged — if the pipeline runs an apply on the source stack in that window it will not delete the group, but any apply on the destination stack will try to create a second one and fail on the duplicate name. Do the whole sequence with the pipeline paused, and never skip the backups: terraform state push services.backup.tfstate is the only undo you have.
The same move can be expressed inside CDKTF once the resource is in the right stack, using import_from instead of the CLI, which keeps the adoption reviewable in a pull request:
# adopt.py — import the relocated group declaratively
# CLI: cdktf synth && cdktf deploy network
group = SecurityGroup(self, "api", name="api-ingress", vpc_id=self.vpc.id)
group.import_from("sg-0a1b2c3d4e5f6a7b8")
# Provider note: emits a Terraform import block; the plan reads "will be
# imported" rather than "will be created". Remove the call after the apply.
Verification
A split is verified when both stacks plan clean and the resource inventory has not changed. Count before and after — the sum across the new stacks must equal what the single stack held, minus nothing.
# CLI: prove the split moved resources rather than recreating them
for s in network services; do
terraform -chdir=cdktf.out/stacks/$s init -input=false >/dev/null
echo "$s: $(terraform -chdir=cdktf.out/stacks/$s state list | wc -l) resources"
terraform -chdir=cdktf.out/stacks/$s plan -detailed-exitcode -input=false >/dev/null \
&& echo "$s: no changes" || echo "$s: PLAN NOT EMPTY"
done
Pin the boundary in a test as well, so a future refactor that quietly drops an output fails in CI rather than in a consumer's plan.
# tests/test_stack_boundary.py
# CLI: pytest tests/test_stack_boundary.py -q
import json
from typing import Any, Dict
from cdktf import App, Testing
from main import Env, NetworkStack
PROD = Env(name="prod", region="eu-west-1", cidr="10.30.0.0/16")
def test_network_exports_the_documented_outputs() -> None:
app: App = Testing.app()
stack = NetworkStack(app, "network", env=PROD)
synthesized: Dict[str, Any] = json.loads(Testing.synth(stack))
assert set(synthesized["output"]) >= {"vpc_id", "private_subnet_id"}
# State implication: these output names are the published contract other
# stacks read out of this stack's state file.
Gotchas & Edge Cases
A reference in both directions is a hard failure. If the network stack reads anything from the services stack while the services stack reads the VPC id, synthesis reports a cyclic dependency between the two stacks and stops. Break it by moving the shared resource into a third stack, or by passing a plain configuration value instead of a resource attribute.
Remote state reads are plan-time permissions. A consumer plan that cannot read the producer's state object fails with an S3 access error long before it reaches any provider API, and the message names the bucket, not the stack. Grant s3:GetObject on the producer's key to every consumer role.
A missing output surfaces in the consumer. Delete an output that another stack still reads and its plan fails with Unsupported attribute: This object does not have an attribute named "vpc_id". Deprecate outputs the way you deprecate an API: add the new one, migrate consumers, remove the old one in a later change.
Stale reads are real. A remote state data source reads the producer's last applied state. A consumer planned while a producer apply is in flight can act on values that are about to change; order the pipeline rather than running the two in parallel.
Splitting does not reduce total work. Two stacks refresh independently, so the sum of both applies is usually longer than the single apply was. What improves is the duration of the apply you actually run for a given change.
Operational Notes
Give each stack an owner and a pipeline. The whole point of the boundary is that the fast-moving stack deploys on merge while the slow-moving one deploys behind a review — if both run from the same job on every commit you have paid the cost of splitting and kept none of the benefit.
# CLI: CI stage that only plans the stacks whose inputs changed
cdktf synth
for s in $(cdktf list); do
terraform -chdir=cdktf.out/stacks/$s init -input=false >/dev/null
terraform -chdir=cdktf.out/stacks/$s plan -detailed-exitcode -input=false -out=$s.plan
case $? in
0) echo "$s unchanged" ;;
2) echo "$s has changes — attach $s.plan to the review" ;;
*) echo "$s plan failed"; exit 1 ;;
esac
done
Keep the number of stacks small enough to hold in your head. Three or four boundaries drawn along ownership and cadence lines are manageable; twenty stacks with a dense reference graph reproduce the coupling you split to escape, with more moving parts and slower feedback. When in doubt, add a construct rather than a stack — grouping resources inside one state file costs nothing and can be promoted to a stack later using the relocation sequence above.
FAQ
How many stacks should one CDKTF app have?
As many as there are genuine ownership or cadence boundaries, and no more. Most production programs settle on three to five: a long-lived network or account layer, one or two service layers, and something for shared data stores that need their own review path.
Can two stacks in the same app target different regions or accounts?
Yes. Each stack instantiates its own provider, so one can point at eu-west-1 and another at us-east-1, and each can assume a different role. That is one of the cleaner reasons to split, because a single stack with several aliased providers gets confusing quickly.
Do I have to use cross-stack references at all?
No. Passing a value implicitly is the convenient option; publishing a named TerraformOutput and reading it through an explicit remote state data source is the stable one. For anything crossing a team boundary, prefer the explicit form so the contract has a name.
What happens if I run cdktf deploy without naming a stack?
With more than one stack in the app, the CLI will not guess — it asks you to name the stacks or pass '*'. Scripted pipelines should always name them so a new stack added by someone else does not silently join your deployment.
Is it safe to delete a stack outright?
Only after cdktf destroy <stack> has emptied its state and no other stack reads its outputs. Deleting the Python class first orphans both the resources and the state file, and the resources will keep costing money with nothing tracking them.
Related
- Controlling CDKTF Stack and Construct Naming — how the addresses you move between state files are derived in the first place.
- State Backend Configuration for CDKTF — configuring the per-stack backends this split depends on.
- CDKTF Architecture & Synthesis — the parent topic covering synthesis end to end.
- Python Constructs & Modules — the cheaper alternative when you need grouping rather than isolation.