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

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 t3.medium EC2 instance runs api and web as Docker containers, fronted by Caddy 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 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 repositories hold the api and web images. GitHub Actions, on Heizen’s self-hosted runner, builds and pushes both on every push to dev.
  • 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 (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> 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 formatrepo: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.

Was this page helpful?