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 you 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 you recognize on the dashboard, and (for STATIC) the literal object-store 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 check — assertProjectOwnership(projectId, userId). Same uniform-404 reasoning as everywhere else in this codebase (see projects docs) — a project that exists but isn't owned by the authenticated account 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. Enqueue the build — a BullMQ job lands in Redis; that's what decouples "you clicked Deploy" from "a build actually starts," and what gives build-worker its retries.
  5. Transition bookkeeping — the job data persists exactly what the build needs, minus any credential.

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

DeploymentEnvSnapshot exists because a project's env vars can change between deployments — if you edit 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 container

deploymentEngine.launchBuildTask() doesn't just start a container — it passes everything that container needs as environment variables on the docker run (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
REDIS_URL, S3_BUCKET, S3_ENDPOINT_URL, BASE_DOMAIN
+ every resolved user env var

* only present for a private repo — see the private-repo section below.

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.

The resolved build config fields fall back to sane defaults inside build-engine when null (npm ci --legacy-peer-deps, npm run build, dist) — so a project created before per-project build settings shipped keeps building identically, no migration needed.

Private repos

If Project.isPrivate, the operator's stored Personal Access Token is decrypted by build-worker, at dequeue time — never at enqueue time, so the plaintext token never sits in Redis as part of the persisted job payload — and passed as GIT_ACCESS_TOKEN (see auth docs). 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 container's whole lifetime.

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

build-engine (inside its throwaway container)
    │  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 why the boundary between build-engine's phase and api-server's phase matters here.

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 build container) — deploymentEngine.stopBuildTask(), a plain docker rm -f of the build container. Idempotent: calling it on a container that already exited doesn't throw, it just no-ops.
  • Already RUNNING — tears down whatever's actually live: the MinIO output-prefix delete for STATIC, removal of the app container 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 MinIO prefix (__outputs/{project.slug}/), overwriting the previous build's files.
  • DYNAMIC: every deployment of the same project builds to the exact same image tag (dreamer-app:{project.slug}) and swaps the same-named container (dreamer-app-{project.slug}) rather than accumulating new ones.

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.