AWS CDK infrastructure
The CDK app in infra/cdk/ — its five stacks, how they depend on each other, and the cross-stack reference trap to know before touching them.
infra/cdk/ is the AWS CDK (TypeScript) app that
provisions everything the dev environment
runs on. One CDK App, five stacks, all in ap-south-1. It is provisioned by
a human operator running cdk deploy with real AWS credentials — never by CI.
The five stacks
| Stack | Owns |
|---|---|
FlowosDevNetwork |
One VPC, two public subnets (ap-south-1a and ap-south-1b), one security group. No NAT gateway. |
FlowosDevRegistry |
Two ECR repositories, flowos-api and flowos-web. Untagged images expire after 14 days. |
FlowosDevCompute |
The EC2 instance, its Elastic IP, the S3 bucket CI drops deploy files into, and its IAM instance role. |
FlowosDevDatabase |
The Lightsail-managed Postgres database — a separate service, not a container on the box. |
FlowosDevCiRole |
The IAM role GitHub Actions assumes via OIDC to push images and trigger a deploy. |
FlowosDevCompute depends on both FlowosDevNetwork (for the VPC and
security group) and FlowosDevRegistry (so its instance role can pull
images). FlowosDevCiRole depends on FlowosDevCompute (it scopes an IAM
policy to the instance’s own ARN) and FlowosDevRegistry. FlowosDevDatabase
has no dependencies — it is a standalone Lightsail resource, not inside the
VPC.
Commands
cd infra/cdk
pnpm synth # cdk synth, all stacks
pnpm diff # cdk diff --all
pnpm cdk-deploy -- --profile <profile> -c githubImmutableSubject=<owner>@<owner-id>/<repo>@<repo-id>
deploy is reserved by pnpm itself (pnpm deploy builds a trimmed deployable
bundle of a workspace package) and would silently shadow a same-named script —
that’s why the deploy script is named cdk-deploy, not deploy.
A single stack can be targeted directly, which matters for the reference trap below:
AWS_PROFILE=<profile> pnpm exec cdk deploy <StackName> --exclusively \
-c githubImmutableSubject=<owner>@<owner-id>/<repo>@<repo-id>
--exclusively deploys only the named stack — its dependency stacks are
still synthesized (so the whole app’s cross-stack reference graph is
recomputed), but not pushed to CloudFormation. That distinction is the whole
reason the trap below is avoidable once you know it’s there.
The cross-stack reference trap
CDK wires a value from one stack into another (vpc.publicSubnets,
compute.instance.instanceId, etc.) through CloudFormation’s own
export/import mechanism — a CfnOutput with an Export in the producing
stack’s template, and an Fn::ImportValue in the consuming stack’s. By
default, CDK only creates that export while some other stack’s current
template actually imports it, and removes it the moment nothing does.
This is invisible right up until you need to move which resource a stack
imports — swap compute-stack.ts from one subnet to another, say. CDK
regenerates the whole app in one synthesis pass, so the new subnet’s export
gets added and the old one gets marked for removal in the same deploy. But
CloudFormation deploys the producing stack (FlowosDevNetwork) before the
consuming one (FlowosDevCompute), so it tries to delete an export the
consumer’s currently-live template still imports — and refuses, with
Cannot update export ... as it is in use by <Stack>. The deploy rolls
back. Retrying doesn’t help; the templates are identical each time.
This happened twice during the 2026-09-14 arm64/Graviton migration: once
moving FlowosDevCompute’s subnet, once on FlowosDevCiRole’s IAM policy
(scoped to compute.instance.instanceId, which changes value on every
instance replacement).
Two fixes, depending on which side of the problem you’re on:
- Switching which resource is referenced (subnet A → subnet B): give the
new resource a stable, explicit export, independent of automatic
consumption —
— and have the consumer import that fixed name directly (this.exportValue(subnet.subnetId, { name: 'FlowosDevNetworkPublicSubnetBId' })Fn.importValue(...)+ the relevantfrom*Attributesconstructor) instead of pulling it off the producer’s live construct. Switching which export you read from then never touches the old export’s lifecycle at all. - The same logical value changing (an instance’s ID after replacement,
where the consumer only ever wants whichever one is current): break the
live token temporarily. Point the consumer at a plain string literal (the
value it currently resolves to) and deploy it — this removes the
Fn::ImportValuefrom its template without changing the resolved value, so CloudFormation’s export-in-use check passes trivially. Deploy the producer (now free to change/replace the resource). Restore the consumer to the live token and deploy it again — this is now a normal update, since nothing was depending on the old value while the producer changed.
Either way, the order is always: deploy the producer additively first
(nothing removed), then switch the consumer over in its own deploy, then —
if anything is left over and unused — clean it up in a third, separate
deploy. Never let removal and re-addition of the same export land in one
cdk deploy that spans both stacks; the ordering CloudFormation deploys them
in guarantees a rollback.
Look it up
- AWS dev deployment — the environment this provisions, and how a code change reaches it.
infra/AGENTS.md— non-negotiables for everything underinfra/.infra/cdk/test/— onevitest+aws-cdk-lib/assertionssuite per stack.