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). Callingcreate_previewtwice with the same pair is idempotent, and a re-call is also the resume path. A preview that is genuinely finished — env written, provisioning recordready, 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 reportsresumed: true. That is what makes "re-runcreate_preview" a real recovery instruction rather than a no-op. - An
alreadyExistedre-call also replays the provisioning verdict: thedatabase,storage,volumes,hitsProdDataandwarningsblocks come back exactly as the original create reported them, withoutcomeAvailable: 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 reportsoutcomeAvailable: falseand omits those blocks (rather than reporting them asnull, which would read as "there is no database"), with anoutcomeNotepointing 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 gates | the volume seed (volumes: { from: "backup" }) |
| the preview app itself | the build/deploy |
| its own hostname, reserved | attaching 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_owned → check_environment_name → load_parent → verify_branch → check_collision → check_active_app_limit → check_pool_limit → find_or_create_tenant_project → compute_naming → create_app → reserve_host → set_env → load_parent_config → inherit_protection → inject_secrets → branch_database (or seed_database_from_backup) → provision_storage → setup_volumes → save_env —─ the cut ─— seed_volumes_from_backup → deploy → attach_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"—stageanderrorCodesay where and why. Fix the cause, then re-callcreate_previewwith 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:
| Filter | Effect |
|---|---|
ref | Only 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. |
provisioningStatus | Only 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. |
environment | ephemeral (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 ofsha256(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_projectskips the wholedomainsblock when the target is a preview (the same way it skips it for a worker) and says so inwarnings. Nothing in the file'sdomainsis 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'snameis the parent's, and a preview's name is<parent>-<branch-slug>, soreconcile_projectnormally refuses withconfig_name_mismatch.)deploynever borrows a hostname for a preview. A preview's own hostname is reserved bycreate_previewin 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 carriesdomainAttachRefused: { errorCode: "preview_domain_missing", message, reason }(messageandreasonare the same text) rather than quietly falling back to the parent's domain; re-runcreate_previewwith 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:
- 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'shostPinnedRewritesarray names exactly which keys were rewritten. envOverridesis 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).- The literal token
${PREVIEW_URL}in any override value is substituted with the preview's full URL (scheme included). PREVIEW_URLis also written as its own env var for code that wants to read it directly.- Vault-bound secrets the parent declares are resolved and injected. Every
secretsbinding in the parent's.deploymill/project.json(the same bindingsreconcile_projectresolves 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 yourenvOverridesalways wins, and is left untouched). The response'ssecretsInjectedarray 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 undersecretsinproject.jsonand put the values in the org vault withrequest_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>(admpv_<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_URLin 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
databasearg wins, then the parent'spreviews.database, then the legacypreviews.shareDatabase(true⇒"shared"), then the default"branch". - Paid, on the same entitlement as restore-to-a-new-database. Without it,
create_previewfails witherrorCode: "upgrade_required"plus theupgradeGuidepointer. - 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 isbackup_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 }— noteactionis"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)
| Condition | Behavior | database.action / warning reason |
|---|---|---|
Parent has no DATABASE_URL | No DB env at all | database: null |
Caller's envOverrides already sets DATABASE_URL | Override wins | database: null |
| Managed-database backend not configured on the server | Reuse parent's URL | shared / database_not_configured |
| Parent is a legacy app whose database is hosted by an external vendor with no per-preview copy | Reuse parent's URL | shared / 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 URL | shared / parent_database_unmanaged |
previews.shareDatabase: true in parent's project.json | Reuse parent's URL | shared / 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 samemountPath.<hash8>is the first 8 hex chars ofsha256(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 }] }—actionis 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 declaredsizeGb(omitted ⇒ 20 GB). If provisioning the preview's volumes would push the org over its limit,create_previewfails at thesetup_volumesstage with astorage_limit_reachederror ({ limitGb, currentGb, requestedGb, perVolumeGb }). Free up quota by dropping unused mounts (reconcile withprune: 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_backups → volumeBackups) 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
mountPaththe parent doesn't declare (or asking for a seed on a parent with nomountsat all) fails withvolume_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, sooknever means an empty disk. Check the backup withdownload_backupand retry, or pick a different one fromlist_backups. - Once the restore lands, the provisioning record advances to the
volumes_seeded_from_backupstage, solist_previewstells you the seed ran even if thecreate_previewresponse 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 ownbypassToken, returned exactly once (at creation/backfill; send it as thex-deploymill-protection-bypassheader). An idempotentcreate_previewre-call (alreadyExisted: true) returns theprotectionblock without the token. Rotate viaset_app_protectionon the preview if it's lost. - Basic mode: the preview answers to the parent's username/password. Only the
usernameis echoed. - No
protectionfield = the preview is public (unprotected parent, or the parent's project.json opted out withprotection: { ..., "previews": false }). - An
alreadyExistedre-call does not backfill protection onto a pre-protection preview. Deploying the preview (deploy) is the healer. - The TTL sweep and
delete_appclean 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 andget_appon it always reportsschedules: []. The preview's own/_system/tickreceiver still works (it inheritsDM_SCHEDULE_TICK_SECRET), so to exercise a job on a preview you POST that endpoint yourself — set a tick secret you know viaenvOverrides, since the inherited value can't be read back.create_previewwarns 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 theparent_application_id/refcolumns. (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-callingcreate_previewfor 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'sttlHours/expiresAt. To extend or shorten an existing preview's lifetime, callset_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 fixedcreatedAt(it's "the preview now lives N hours total", not "add N more hours"): a largerttlHourspushesexpiresAtout, a smaller one pulls it in. Ownership-checked. Returns{ ok: true, applicationId, ttlHours, expiresAt }on success. Permanence is not reachable here.ttlHours: 0on an un-named preview is rejected (permanent_preview_requires_name), and calling it on a named/permanent environment returnspermanent_environment(there's no TTL to change). UseenvironmentNameatcreate_previewfor indefinite life.- Omitting
ttlHoursdoes NOT mean permanent. It resolves to the org default, or the 72-hour system default. A preview created withoutttlHoursgets the org's configured default preview TTL (or 72h if the org set none, or if the org default is0/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, passenvironmentNameto create a named environment (it never expires and occupies a standard app-quota slot). An explicitttlHours: 0on an un-named preview is rejected witherrorCode: "permanent_preview_requires_name". UseenvironmentNameinstead. (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_ttltakes an integerttlHours(fractional values like0.5are rejected). Fractional TTLs are accepted only atcreate_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 byCLEANUP_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 setttlHoursand 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 omittedttlHoursresolves to the org default (or 72h), so it lands here. At-limit,create_previewfails 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 witherrorCode:"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 regularattach_domainpath internally. - Don't enable
isPreviewDeploymentsActiveon a DeployMill-managed app. It's not used anymore; if you see it set anywhere it's legacy state. - Don't set
previews.shareDatabase: truefor 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_previewrejects it withshare_volumes_unsupportedwhenever the parent declares mounts. Remove it from.deploymill/project.json; usevolumes: { from: "backup", ... }if you need prod-shaped volume data in a preview.
Troubleshooting
create_previewsays branch not found → push the branch first, or re-call withcreateBranchIfMissing: trueto havecreate_previewcreate it.create_previewcame back withdeployStatus: "pending"→ that is the normal default (wait: false). The preview is still building; polllist_previewsand readprovisioning.status.create_previewtimed out at your client, or you never saw a response → the preview kept provisioning server-side. Calllist_previews({ parentApplicationId })and read that ref'sprovisioningblock for the stage it reached, then re-callcreate_previewwith the same(parentApplicationId, ref)to resume. If it had already finished, that re-call also hands back thedatabase/volumes/warningsverdict 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 viaenvOverrides: { THAT_KEY: "${PREVIEW_URL}" }. - Preview URL 404s → check
list_previewsfor the deploy status; iferror, inspect the build logs withget_logsand redeploy the preview withdeployafter pushing a fix. - "An app already exists with that name" → there's already a preview for this
(parent, ref). Calldelete_app(by(parentApplicationId, ref)) first if you want to start over. preview_app_limit_reachedatcheck_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 hitsactive_app_limit_reachedagainst the standard app quota instead, so stop or delete an app to free a standard slot.)permanent_preview_requires_nameatvalidate_input→ you passedttlHours: 0(permanent) without anenvironmentName. Un-named previews are always ephemeral; to create a permanent target, passenvironmentName(a named environment) or use a positivettlHours.storage_limit_reachedatsetup_volumes→ the org's storage quota would be exceeded. Each volume counts by its declaredsizeGb(omitted ⇒ 20 GB), against a plan-based ceiling, perget_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.jsonand reconciling withprune: true.share_volumes_unsupportedatsetup_volumes→ the parent's.deploymill/project.jsonsetspreviews.shareVolumes: trueand declaresmounts. Remove the flag — previews never share volumes; usevolumes: { from: "backup", ... }to seed a preview volume from a snapshot instead.