Browse docs All docs
Docs / Preview deployments reference
Guidedeploymill://guides/previews

Preview deployments

A preview is a regular DeployMill app provisioned from a branch of an existing parent app. It is not an automatic per-PR "preview deployment": there is no container the platform spins up on its own for every PR. Previews are agent-driven: the MCP tools (create_preview, list_previews) plus deploy and delete_app (each addressed by (parentApplicationId, ref) or by the preview's applicationId) are the lifecycle.

Mental model

  • A preview lives in the same tenant project as its parent.
  • It clones the parent's repo source, build type, container port, and env blob at creation time.
  • It diverges in: the branch it deploys from (ref), its own hostname, and any env overrides the agent passes.
  • Identity is (parentApplicationId, ref). Calling create_preview twice with the same pair is idempotent. A re-call against a fully-provisioned preview returns { alreadyExisted: true, resumed: false }. If the first attempt failed partway (e.g. at the DB-branch stage) and left a half-built app, the re-call resumes the unfinished provisioning instead of returning a false success, and reports resumed: true.
  • A preview is identified by markers stamped on its application description: [preview-of:<parentApplicationId>] and [ref:<branch>] (plus the usual [factory-managed] + [factory-tenant:<orgId>] carried via the parent project).

Lifecycle

Create

create_preview({
  parentApplicationId: "<parent-id>",
  ref: "feature/foo",
  prNumber: 42,                              // optional metadata
  ttlHours: 48,                              // optional — auto-delete after 48h
  createBranchIfMissing: true,               // optional — create `ref` if it doesn't exist yet
  createBranchFrom: "main",                  // optional — base for the new branch (default: repo default)
  envOverrides: {                            // optional — common host-pinned vars
    OTHER_HOST_VAR: "${PREVIEW_URL}"         // (BETTER_AUTH_URL, NEXTAUTH_URL, …)
  }                                          // are auto-rewritten; see "env handling"
})

The success response includes ttlHours and expiresAt (the computed deletion instant, or null for a permanent named environment, since every un-named preview always carries a TTL, so expiresAt is non-null there).

By default a ref that doesn't exist on GitHub fails with branch_not_found (push the branch first). Pass createBranchIfMissing: true to have create_preview create the branch (from createBranchFrom or the repo's default branch) before provisioning, so the branch-then-preview flow is a single call (there's no standalone branch-create tool).

Stages: assert ownership of parent → load parent's repo/env/port → verify the branch exists on GitHub (creating it first when createBranchIfMissing is set) → check for an existing preview for (parent, ref) (if found fully provisioned, return idempotently; if found half-provisioned from a prior failed attempt, re-ensure its domain and resume the remaining stages below) → enforce the active-app quota (the preview bucket for every ephemeral preview, i.e. every un-named preview, since they all carry a TTL; the standard bucket only for a permanent named environment; see Active-app quota below) → find-or-create tenant project → create the preview app → attach its domain → read parent's project.json → (if eligible) give the preview its own isolated DB copy (Neon branch or internal-postgres clone) and override DATABASE_URL → enforce org storage quota for fresh volumes → provision per-preview volumes for declared mounts → write the merged env → deploy.

Partial-failure return shape mirrors start_project: { ok: false, failedAt, partial }.

Re-deploy

deploy({ parentApplicationId, ref })
# or, equivalently, by the preview's own applicationId:
deploy({ applicationId })

Triggers a fresh build of the preview's branch. Call deploy on the preview. Use after pushing new commits. You address it either by (parentApplicationId, ref) (no need to resolve the preview's id) or by the preview's applicationId (from create_preview / list_previews). If no live preview exists for that (parent, ref) (e.g. it self-reclaimed on TTL), deploy returns a routine { previewMissing: true, ref, reason } (not an error). Branch on it and call create_preview to recreate it. Deploying a preview also backfills the parent's site-protection (see below).

Delete

delete_app({ parentApplicationId, ref })

Delete a preview with delete_app, addressing it by (parentApplicationId, ref) or by the preview's own applicationId. Tears down the preview app and, if the preview had its own isolated DB copy (Neon branch or internal-postgres clone) and per-preview bucket, drops those as well (reported under database/storage). Idempotent: a (parentApplicationId, ref) with no live preview returns { deleted: false, ref, reason }. Best-effort on the database/storage side: failures land in warnings rather than blocking the app delete. Deleting a preview requires the preview.delete capability (admin/owner by default).

Deleting the parent cascades. delete_app on a parent app first tears down every preview spawned from it (the same teardown a preview-targeted delete_app does: the app + its isolated DB copy + bucket), so deleting a parent never leaves orphaned previews behind. The deleted previews are listed in delete_app's previews field. Per-preview teardown is best-effort, with failures surfaced in warnings.

List

list_previews({ parentApplicationId })

Returns { previews, count }. Each entry in previews is a preview app for the given parent (any branch), with applicationId, name, ref, status, URL, environmentName (set for named environments, null for ephemeral previews), and, for an ephemeral preview, ttlHours, createdAt, and expiresAt (all null for a permanent named environment).

Hostnames

Two modes, controlled by the server's WILDCARD_DOMAIN_BASE:

  • Unset: the platform mints an auto-generated host (HTTP only). Each preview gets its own URL but DNS is platform-managed.
  • Set (e.g. detz.dev) derives <parent-name>-<ref-slug>-<orgSlug>-<hash10>.<base>:
    • <ref-slug> is the branch name lowercased and DNS-label-sanitized, capped at 30 chars.
    • <hash10> is the first 10 hex chars of sha256(parentApplicationId + ":" + ref): deterministic so re-creating the same preview lands on the same host, but unguessable so URLs can't be enumerated from a branch name.
    • <orgSlug> keeps preview hostnames in the same shape as prod (<app>-<orgSlug>.<base>).

HTTPS via Let's Encrypt; requires matching wildcard DNS pointed at the host.

Env handling

create_preview does a full read of the parent's env blob and uses it as the preview's starting env. Then:

  1. Host-pinned vars the parent sets are auto-rewritten to the preview's own URL. Any of these keys present in the parent's env is set to the preview URL automatically (so origin/sign-in checks pass without a manual fix): BETTER_AUTH_URL, NEXTAUTH_URL, AUTH_URL, PUBLIC_URL, APP_URL, BASE_URL, SITE_URL, ORIGIN, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_BASE_URL, VITE_APP_URL. The response's hostPinnedRewrites array names exactly which keys were rewritten.
  2. envOverrides is merged on top (keys you name win: an explicit value here overrides the auto-rewrite above, and is how you set host-pinned keys not on the list).
  3. The literal token ${PREVIEW_URL} in any override value is substituted with the preview's full URL (scheme included).
  4. PREVIEW_URL is also written as its own env var for code that wants to read it directly.
  5. Vault-bound secrets the parent declares are resolved and injected. Every secrets binding in the parent's .deploymill/project.json (the same bindings reconcile_project resolves for prod) is read from the org vault and written into the preview's env, but only for keys the cloned env doesn't already carry (an explicit value already in the parent's env blob or in your envOverrides always wins, and is left untouched). The response's secretsInjected array names the keys filled from the vault (values are never returned); a declared secret missing from the vault is reported as a warning, and the preview boots without it. This is what lets a preview boot as a faithful copy even when the parent app's secrets live out-of-band. The clearest case is DeployMill dogfooding itself, where the control plane's infra config (DATABASE_URL, SECRETS_ENC_KEY, object-storage keys, …) comes from the platform's deployment environment and never lands in the stored env blob. Declare those keys under secrets in project.json and put the values in the org vault with request_secret.

Because of step 1, the common host-pinned vars (Better Auth's BETTER_AUTH_URL, NextAuth's NEXTAUTH_URL, PUBLIC_URL, etc.) no longer need an explicit envOverrides entry or a post-creation set_env_vars. They just work. Pass envOverrides only to override the auto-rewrite or to set a host-pinned key that isn't auto-detected.

After creation, env changes go through the regular set_env_vars / delete_env_vars flow against the preview's applicationId. There is no separate "preview env" surface.

Database: its own isolated copy by default

When the parent app declares a branch-capable managed database, create_preview gives the preview its own isolated copy of that database and points the preview's DATABASE_URL at it, so destructive migrations on the preview stay off prod data. Two selectable backends are branch-capable, and the mechanism differs:

  • Neon (database: { engine: "postgres", provider: "neon" }) forks a copy-on-write branch off the parent's default branch. The fork is instant and near-zero cost. Schema and data at the moment of fork come along, and writes diverge from there.
  • Internal postgres (database: { engine: "postgres", provider: "deploymill" }, the house default) has no copy-on-write fork, so it clones the parent's database: a fresh database on the same server, populated from the parent via pg_dump/pg_restore while the parent keeps serving traffic. The copy is O(data size), so it's slower than a Neon branch for large databases, but the isolation is the same.

(A file-on-volume SQLite backend is implemented behind the seam with the same isolation behavior, a per-preview .db file copy, but is currently disabled as a selectable backend.)

What happens:

  • Create the isolated copy, keyed preview-<ref-slug>-<hash6> (a Neon branch, or a dmpv_<hash> clone database on the internal server), reusing the parent's database and role.
  • Get a connection URI scoped to that copy (a pooled Postgres URI).
  • Override DATABASE_URL in the preview's env with that URI before the first deploy.
  • Drop the copy on delete_app (when the target is a preview) and on the TTL sweep.

The success response includes database: { provider, action: "branched", branchName, databaseName } when an isolated copy was made (provider is "neon" or "postgres").

Opting out (share the parent's database)

Set previews: { shareDatabase: true } in the parent's .deploymill/project.json. The preview will get the parent's DATABASE_URL verbatim, with a warnings entry flagging that destructive migrations will hit prod data. Use only when the workflow guarantees non-destructive operations on previews.

When forking is skipped (and why)

ConditionBehaviordatabase.action / warning reason
Parent has no DATABASE_URLNo DB env at alldatabase: null
Caller's envOverrides already sets DATABASE_URLOverride winsdatabase: null
Managed-database backend not configured on the serverClone parent's URLshared / database_not_configured
Parent declares a managed DB the backend can't branch (e.g. Supabase, no copy-on-write branch)Clone parent's URLshared / parent_database_no_branch_support
Parent's project.json doesn't declare a branch-capable managed DB (e.g. external connection string)Clone parent's URLshared / parent_database_unmanaged
previews.shareDatabase: true in parent's project.jsonClone parent's URLshared / share_database_opt_in

In every "shared" case the preview connects to whatever DB the parent points at, the same risk model as pre-branching. create_preview's warnings array names the reason.

hitsProdData: branch on this, not the warning prose

A successful create_preview always returns a top-level hitsProdData boolean. It is true when any of the preview's data planes (database, object storage, or volumes) ended up wired to the parent's prod resource rather than an isolated copy, whether you opted in (shareDatabase/shareStorage/shareVolumes) or sharing was unavoidable (Supabase/unmanaged DB, object storage not configured). Branch on this flag instead of parsing the warnings strings: if hitsProdData is true, treat the preview as touching production and avoid destructive migrations/seeds/writes. The per-resource action: "shared" fields tell you which plane is shared.

Org kill switch: allowSharedPreviewResources

A workspace admin can disable the opt-in share modes org-wide by setting allowSharedPreviewResources: false in the org's deploy preferences (dashboard → org preferences, or read it back via get_account). When disabled, the per-call database: "shared" arg and the parent project.json previews.shareDatabase/shareStorage/shareVolumes flags become no-ops: every preview takes the isolated path (copy-on-write DB branch, fresh bucket, fresh volumes) wherever one is feasible, and a warnings entry notes the override. It governs only the opt-in: where sharing is genuinely unavoidable (a Supabase/unmanaged DB has no per-preview branch), the preview still shares and hitsProdData stays true, so always check the flag rather than assuming the kill switch guarantees isolation.

Schema migrations and the copy

The isolated copy takes schema + data at the moment of create_preview. Migrations applied to the parent after that do not flow into the preview copy, and vice versa. Redeploying the preview (deploy) reuses the same copy (no re-fork/re-clone); delete_app + create_preview gets a fresh copy off the current parent state.

Volumes: fresh per-preview volume by default

When the parent declares persistent storage (mounts: [{ volumeName, mountPath }] in .deploymill/project.json), create_preview gives the preview its own volume at each mountPath, never the parent's. This is the storage-layer analog of the per-preview database copy: the preview is stateful and works, but its writes can't reach prod data.

What happens:

  • For each declared mount, a volume named <parentVolumeName>-pv-<hash8> is attached to the preview at the same mountPath. <hash8> is the first 8 hex chars of sha256(parentApplicationId + ":" + ref): deterministic, so delete + recreate of the same preview lands on the same volume (data survives a redeploy), but distinct from prod's volume.
  • The volume starts empty (unlike the database copy, volumes aren't cloned, so there's no seed of prod's files). Seed any required fixtures from the app itself on boot.
  • The success response includes volumes: { action: "fresh", mounts: [{ mountPath, volumeName }] }.
  • Storage quota is enforced before provisioning. Each fresh preview volume counts against the org's storage quota (plan-based, per get_account: Explore has no persistent storage, Builder 500 GB, Enterprise 5 TB), by its declared sizeGb (omitted ⇒ 20 GB). If provisioning the preview's volumes would push the org over its limit, create_preview fails at the setup_volumes stage with a storage_limit_reached error ({ limitGb, currentGb, requestedGb, perVolumeGb }). Free up quota by dropping unused mounts (reconcile with prune: true) or deleting old previews. Shared-volume previews (shareVolumes: true) are exempt: they reuse existing volumes and add nothing to the org's total.

Mounts take effect on the preview's first deploy (which create_preview runs for you).

Opting out (share the parent's volumes)

Set previews: { shareVolumes: true } in the parent's .deploymill/project.json. The preview then attaches the parent's actual named volumes (same names) instead of fresh ones, and warnings flags the risk. Two containers writing one volume with no locking can corrupt data, so use only for read-mostly volumes (e.g. a shared, rarely-written asset cache). The response reports volumes: { action: "shared", … }.

If the parent declares no mounts, the preview gets no volumes and volumes is null.

Site protection: inherited from the parent by default

A parent protected with set_app_protection never spawns a publicly reachable preview: create_preview copies the parent's protection onto the preview (a dedicated inherit_protection provisioning stage, fail-closed: a policy-store failure fails the resumable step rather than shipping a public preview), and deploying the preview (deploy) backfills it for previews created before the parent was protected. The result's protection block reports what happened:

  • Workspace mode: the preview gets its own bypassToken, returned exactly once (at creation/backfill; send it as the x-deploymill-protection-bypass header). An idempotent create_preview re-call (alreadyExisted: true) returns the protection block without the token. Rotate via set_app_protection on the preview if it's lost.
  • Basic mode: the preview answers to the parent's username/password. Only the username is echoed.
  • No protection field = the preview is public (unprotected parent, or the parent's project.json opted out with protection: { ..., "previews": false }).
  • An alreadyExisted re-call does not backfill protection onto a pre-protection preview. Deploying the preview (deploy) is the healer.
  • The TTL sweep and delete_app clean up the preview's protection row with the preview.

See deploymill://guides/site-protection for modes, tokens, and caveats.

What's NOT auto-copied to a preview

  • Rollback. Wasted image churn for ephemeral apps. Previews don't get rollbackActive.
  • Volume data. Previews get fresh, empty volumes (see above), not a copy of prod's files. There's no cheap clone for a volume the way there is for the database.

TTL: auto-expiring previews

Every (un-named) preview is ephemeral: it always gets a lifetime at create_preview time. The lifetime resolves in this order: an explicit positive ttlHours argument → your org's configured default preview TTL (if an operator/admin set a positive one) → the 72-hour system default. The preview's age is tracked and a platform-wide scheduled sweep deletes it once created-at + ttlHours is in the past, tearing it down exactly as a preview-targeted delete_app would (the app + its isolated DB copy + per-preview volumes). To make a non-prod app permanent, give it a name instead of dropping the TTL (a named environment). See below.

  • Where it's stored. TTL is persisted on the preview's row in DeployMill's resource-metadata table (ttl_hours + preview_created_at), alongside the parent_application_id / ref columns. (Historically these were stored as backend description markers; that was migrated to Postgres in DET-92.)
  • An ephemeral preview never touches the standard app quota. A preview with a ttlHours (i.e. every un-named preview) counts against the org's separate preview quota, not the standard app quota (see Active-app quota below). So throwaway PR previews never consume a prod app slot.
  • Stamped at creation; re-set later with set_preview_ttl. Re-calling create_preview for the same (parent, ref) is idempotent and does not change an existing preview's TTL or reset its creation time. The idempotent return reports the existing preview's ttlHours / expiresAt. To extend or shorten an existing preview's lifetime, call set_preview_ttl (see below). That's the supported re-TTL path.
  • set_preview_ttl: re-TTL an existing preview (DET-519). Sets a new absolute lifetime in hours, measured from the preview's fixed createdAt (it's "the preview now lives N hours total", not "add N more hours"): a larger ttlHours pushes expiresAt out, a smaller one pulls it in. Ownership-checked. Returns { ok: true, applicationId, ttlHours, expiresAt } on success. Permanence is not reachable here. ttlHours: 0 on an un-named preview is rejected (permanent_preview_requires_name), and calling it on a named/permanent environment returns permanent_environment (there's no TTL to change). Use environmentName at create_preview for indefinite life.
  • Omitting ttlHours does NOT mean permanent. It resolves to the org default, or the 72-hour system default. A preview created without ttlHours gets the org's configured default preview TTL (or 72h if the org set none, or if the org default is 0/null, since an unnamed preview is never permanent), so previews self-reclaim instead of leaking. Permanence is name-driven, not TTL-driven: to make a preview permanent, pass environmentName to create a named environment (it never expires and occupies a standard app-quota slot). An explicit ttlHours: 0 on an un-named preview is rejected with errorCode: "permanent_preview_requires_name". Use environmentName instead. (Previews created before TTL enforcement existed, and pre-taxonomy permanent previews, have no stored TTL and are left alone by the sweep.)
  • Max one year (ttlHours ≤ 8760), whole hours only: set_preview_ttl takes an integer ttlHours (fractional values like 0.5 are rejected). Fractional TTLs are accepted only at create_preview.
  • Who runs the sweep. The deletion is a privileged, cross-tenant operation (it spans every org and uses the server's internal provider keys), so it is not an MCP tool. A single-replica platform clock drives all maintenance on a per-minute cadence via the scheduling dispatcher (POST /api/_admin/run-due-schedules, gated by CLEANUP_ADMIN_SECRET), and preview reaping is one of DeployMill's own scheduled jobs. (The legacy bundled sweep, POST /api/_admin/cleanup-previews, is disabled by default and kept only as break-glass.) As an agent you don't trigger cleanup: you just set ttlHours and trust the platform to reap.

Active-app quota (two buckets)

An org has two independent active-app ceilings (DET-116), and a preview lands in one or the other based on whether it's ephemeral or a named environment:

  • Ephemeral preview (has a TTL) → the preview quota (maxActivePreviewApps). This is every un-named preview: an omitted ttlHours resolves to the org default (or 72h), so it lands here. At-limit, create_preview fails with { ok:false, failedAt:"check_active_app_limit", errorCode:"preview_app_limit_reached" }. Free a slot by deleting a preview (or letting one expire), then retry.
  • Named environment (permanent, has environmentName) → the standard app quota (maxActiveApps), the same bucket prod apps use. At-limit it fails with errorCode:"active_app_limit_reached". A named environment is held indefinitely, so it consumes a standard slot just like a prod app.

The two buckets are counted and enforced separately: a full preview bucket never blocks a prod deploy, and a full prod bucket never blocks an ephemeral preview. The same split applies to deploy / start_app of an existing preview (the bucket is chosen from the preview's stored ttlHours, and an environment has none, so it stays in the standard bucket). list_apps reports both as quota (standard) and previewQuota. Defaults: when an operator hasn't set maxActivePreviewApps, it equals the org's standard quota. Practical upshot: throwaway PR previews are always ephemeral, so they never eat into the app quota your prod services need. Reach for a named environment only when you genuinely want a long-lived staging/qa target.

What NOT to do

  • Don't attach_preview_domain / provision_preview_domains. Those tools were removed. Preview apps use the regular attach_domain path internally.
  • Don't enable isPreviewDeploymentsActive on a DeployMill-managed app. It's not used anymore; if you see it set anywhere it's legacy state.
  • Don't set previews.shareDatabase: true for workflows that run destructive migrations on PRs. It opts you back into the pre-branching shared-DB risk model.
  • Don't set previews.shareVolumes: true for a volume the preview writes to. Two containers writing one volume can corrupt data. Default fresh per-preview volumes are almost always what you want.

Troubleshooting

  • create_preview says branch not found → push the branch first, or re-call with createBranchIfMissing: true to have create_preview create it.
  • Preview deploy comes up but OAuth/sign-in fails with an origin error → the common host-pinned auth vars (BETTER_AUTH_URL, NEXTAUTH_URL, …) are auto-rewritten to the preview URL, so check whether the app reads a different host-pinned key not on the auto-rewrite list (see Env handling). If so, set that one via envOverrides: { THAT_KEY: "${PREVIEW_URL}" }.
  • Preview URL 404s → check list_previews for the deploy status; if error, inspect the build logs with get_logs and redeploy the preview with deploy after pushing a fix.
  • "An app already exists with that name" → there's already a preview for this (parent, ref). Call delete_app (by (parentApplicationId, ref)) first if you want to start over.
  • preview_app_limit_reached at check_active_app_limit → the org is at its ephemeral-preview ceiling (maxActivePreviewApps). Delete a preview (or let one expire) and retry. (A permanent named environment hits active_app_limit_reached against the standard app quota instead, so stop or delete an app to free a standard slot.)
  • permanent_preview_requires_name at validate_input → you passed ttlHours: 0 (permanent) without an environmentName. Un-named previews are always ephemeral; to create a permanent target, pass environmentName (a named environment) or use a positive ttlHours.
  • storage_limit_reached at setup_volumes → the org's storage quota would be exceeded. Each volume counts by its declared sizeGb (omitted ⇒ 20 GB), against a plan-based ceiling, per get_account (Explore has no persistent storage, Builder 500 GB, Enterprise 5 TB). Free quota by deleting unused previews or dropping mounts from .deploymill/project.json and reconciling with prune: true. Alternatively, use shareVolumes: true to reuse the parent's volumes for this preview (writes will touch prod data, so use with care).