---
title: AWS dev deployment
description: How the dev environment is provisioned on AWS, and how a code change reaches it.
sidebar:
  label: Deployment
  order: 1
---

FlowOS runs in one real AWS environment today, called `dev`. It exists so the
team can review work outside a laptop — it is not the production topology, and
it is not built to be one.

## Architecture

```mermaid
flowchart TB
  subgraph internet["Internet"]
    browser["Browser"]
    gha["GitHub Actions\n(arm64 CodeBuild runner)"]
  end

  subgraph box["EC2 t4g.medium, arm64 — Docker Compose"]
    caddy["Caddy\n:80 / :443, auto TLS"]
    web["web (Next.js)\n:3000"]
    api["api (NestJS)\n:4000, internal only"]
    docs["docs (blume, static)\n/srv/docs, no process"]
  end

  db[("Lightsail PostgreSQL\npublic, TLS required")]
  ecr[("ECR\nflowos-api, flowos-web")]
  studio["Heizen Studio\nsecrets"]

  browser -->|"app.flowtech-os.heizen.tech"| caddy
  browser -->|"docs.flowtech-os.heizen.tech"| caddy
  caddy --> web
  caddy --> docs
  web --> api
  web -.->|"/docs rewrite, DOCS_SITE_URL"| caddy
  api -->|"DATABASE_URL, sslmode=require&uselibpqcompat=true"| db
  api -.->|"flowos-secrets, at container start"| studio
  web -.->|"flowos-secrets, at container start"| studio
  gha -->|"build + push images"| ecr
  gha -->|"deploy via SSM"| box
  ecr -.->|"docker compose pull"| box
```

- **Compute:** one `t4g.medium` (Graviton, arm64) EC2 instance runs `api` and
  `web` as Docker containers, fronted by [Caddy](https://caddyserver.com/) for
  automatic Let's Encrypt TLS. `api` is never reachable from the internet directly —
  only `web`, the backend-for-frontend, is exposed; `api` is reached at
  `http://api:4000` over the Compose network.
- **Database:** Postgres runs as a separate [Amazon Lightsail](https://aws.amazon.com/lightsail/)
  managed database, not a container on the box — so its data survives
  replacing the instance. It is publicly reachable, protected by TLS in
  transit and a strong generated password rather than a network-level
  restriction (Lightsail databases have no IP allow-list at the
  infrastructure-as-code level). `DATABASE_URL` needs
  `?sslmode=require&uselibpqcompat=true`, not just `sslmode=require` —
  `@prisma/adapter-pg` (the driver `packages/db/src/client.ts` uses, so this
  applies to `apps/api` itself, not just one-off scripts) now treats a bare
  `sslmode=require` as `verify-full`, which fails against Lightsail's
  certificate with `self-signed certificate in certificate chain`.
  `uselibpqcompat=true` restores the older, TLS-in-transit-only behavior this
  environment's security model already assumes. `prisma migrate deploy`
  (the Rust engine, not `adapter-pg`) isn't affected either way — confirmed
  by actually running both against the live database, not just reading the
  driver's changelog.
- **Images:** two [ECR](https://aws.amazon.com/ecr/) repositories hold the
  `api` and `web` images. GitHub Actions, on the org's arm64 CodeBuild runner
  fleet, builds and pushes both on every push to `dev`. Both Dockerfiles build
  natively for arm64 (no `--platform` pin), matching the runner and the box.
- **Secrets:** unchanged from local development — `flowos-secrets` fetches
  everything from Heizen Studio at container start. The box only needs three
  bootstrap keys placed once, manually, during provisioning.
- **No static AWS credentials anywhere.** The EC2 instance authenticates as
  itself via an IAM instance role; GitHub Actions authenticates via GitHub's
  OIDC token exchanged for a short-lived role scoped to this repository's
  `dev` branch.
- **This site itself** is served the same way, as a second Caddy site block
  on the same box — a static blume build (`apps/docs`) at
  `docs.flowtech-os.heizen.tech`, no server, no container beyond Caddy,
  served straight from `/srv/docs` at the domain root. `apps/web`'s own
  `/docs` route proxies here too (`DOCS_SITE_URL`, rewritten without a
  `/docs` prefix since the standalone site has none), so the same content is
  reachable both standalone and inside the signed-in product.

## Provisioning

All of the above — the network, the two ECR repositories, the EC2 instance,
the Lightsail database, and the CI deploy role — is defined as
[AWS CDK](https://aws.amazon.com/cdk/) (TypeScript) in `infra/cdk/`. It is
provisioned once, by a human operator with real AWS access, not by CI:

```sh
cd infra/cdk
npx cdk bootstrap aws://<account-id>/ap-south-1   # once per account/region
AWS_PROFILE=<profile> npx cdk deploy --all --require-approval never \
  -c githubImmutableSubject=<owner>@<owner-id>/<repo>@<repo-id>
```

The CI deploy role's trust condition matches GitHub's OIDC `sub` claim using
its new **immutable subject format** —
`repo:OWNER@OWNER-ID/REPO@REPO-ID:environment:NAME` — because this
repository, created 2026-08-17 (after GitHub's 2026-07-15 cutover), gets
that format automatically, not the classic `repo:OWNER/REPO:environment:NAME`.
Trusting the classic form failed every deploy with `Not authorized to
perform sts:AssumeRoleWithWebIdentity`. Matching `repository_id` and
`environment` as separate claims instead of `sub` was tried next and failed
differently: AWS's IAM API rejects any GitHub OIDC trust policy whose
condition doesn't include a scoped `sub` or `job_workflow_ref` — "which is
not scoped to all" — so at least one of those two claims is mandatory, not
optional, regardless of what else the condition checks. Both failures were
confirmed against real `cdk deploy` runs and GitHub's own OIDC reference, not
assumed from either alone. Find `<owner>`/`<owner-id>`/`<repo>`/`<repo-id>`
with `gh api repos/<org>/<repo> --jq '{owner: .owner.login, owner_id:
.owner.id, repo: .name, repo_id: .id}'`.

## Deploying a change

Once the infrastructure exists, `.github/workflows/deploy.yml` runs on every
push to `dev`:

1. Assume the deploy role via GitHub OIDC — no stored AWS credential.
2. Build and push the `api` and `web` images to ECR.
3. Build this docs site (`blume build`) and package its static output.
4. Upload the Compose file, Caddy config, and the packaged docs site to S3.
5. Run an SSM command on the EC2 instance: pull the new files and images,
   `docker compose up -d`, then restart Caddy. The restart is required, not
   cosmetic — Caddy only reads its config at startup, `docker compose up -d`
   does not recreate a container over a bind-mounted file's contents changing
   (only over a changed service definition), and `caddy reload` was tried
   first and confirmed, against the running admin API, to report success
   while leaving the old routes live. A Caddyfile change with no image or
   Compose-file change would otherwise deploy silently and never take effect.

A push to `dev` is the only way this environment's application code changes.
The infrastructure itself only changes when someone re-runs `cdk deploy`.

## What this is not

This is a **dev** environment for review, not a production one:

- No staging or production tier exists yet.
- No ECS, Fargate, Aurora, or load balancer — the full production topology
  (multi-AZ, Aurora Serverless v2, ECS services) is a separate, larger design
  that this environment does not build toward incrementally so much as
  precede. When it's built, this EC2 box does not become it.
- `apps/mail` and `apps/worker` are not deployed here — they have no consumer
  logic yet. When they do, they're expected to run as separate ECS Fargate
  services alongside this box, not inside it.
- DNS is mapped by hand, not by this infrastructure — a wildcard
  `*.flowtech-os.heizen.tech` A record covers `app.` and `docs.`, and the
  domain's DNS is not hosted in the AWS account this environment runs in.
