---
title: AWS CDK infrastructure
description: 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.
sidebar:
  label: CDK
  order: 2
---

`infra/cdk/` is the [AWS CDK](https://aws.amazon.com/cdk/) (TypeScript) app that
provisions everything [the dev environment](/docs/engineering/deployment)
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

```sh
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:

```sh
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 —
  ```ts
  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](/docs/engineering/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.
