Skip to content
FlowOS Guide
Esc
navigateopen⌘Jpreview
On this page

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 —
    this.exportValue(subnet.subnetId, { name: 'FlowosDevNetworkPublicSubnetBId' })
    — and have the consumer import that fixed name directly (Fn.importValue(...) + the relevant from*Attributes constructor) 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::ImportValue from 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 under infra/.
  • infra/cdk/test/ — one vitest + aws-cdk-lib/assertions suite per stack.

Was this page helpful?