Architecture Overview
The system, end to end
Five services. Two of them (frontend, api-server) are the control
plane — the dashboard you interact with. One (build-engine) is
ephemeral compute that only exists for the duration of a single build.
One (reverse-proxy) is the always-on ingress every deployed app's
traffic passes through. The last "service" isn't a service at all — it's
whatever a user's own project becomes once deployed: either files in S3
or a Lambda function, both entirely outside Dreamer's own runtime.
Why a separate build-engine, and why ECS RunTask specifically
Building an arbitrary user's repo means running npm install and
npm run build for code you didn't write, with a package.json you don't
control, potentially with a malicious postinstall script. That's not
something to run inside api-server's own process — a compromised build
should not have access to api-server's database credentials, its
Redis connection, or any other tenant's data.
ECS RunTask (not a long-running Service) is the specific
mechanism: a single Fargate task per build, with its own isolated
container, its own filesystem, that exits the moment the build finishes.
api-server never runs untrusted code itself — it only ever launches an
isolated task and waits for that task to report back over Redis pub/sub.
See deployments/overview.md for exactly
what build-engine does once it's running.
Why Postgres + Redis are used for different things
- Postgres is the durable source of truth — every
Project,Deployment,User,UserSession, andDeploymentLogrow lives here. Anything that needs to survive a restart, or that another part of the system needs to query later, goes here. - Redis is used for two things that are NOT durable state:
- Pub/sub —
build-enginepublishes log lines and status updates to a channel nameddeployment:{deploymentId};api-serversubscribes and relays them to connected dashboard clients over Socket.IO, AND persists them to Postgres as it goes (seerealtime/log-relay.ts). Redis here is a message bus, not storage — if no one's subscribed when a message publishes, it's gone; the Postgres write is what actually makes the log durable. - A 30-second cache in
reverse-proxy, in front of the Postgres query that resolves a subdomain to a deployment (seereverse-proxy/README.md) — every single request to every deployed app would otherwise cost a Postgres round trip before it could even start proxying.
- Pub/sub —
The full lifecycle of one deployment
This is the thread that ties every other doc together. Follow a single deployment from click to live URL:
- User clicks "Deploy" in the dashboard (or hits
POST /api/deployments).api-server'sdeployment.service.tscreates aDeploymentrow with statusQUEUED, resolves the project's configured (or user-overridden) env vars, and callsdeploymentEngine.launchBuildTask(). launchBuildTask()issues an ECSRunTask— one Fargate task, running thebuild-engineimage, with everything that task needs (repo URL, branch, install/build commands,DEPLOYMENT_TYPE,FRAMEWORK, env vars) passed in as container overrides. TheDeploymentrow transitions toBUILDING.- Inside the task,
build-engine'sscript.jsclones the repo, checks out the right commit, and branches onDEPLOYMENT_TYPE:- STATIC:
npm install→npm run build→ verify the output directory exists → upload every file to S3 under__outputs/{project-slug}/. - DYNAMIC: resolve a Dockerfile (the repo's own, or a generated
one) → build it with Kaniko (no Docker daemon needed — see
deployments/dynamic-deployments.md) → push the image to ECR. Every log line and status change is published to Redis as it happens.
- STATIC:
api-server'slog-relay.tsis subscribed to that Redis channel the whole time — it persists each log line to Postgres and relays it over Socket.IO to anyone with the deployment's detail page open, live.- STATIC finishes here — the last thing
build-enginepublishes is aRUNNINGstatus with the deployment's public URL, andProject.activeDeploymentIdis updated to point at it. DYNAMIC has one more hop:build-engine's job ends at "image pushed to ECR" — it publishes animage_readyevent instead. That event triggershandleImageReady()back inapi-server, which calls the AWS Lambda APIs directly (CreateFunction/UpdateFunctionCode, a Function URL, the resource policy that makes it public) — this happens inapi-server, notbuild-engine, deliberately: the build task's IAM role only ever needed S3/ECR permissions, never Lambda permissions. Once the function reportsActive,api-servertransitions the deployment toRUNNINGitself. - A visitor hits
https://{project-slug}.yourdomain.com.reverse-proxylooks up that subdomain (Postgres, Redis-cached), finds the project'sactiveDeploymentId, checks itstype, and proxies accordingly — straight to the S3 path for STATIC, or to the deployment's Lambda Function URL (with the Host header rewritten) for DYNAMIC.
Every step above is covered in far more depth in its own doc — this page is the map, not the territory.
The state machine
Deployment.status isn't a free-form string — a Postgres trigger
(check_deployment_status_transition, in the schema's migrations)
enforces which transitions are even possible at the database level, not
just in application code. The full set:
QUEUED → BUILDING → UPLOADING* → RUNNING
↘ STARTING† ↗
↘ FAILED
(any of the above) → STOPPED
* UPLOADING is STATIC-only (the S3 upload phase).
† STARTING is DYNAMIC-only — covers the window between "image pushed"
and "Lambda function reports Active."
This matters beyond bookkeeping: it's what makes stopDeployment() safe
to call from any state without special-casing every possible current
status in application code — an invalid transition simply gets rejected
by Postgres itself.