DreamerDreamer

Deployments: Overview

Every deployment — static or dynamic — runs through the exact same first half of a pipeline, and only splits into two genuinely different runtimes at the very end. This page covers the shared half and the handoff point; the two runtimes each get their own from-scratch deep dive:

Deployment vs. Project

A Project is created once, through the import wizard. A Deployment is created every time you actually build and release that project — one project can have many deployments over its lifetime, each an independent row with its own status, its own logs, its own build output.

Deployment.slug is not derived from anything a user typed — it's generated by random-word-slugs (e.g. fuzzy-cat-42), deliberately independent of Project.slug. Two different identity models, on purpose:

  • Project.slug is the project's public identity — the thing users recognize on the dashboard, and (for STATIC) the literal S3 key prefix. It's meant to be memorable and matches what the project is named.
  • Deployment.slug exists purely as an internal, disambiguating identifier for one specific build — it's never the actual live subdomain a visitor types (that's always {project.slug}.{domain}, see reverse-proxy), so "memorable" isn't a design goal for it at all.

Creating a deployment

POST /api/deployments  { projectId }

createDeploymentInternal does five things, in order:

  1. Ownership checkassertProjectOwnership(projectId, userId). Same 404-not-403 reasoning as everywhere else in this codebase (see projects docs) — a project that exists but belongs to someone else looks identical to one that doesn't exist.
  2. Resolve environment variables — every EnvVariable row scoped to this project and this deployment's target environment (production/preview), decrypted from storage. These get frozen into a DeploymentEnvSnapshot — see below.
  3. Create the Deployment row — status QUEUED, type copied straight from Project.detectedDeploymentType (this is the ONE place that decision gets made per-deployment; it isn't re-detected on every build, it's whatever the project is currently configured as).
  4. Launch the build taskdeploymentEngine.launchBuildTask(job), which issues one ECS RunTask. See architecture overview for why this runs on ECS specifically, not inside api-server itself.
  5. Persist the task ARN, transition to BUILDING.

Why env vars are snapshotted, not read live at build time

DeploymentEnvSnapshot exists because a project's env vars can change between deployments — if a user edits an env var after deployment #12 but before deployment #13, deployment #12's build should still show what it actually ran with, not silently reflect a value that didn't exist yet when it built. Every deployment freezes its own copy at creation time.

What gets sent to the build task

deploymentEngine.launchBuildTask() doesn't just start a container — it passes everything that container needs as ECS container overrides (environment variables on the task, not files, not a shared volume):

DEPLOYMENT_ID, PROJECT_SLUG, GIT_REPOSITORY_URL, BRANCH, GIT_ACCESS_TOKEN*
ROOT_DIRECTORY, INSTALL_COMMAND, BUILD_COMMAND, OUTPUT_DIRECTORY
DEPLOYMENT_TYPE, FRAMEWORK, ECR_REPOSITORY_URI†
+ every resolved user env var

* only present for a private repo — see the private-repo section below. † only meaningful for DEPLOYMENT_TYPE=DYNAMIC, but always sent, same "let the receiving side default an empty string" pattern as every other optional field here.

DEPLOYMENT_TYPE is the one variable that decides everything downstream. build-engine's script.js branches its ENTIRE pipeline on this single value immediately after cloning — see the two deep-dive docs for what each branch actually does.

Private repos

If Project.isPrivate, the owner's GitHub token (stored encrypted — see auth docs) is decrypted and passed as GIT_ACCESS_TOKEN. build-engine's clone-repo.js writes a temporary .netrc file from it before cloning, then scrubs that file immediately after — the token exists on disk for exactly as long as the git clone command needs it, not for the task's whole lifetime.

Realtime logs: how a build's output reaches your browser live

build-engine (inside the ECS task)
    │  publishLog(), publishStatus(), publishCommitInfo(), publishImageReady()
    ▼
Redis channel: deployment:{deploymentId}          ← pub/sub, not storage
    │
    ▼
api-server's log-relay.ts (subscribed the whole time)
    │  1. persists each event to Postgres (DeploymentLog rows, or a
    │     Deployment status/field update)
    │  2. relays it over Socket.IO to the "deployment:{id}" room
    ▼
frontend's deployment detail page (if a socket client has joined that room)

Two things worth understanding about this path:

  • Redis here is a message bus, not a store. If nobody's subscribed when a log line publishes, it's genuinely gone from Redis's perspective — the Postgres write inside log-relay.ts is what actually makes it durable and re-fetchable later (e.g. opening a deployment's detail page hours after it finished still shows the full log).
  • Four distinct event shapes share one channel, disambiguated by a type field: log, status, commit_info, and image_ready (the last one DYNAMIC-only — see dynamic-deployments.md). log-relay.ts branches on this field to decide what to persist and how.

The status state machine

QUEUED → BUILDING → UPLOADING* → RUNNING
                  ↘ STARTING†  ↗
                  ↘ FAILED
        (any status) → STOPPED

* STATIC-only. † DYNAMIC-only.

This isn't just an application-level convention — a Postgres trigger (check_deployment_status_transition) rejects any transition not in this table, at the database layer, regardless of what application code attempts. One consequence worth knowing if you're extending this system: a status can't self-transition (e.g. STARTING → STARTING is invalid) — see the dynamic-deployments doc for a real bug this caught during development.

Stopping a deployment

POST /api/deployments/:id/stop

Two very different cases, both handled by one function:

  • Still building (QUEUED/BUILDING/UPLOADING/STARTING, with a live ecsTaskArn) — deploymentEngine.stopBuildTask(), an ECS StopTask call. Idempotent: calling it on a task that already exited doesn't throw, it just no-ops.
  • Already RUNNING — tears down whatever's actually live: an S3 prefix delete for STATIC, a Lambda function + Function URL delete for DYNAMIC (see each deep-dive doc). Only happens if this deployment is the project's current activeDeploymentId — stopping an old, already-superseded RUNNING row must never touch whatever the project is serving right now.

Redeploying

There's no separate "redeploy" endpoint — hitting POST /api/deployments again for the same project is a redeploy. What makes this safe is that both runtimes share one rule: one live thing per PROJECT, not per deployment.

  • STATIC: every deployment of the same project uploads to the exact same S3 prefix (__outputs/{project.slug}/), overwriting the previous build's files.
  • DYNAMIC: every deployment of the same project updates the exact same Lambda function (dreamer-{project.slug}) rather than creating a new one.

This symmetry is deliberate — it's what let stopDeployment() and the redeploy path stay one function with a type branch, instead of forking into two structurally different implementations.