AWS dev deployment
How the dev environment is provisioned on AWS, and how a code change reaches it.
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
- Compute: one
t4g.medium(Graviton, arm64) EC2 instance runsapi,web,mail,worker,unitsandredisas Docker containers, fronted by Caddy for automatic Let’s Encrypt TLS. Caddy serveswebon the app domain andapion its own API domain;webreachesapiathttp://api:4000over the Compose network.mail’s bull-board listens only on the box’s loopback at:4001, reached through an SSM port-forwarding session.mailsyncs the enrolled mailboxes from Microsoft Graph and hands each stored email toworkerover BullMQ onredis; the worker is capped at 1 GB so a large document conversion can only restart the worker, neverapiorweb.unitsconverts units for the worker athttp://units:8005, capped at 512 MB and reachable only inside the Compose network. The box keeps a 2 GB swapfile, which every deploy creates if it is missing. - Mail storage: an S3 bucket holds raw mail, bodies and attachments —
written by
mailandworker, read byapi, all through the instance role. It is retained if the stack is deleted, and admits the web origin for presigned attachment previews. - Logs: every container logs to CloudWatch Logs, one group per service
under
/flowos/dev/, kept for 7 days.docker logson the box still works. - Load: the
flowos-devCloudWatch dashboard shows the box, its containers and its queues — see Watching the load. - First boot: mail sync starts paused, and the worker’s model calls and
its requirement-gathering calls start off. A Developer turns each on from
/debug/mail; a later deploy never pauses a sync again. - Database: Postgres runs as a separate Amazon 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_URLneeds?sslmode=require&uselibpqcompat=true, not justsslmode=require—@prisma/adapter-pg(the driverpackages/db/src/client.tsuses, so this applies toapps/apiitself, not just one-off scripts) now treats a baresslmode=requireasverify-full, which fails against Lightsail’s certificate withself-signed certificate in certificate chain.uselibpqcompat=truerestores the older, TLS-in-transit-only behavior this environment’s security model already assumes.prisma migrate deploy(the Rust engine, notadapter-pg) isn’t affected either way — confirmed by actually running both against the live database, not just reading the driver’s changelog. - Images: five ECR repositories hold the
api,web,mail,workerandunitsimages, and scan each one on push. GitHub Actions, on the org’s arm64 CodeBuild runner fleet, builds and pushes all five on every push todev.api,mailandworkershare oneinfra/node-service.Dockerfile, whose runtime image holds production dependencies in one layer and compiled code in the next, so a code-only change pulls tens of megabytes rather than hundreds.webruns as a Next.js standalone server, andunitsbuilds fromservices/units/server.Dockerfile. Migrations run from theapiimage. Everything builds natively for arm64 (no--platformpin), matching the runner and the box. - Secrets: as in local development,
flowos-secretsfetches every secret from Heizen Studio’sdev-awsenvironment at container start. The box’s own.env, placed once by hand, holds only the Studio bootstrap keys, Caddy’s three domains and the mail bucket’s name; the Compose file sets the Redis and storage wiring.mailneeds the Microsoft Graph app credentials and a bull-board login indev-aws, and the worker needs its model keys before model calls are turned on.infra/AGENTS.mdlists every key and where it lives. - 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-awsenvironment. - This site itself is served the same way, as a second Caddy site block
on the same box — a static blume build (
apps/docs) atdocs.flowtech-os.heizen.tech, no server, no container beyond Caddy, served straight from/srv/docsat the domain root.apps/web’s own/docsredirects here (DOCS_SITE_URL, without the/docsprefix, which the standalone site does not have), so the Guide entry in the product opens this site.
Provisioning
All of the above — the network, the five ECR repositories, the mail bucket, the log groups, the dashboard, the EC2 instance,
the Lightsail database, the CI deploy role and CI’s Turbo cache — is defined as
AWS CDK (TypeScript) in infra/cdk/. It is
provisioned once, by a human operator with real AWS access, not by CI:
cd infra/cdk
npx cdk bootstrap aws://<account-id>/ap-south-1 # once per account/region
AWS_PROFILE=<profile> pnpm cdk-deploy \
-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}'.
CI’s Turbo cache is the flowos-ci-turbo-cache-<account-id> bucket in the
FlowosDevCiRole stack, with two roles on the same immutable subject. Pull
requests (the pull_request context) assume flowos-ci-turbo-cache-read, which
can only read the bucket. Pushes to testing, dev and main assume
flowos-ci-turbo-cache, which can read and write it. So only code already on a
trunk ever writes an entry that a later run replays. Entries expire after 14 days.
Deploying a change
Once the infrastructure exists, .github/workflows/deploy.yml runs on every
push to dev:
- Assume the deploy role via GitHub OIDC — no stored AWS credential.
- Build and push the
api,web,mail,workerandunitsimages to ECR. - Build this docs site (
blume build) and package its static output. - Upload the deploy script, the Compose file, Caddy config and the metrics
publisher to S3, and the
packaged docs site both under
docs/<commit-sha>.tar.gz, kept for 365 days, and as the rootdocs-dist.tar.gzthat older deploy scripts read. - Run
infra/deploy-dev-box.shon the instance over SSM. It makes sure the 2 GB swapfile and the pinned Compose version are in place, stops unless the box’s.envnames the mail bucket, pulls the new files, installs the timer that publishes the box’s load, pulls that release’s docs site and its images, runs migrations, starts Redis and pauses mail sync once per Redis volume, then runsdocker compose up -d --waitand restarts Caddy. The restart is required, not cosmetic — Caddy only reads its config at startup, anddocker compose up -ddoes not recreate a container over a bind-mounted file’s contents changing.
A deploy fails — and the Actions run goes red — when api, web,
mail or units is not healthy within five minutes, or when any service is restarting
or has exited 30 seconds later. The run prints docker compose ps -a and the
last 50 log lines of each failed service. Nothing is rolled back: the new
containers stay up, and api and web do not depend on mail or worker.
cdk deploy goes before the first push that adds a service: the workflow can
only push to repositories CDK has already created, and a container whose log
group does not exist yet starts with its logs dropped.
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.
Rolling back
Each ECR repository keeps its 50 newest tagged images, latest and
buildcache among them, so about the last 48 releases stay available under
their commit SHA. To run one of those again, dispatch the workflow against
dev with that release’s full commit SHA:
gh workflow run deploy.yml --ref dev -f image_tag=<full-commit-sha>
GitHub shows a Run workflow button only when the default branch (main)
carries the workflow, which it does not, so the CLI is the way in.
The run checks every image that commit’s Compose file names exists, skips the
build, uses that commit’s Compose
file, Caddyfile, deploy script and docs site, and skips migrations — the
database stays on the newer schema. That is safe only when every migration
since that release only added to the schema. Only
releases deployed after the deploy script was introduced can be rolled back
to this way. A release deployed before bundles were kept per release runs its
own older deploy script, which reads the root docs-dist.tar.gz — always the
newest docs — so its rollback leaves the docs site as it is. A later release
whose bundle has expired after 365 days does the same.
Watching the load
The flowos-dev dashboard in CloudWatch (ap-south-1) is where to look
before deciding the box is too small, or when mail or requirements seem slow:
| Row | Shows | Read it for |
|---|---|---|
| Box | CPU, CPU burst credits, memory and swap, disk and load | Whether the instance itself is the limit. A t4g slows down hard once its credit balance reaches zero. |
| Containers | Memory and CPU for each service | Which service is using the box. The worker restarts at 1 GB and units at 512 MB. |
| Queues | Jobs waiting, running, delayed and failed for each queue | Whether work is arriving faster than the worker clears it, and whether jobs are failing. |
| Errors | The newest log lines that mention an error, across every service | What went wrong, without opening each log group. |
EC2 reports CPU and credits itself. Everything else is published once a
minute by infra/publish-box-metrics.sh, which a systemd timer on the box
runs: it reads memory and disk from the host, asks Docker for each container,
and counts each queue’s jobs in Redis. A service or queue added later appears
on the dashboard without a change to it. Failed jobs are also listed one by
one on mail’s bull-board.
Nothing alerts yet: the dashboard has to be opened to be read.
Four commands from the repository root reach all of it. They use
AWS_PROFILE, or flowtech-pilot when it is unset:
pnpm box:dashboard # the dashboard's address
pnpm box:logs worker # one service's logs, live: api web mail worker units redis caddy
pnpm box:logs api --since 30m # any `aws logs tail` option passes through
pnpm box:deploy # watch the newest deploy run
pnpm box:queues # tunnel to bull-board at localhost:14001/queues
pnpm box:queues needs the AWS Session Manager plugin
(brew install --cask session-manager-plugin on macOS), and stays open until
it is interrupted. Its local port is 14001 so that it does not collide with a
local mail, which listens on 4001.
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.
- No autoscaling.
mailruns as exactly one container (one Graph delta cursor per mailbox) andworkeras one; ECS Fargate is where the worker scales on queue depth when that is needed. - DNS is mapped by hand, not by this infrastructure — a wildcard
*.flowtech-os.heizen.techA record coversapp.anddocs., and the domain’s DNS is not hosted in the AWS account this environment runs in.