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, and a re-call is also the resume path. A preview that is genuinely finished — env written, provisioning record ready, a route of its own — comes back as { alreadyExisted: true, resumed: false } with nothing rebuilt. Anything short of that (a tail that never finished, a preview with no hostname attached) is resumed: the unfinished stages re-run and the result reports resumed: true. That is what makes "re-run create_preview" a real recovery instruction rather than a no-op.
  • An alreadyExisted re-call also replays the provisioning verdict: the database, storage, volumes, hitsProdData and warnings blocks come back exactly as the original create reported them, with outcomeAvailable: true. That is how you recover the answer to "is this preview isolated from prod, and what did my volume seed do?" after the original response was lost to a client timeout. A preview provisioned before that verdict was recorded reports outcomeAvailable: false and omits those blocks (rather than reporting them as null, which would read as "there is no database"), with an outcomeNote pointing at the live reads.
  • 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).

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

Latency: create_preview is start-and-poll

Creating a preview waits out a container build — and, when you pass volumes: { from: "backup" }, a file restore on top of it. That routinely takes longer than an MCP client will keep a request open. So create_preview returns as soon as the slow part is triggered (wait: false, the default), the same contract start_project offers with wait: false and create_backup uses for volumes.

Everything fast and correctness-critical happens before the call returns:

Synchronous (always done when the call returns)Left running in the background
ownership + branch checks, quota + pool gatesthe volume seed (volumes: { from: "backup" })
the preview app itselfthe build/deploy
its own hostname, reservedattaching the route once the build produces an upstream
env clone + rewrites, vault secrets, site-protection inheritance
the isolated database copy, the fresh bucket, the volume mounts
the env write

The ordering is deliberate: the preview's hostname is minted and recorded before anything slow runs, so its url is correct and stable from the first response and it can never end up hostname-less (see Hostnames below for why that state was dangerous).

A pending response looks like:

{
  "ok": true,
  "url": "https://myapp-feature-acme-1a2b3c4d5e.detz.dev",
  "deployStatus": "pending",
  "deployPending": true,
  "provisioning": {
    "status": "pending",
    "remaining": ["seed_volumes_from_backup", "deploy", "attach_domain"],
    "reservedHost": "myapp-feature-acme-1a2b3c4d5e.detz.dev"
  },
  "provisioningPollHint": "Poll list_previews(...)"
}

Poll list_previews({ parentApplicationId, ref }) — pass the ref and the poll returns just that preview — and read its provisioning block (see List below). To get the terminal verdict inline instead, pass wait: true (bounded by timeoutMs, default 8 minutes) — the response then carries the real deployStatus, the volume-seed result under volumes.seededFromBackup, and provisioning.status: "ready". A wait: true call that runs out of time is not a failure: it comes back waitTimedOut: true with the same polling contract, and the preview keeps provisioning.

timeoutMs bounds the server's wait only. Your MCP client enforces its own request timeout — commonly around 60 seconds — and nothing you pass here can raise it, so a long timeoutMs on a client that gives up first does not hold the connection open; it just loses the response (including the waitTimedOut contract). That is why wait: false is the default. If a waited call is severed, re-call create_preview for the same (parentApplicationId, ref): the reply replays the recorded verdict (see the resume bullet under Mental model).

Stage order (everything through save_env is synchronous; the last three run in the background unless wait: true):

assert_ownedcheck_environment_nameload_parentverify_branchcheck_collisioncheck_active_app_limitcheck_pool_limitfind_or_create_tenant_projectcompute_namingcreate_appreserve_hostset_envload_parent_configinherit_protectioninject_secretsbranch_database (or seed_database_from_backup) → provision_storagesetup_volumessave_env —─ the cut ─— seed_volumes_from_backupdeployattach_domain

Recovering an interrupted create

Because the slow tail can outlive the call (or the process), every preview carries a provisioning record you can query. list_previews reports it per preview:

"provisioning": {
  "status": "failed",
  "stage": "deploy",
  "reservedHost": "myapp-feature-acme-1a2b3c4d5e.detz.dev",
  "reservedUrl": "https://myapp-feature-acme-1a2b3c4d5e.detz.dev",
  "errorCode": "preview_deploy_failed",
  "error": "...",
  "startedAt": "2026-08-07T12:00:00.000Z",
  "updatedAt": "2026-08-07T12:03:11.000Z",
  "nextStep": "Provisioning stopped at stage \"deploy\" (preview_deploy_failed). Fix the cause, then re-run create_preview ..."
}
  • status: "pending" — the build (and any volume seed) is still running. Poll again.
  • status: "ready" — deployed and routed.
  • status: "failed"stage and errorCode say where and why. Fix the cause, then re-call create_preview with the same (parentApplicationId, ref) to resume from that stage.
  • provisioning: null — a preview created before provisioning records existed.

reservedUrl is the URL the preview will serve on; url stays honest and is null until the route is actually live.

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 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 })
list_previews({ parentApplicationId, ref: "feature-x" })          // just this branch
list_previews({ parentApplicationId, provisioningStatus: "pending" })
list_previews({ parentApplicationId, limit: 50, offset: 50 })

Returns { previews, count, total, limit, offset, hasMore, nextOffset? }, newest first. Each entry in previews is a preview app for the given parent, with applicationId, name, ref, status, URL, environmentName (set for named environments, null for ephemeral previews), a provisioning block (see Recovering an interrupted create above — this is how you follow a create_preview that returned deployStatus: "pending"), and, for an ephemeral preview, ttlHours, createdAt, and expiresAt (all null for a permanent named environment).

It is a window, not the whole history. count is what came back in this page, total is how many previews match your filters, and hasMore/nextOffset say where the rest are — page with offset. limit defaults to 20 and caps at 100.

Filters, all optional and combinable:

FilterEffect
refOnly the preview built from that exact git ref. This is the right way to poll a preview you just created — pass the same ref you gave create_preview and you get one entry per tick instead of the parent's whole preview history. An unknown ref returns an empty page, not an error.
provisioningStatusOnly previews whose provisioning record is pending, ready, or failed. A preview old enough to have no provisioning record (provisioning: null) is excluded by any value here — omit the filter to see it.
environmentephemeral (TTL previews), named (permanent named environments), or all (default).

Filters are applied before the per-preview status and URL lookups, so narrowing the query makes the call cheaper, not just the response smaller.

Hostnames

Preview hostnames come from the server's WILDCARD_DOMAIN_BASE. It is required: the ingress backend has no auto-host minting, so with WILDCARD_DOMAIN_BASE unset there is no fallback host — domain attach fails with a coded error telling the operator to configure it. There is no HTTP-only auto-host mode.

With it set (e.g. detz.dev), each preview host is derived as <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.

A preview never uses the parent's domains.prod. The preview builds the same repo and branch as its parent, so it carries the same .deploymill/project.json — and that file declares the parent's production hostname. A preview only ever gets the derived host above; the prod host stays on the parent app. Two things follow:

  • reconcile_project skips the whole domains block when the target is a preview (the same way it skips it for a worker) and says so in warnings. Nothing in the file's domains is attached to, or pruned from, a preview. Mounts, database, env, secrets and rollback still reconcile normally. (Reconciling a preview at all takes a deliberate config edit — the file's name is the parent's, and a preview's name is <parent>-<branch-slug>, so reconcile_project normally refuses with config_name_mismatch.)
  • deploy never borrows a hostname for a preview. A preview's own hostname is reserved by create_preview in its fast synchronous half, before anything slow runs, so a converged deploy simply attaches that reserved host — which is how a preview whose create was cut short still ends up correctly routed, with no second call. In the remaining case where no host was ever reserved, the deploy result carries domainAttachRefused: { errorCode: "preview_domain_missing", message, reason } (message and reason are the same text) rather than quietly falling back to the parent's domain; re-run create_preview with the same (parentApplicationId, ref) to fix it.

More generally, a hostname can only be attached to the app that already holds it. Attaching a host that is live on a different app is refused with a coded host_attached_to_other_app (carrying attachedTo, the app that owns it) — or host_taken when the owner is in another organization. To genuinely move a hostname between two of your own apps, detach_domain it from the current owner first, then attach it on the new one.

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 managed database (database: { engine: "postgres" }), 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.

The copy is a clone: a fresh database on the same server, populated from the parent via pg_dump/pg_restore while the parent keeps serving traffic. It's O(data size), so a large database takes a while, but the isolation is complete from the moment it lands.

(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 dmpv_<hash> clone database on the same 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. databaseName is the preview's own database — the dmpv_<hash> clone on internal Postgres — not the parent's, so it is safe to check before running destructive SQL. get_app on the preview reports the same name.

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.

Seeding the copy from a backup instead of live prod

Pass database: { from: "backup", backupId } to create_preview (or declare it once as previews.database in the parent's .deploymill/project.json) to initialize the preview's own isolated database by restoring a chosen backup of the parent rather than cloning live prod. Use it to reproduce a bug against a known-good past snapshot, or to keep prod's newest rows out of a low-trust preview. backupId is a successful postgres backup of the parent from list_backups.

  • Precedence: a per-call database arg wins, then the parent's previews.database, then the legacy previews.shareDatabase (true"shared"), then the default "branch".
  • Paid, on the same entitlement as restore-to-a-new-database. Without it, create_preview fails with errorCode: "upgrade_required" plus the upgradeGuide pointer.
  • DeployMill-operated Postgres only. A parent on a legacy external-vendor database fails with backup_seed_provider_unsupported.
  • The backup is scoped to this org and this parent app: another app's (or org's) backup comes back as backup_not_found, and a backup that isn't a completed success is backup_not_restorable.
  • Failures land at failedAt: "seed_database_from_backup", so an agent can fix the precondition and re-call.
  • On success the result reports database: { provider, action: "seeded", branchName, databaseName, backupId, takenAt } — note action is "seeded", not "branched". The seeded database is still the preview's own isolated database, torn down with the preview exactly like a clone.

The volume analog is volumes: { from: "backup", … } (and previews.volumes in project.json) — see Volumes below.

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 serverReuse parent's URLshared / database_not_configured
Parent is a legacy app whose database is hosted by an external vendor with no per-preview copyReuse parent's URLshared / parent_database_no_branch_support
Parent's project.json doesn't declare a managed DB (e.g. an external connection string set by hand)Reuse parent's URLshared / parent_database_unmanaged
previews.shareDatabase: true in parent's project.jsonReuse 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 or object storage) ended up wired to the parent's prod resource rather than an isolated copy, whether you opted in (shareDatabase/shareStorage) or sharing was unavoidable (an unmanaged or legacy externally-hosted database, 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. Volumes are never a hitsProdData source — previews never share volumes (see Volumes below).

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 flags become no-ops: every preview takes the isolated path (isolated DB copy, fresh bucket) wherever one is feasible, and a warnings entry notes the override. Volumes are unaffected — they are always fresh regardless of this switch. It governs only the opt-in: where sharing is genuinely unavoidable (an unmanaged or legacy externally-hosted database has no per-preview copy), 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: previews never share volumes

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 — and unlike the database and object-storage planes, there is no opt-in to share it. 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, or restore a point-in-time snapshot with volumes: { from: "backup", mounts: [...] } (see below).
  • The success response includes volumes: { action: "fresh", mounts: [{ mountPath, volumeName }] }action is always "fresh".
  • Storage quota is enforced before provisioning, for every preview volume, with no exemption. Each fresh preview volume counts against the org's storage quota (plan-based, per get_account: Explore has no persistent storage, Builder 10 GB, Studio 100 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.

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

Seeding a preview volume from a backup

Pass volumes: { from: "backup", mounts: [{ mountPath, backupId }] } (paid) to initialize a fresh preview volume by restoring a successful volume backup of the parent (list_backupsvolumeBackups) instead of starting empty. Each mountPath must match a mount the parent declares.

The restore is the slowest thing create_preview does, so it runs in the background tail — after the preview's mounts exist, before the deploy, so the app boots over the restored data. Follow it the same way as the build: poll list_previews for provisioning.status, or pass wait: true to get volumes.seededFromBackup inline.

A seed is never silently skipped:

  • Naming a mountPath the parent doesn't declare (or asking for a seed on a parent with no mounts at all) fails with volume_seed_mount_not_found.
  • A failed restore stops provisioning with a coded error (volume_seed_unavailable, volume_backup_not_found, volume_backup_not_restorable, volume_restore_failed) recorded on the provisioning record, so it never comes back as a healthy preview over an empty volume.
  • A restore that finished but left the volume empty — or that couldn't be verified — is refused the same way, with volume_restore_empty. A seed only reports success once the restored files are provably at the mount path, so ok never means an empty disk. Check the backup with download_backup and retry, or pick a different one from list_backups.
  • Once the restore lands, the provisioning record advances to the volumes_seeded_from_backup stage, so list_previews tells you the seed ran even if the create_preview response was lost to a client timeout.

previews.shareVolumes is rejected, not honored

previews.shareVolumes: true in the parent's .deploymill/project.json is not supported — previews never attach the parent's live volumes. If the parent declares mounts and sets this flag, create_preview fails with a coded share_volumes_unsupported error at failedAt: "setup_volumes"not before provisioning starts: the preview app and its hostname already exist by then, so you get the standard { ok: false, failedAt, errorCode, partial } shape over a half-built preview. Remove the flag from the parent's config, then re-call create_preview with the same (parentApplicationId, ref) to resume it (the failure lands before the preview's env is saved, so nothing is left pointing at a stale resource), or delete_app the half-built preview first if you'd rather start clean. If the flag is set but the parent declares no mounts, it's a no-op and create_preview proceeds with a warnings entry telling you to remove it. Need prod-shaped data in a preview volume instead? Seed it from a point-in-time backup with volumes: { from: "backup", mounts: [{ mountPath, backupId }] } (see deploymill://guides/previews backup-seed section) — a safe, isolated restore, not a live share.

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:

  • Organization mode (mode: "organization" — the returned/stored value is still the legacy "workspace"): 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.
  • Schedules. A preview NEVER runs the cron jobs declared in schedules — they're registered against the prod app only, and nothing registers them for a preview, so the platform sends it no ticks and get_app on it always reports schedules: []. The preview's own /_system/tick receiver still works (it inherits DM_SCHEDULE_TICK_SECRET), so to exercise a job on a preview you POST that endpoint yourself — set a tick secret you know via envOverrides, since the inherited value can't be read back. create_preview warns about this when the parent declares schedules. Full recipe: Scheduled jobs → Previews never run your schedules.

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.

One exemption: previews of the onboarding tutorial app count against neither the preview quota nor the GB-hour pool, for as long as that app's own exemption window is open — the tutorial walks you into creating a preview, and that shouldn't spend the single free preview slot it was teaching you to use. Every other preview counts normally.

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: your plan sets the ceiling (Explore 1, Builder 10, Studio 20); on a plan that leaves maxActivePreviewApps unset (Enterprise/custom), it tracks 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. It's not supported — create_preview rejects it with share_volumes_unsupported whenever the parent declares mounts. Remove it from .deploymill/project.json; use volumes: { from: "backup", ... } if you need prod-shaped volume data in a preview.

Troubleshooting

  • create_preview says branch not found → push the branch first, or re-call with createBranchIfMissing: true to have create_preview create it.
  • create_preview came back with deployStatus: "pending" → that is the normal default (wait: false). The preview is still building; poll list_previews and read provisioning.status.
  • create_preview timed out at your client, or you never saw a response → the preview kept provisioning server-side. Call list_previews({ parentApplicationId }) and read that ref's provisioning block for the stage it reached, then re-call create_preview with the same (parentApplicationId, ref) to resume. If it had already finished, that re-call also hands back the database / volumes / warnings verdict the lost response carried (outcomeAvailable: true) — you do not have to reconstruct 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 10 GB, Studio 100 GB, Enterprise 5 TB). Free quota by deleting unused previews or dropping mounts from .deploymill/project.json and reconciling with prune: true.
  • share_volumes_unsupported at setup_volumes → the parent's .deploymill/project.json sets previews.shareVolumes: true and declares mounts. Remove the flag — previews never share volumes; use volumes: { from: "backup", ... } to seed a preview volume from a snapshot instead.