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. 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 reportsresumed: 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 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.
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 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 viapg_dump/pg_restorewhile 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 admpv_<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_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 (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)
| 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 | Clone parent's URL | shared / database_not_configured |
| Parent declares a managed DB the backend can't branch (e.g. Supabase, no copy-on-write branch) | Clone parent's URL | shared / 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 URL | shared / parent_database_unmanaged |
previews.shareDatabase: true in parent's project.json | Clone 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, 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 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.
- 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 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. 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 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.
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.
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 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: truefor 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_previewsays branch not found → push the branch first, or re-call withcreateBranchIfMissing: trueto havecreate_previewcreate 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 500 GB, Enterprise 5 TB). Free quota by deleting unused previews or dropping mounts from.deploymill/project.jsonand reconciling withprune: true. Alternatively, useshareVolumes: trueto reuse the parent's volumes for this preview (writes will touch prod data, so use with care).