Docs / Preview deployments reference
Guidedeploymill://guides/previews

Preview deployments reference (deploymill)

A preview is a regular deploymill app provisioned from a branch of an existing parent app. It is not a Dokploy "preview deployment" — there is no per-PR container automatically spun up by Dokploy. Previews are agent-driven: the MCP tools (create_preview, redeploy_preview, delete_preview, list_previews) are the lifecycle.

Mental model

  • A preview lives in the same tenant Dokploy project as its parent.
  • It clones the parent's repo source, build type, container port, and env blob at creation time.
  • It diverges in: the branch it deploys from (ref), its own hostname, and any env overrides the agent passes.
  • Identity is (parentApplicationId, ref). Calling create_preview twice with the same pair is idempotent.
  • A 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
  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 when no TTL was set).

Stages: assert ownership of parent → load parent's repo/env/port → verify the branch exists on GitHub → check for an existing preview for (parent, ref) (idempotent return if found) → enforce the active-app quota (the preview bucket when ttlHours is set, the standard bucket otherwise — see Active-app quota below) → find-or-create tenant project → create the preview app → attach its domain → read parent's project.json → (if eligible) fork a Neon DB branch 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

redeploy_preview({ parentApplicationId, ref })

Triggers a fresh build of the preview's branch. Use after pushing new commits.

Delete

delete_preview({ parentApplicationId, ref })

Tears down the preview app and, if the preview had its own forked Neon branch, drops the branch as well. Idempotent: { deleted: false } when no preview exists. Best-effort on the Neon side — failures land in warnings rather than blocking the Dokploy app delete.

Deleting the parent cascades. delete_app on a parent app first tears down every preview spawned from it (the same teardown delete_preview does — Dokploy app + forked Neon branch), 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 every preview app for the given parent (any branch), with applicationId, name, ref, status, URL, and — when a TTL was set — ttlHours, createdAt, and expiresAt (all null for previews with no TTL).

Hostnames

Two modes, controlled by the server's WILDCARD_DOMAIN_BASE:

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

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

env handling

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

  1. Host-pinned vars the parent sets are auto-rewritten to the preview's own URL. Any of these keys present in the parent's env is set to the preview URL automatically (so origin/sign-in checks pass without a manual fix): BETTER_AUTH_URL, NEXTAUTH_URL, AUTH_URL, PUBLIC_URL, APP_URL, BASE_URL, SITE_URL, ORIGIN, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_BASE_URL, VITE_APP_URL. The response's hostPinnedRewrites array names exactly which keys were rewritten.
  2. envOverrides is merged on top (keys you name win — an explicit value here overrides the auto-rewrite above, and is how you set host-pinned keys not on the list).
  3. The literal token ${PREVIEW_URL} in any override value is substituted with the preview's full URL (scheme included).
  4. PREVIEW_URL is also written as its own env var for code that wants to read it directly.

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 — forked into its own Neon branch by default

When the parent app declares a managed Neon Postgres database (database: { provider: "neon" } in .deploymill/project.json), create_preview forks a Neon branch off the parent's default branch and points the preview's DATABASE_URL at that branch. The fork is copy-on-write — schema and data at the moment of fork come along, and writes diverge from there. Destructive migrations on the preview branch stay off prod data.

What happens:

  • Fork a Neon branch named preview-<ref-slug>-<hash6> from the org's Neon project's default branch.
  • Get a pooled connection URI scoped to the new branch (reusing the parent's database and role — both forked along with the branch).
  • Override DATABASE_URL in the preview's env with that URI before the first deploy.
  • Drop the branch on delete_preview.

The success response includes database: { provider: "neon", action: "branched", branchName, databaseName } when forking happened.

Opting out (share the parent's database)

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

When forking is skipped (and why)

ConditionBehaviordatabase.action / warning reason
Parent has no DATABASE_URLNo DB env at alldatabase: null
Caller's envOverrides already sets DATABASE_URLOverride winsdatabase: null
NEON_API_KEY not configured on the serverClone parent's URLshared / neon_not_configured
Parent's project.json doesn't declare database.provider: "neon" (e.g. external connection string)Clone parent's URLshared / parent_database_not_neon
previews.shareDatabase: true in parent's project.jsonClone parent's URLshared / share_database_opt_in

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

Schema migrations and the fork

The fork copies schema + data at the moment of create_preview. Migrations applied to the parent after the fork do not flow into the preview branch, and vice versa. redeploy_preview reuses the same branch (no re-fork); delete_preview + create_preview gets a fresh fork 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 Neon branch: 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 Neon fork, Docker named volumes aren't copy-on-write — 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 (default 100 GB total; each volume is 20 GB). If provisioning the preview's volumes would push the org over its limit, create_preview fails at the setup_volumes stage with a storage_limit_reached error ({ limitGb, currentGb, requestedGb, perVolumeGb }). Free up quota by dropping unused mounts (reconcile with prune: true) or deleting old previews. Shared-volume previews (shareVolumes: true) are exempt — they reuse existing volumes and add nothing to the org's count.

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 Docker volume with no locking can corrupt data — 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.

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 copy-on-write for Docker named volumes the way there is for Neon branches.

TTL — auto-expiring previews

A preview can be given a lifetime with ttlHours at create_preview time. When set, 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 delete_preview would (Dokploy app + forked Neon branch + per-preview volumes).

  • 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 Dokploy description markers; that was migrated to Postgres in DET-92.)
  • A TTL frees you from the standard app quota. A preview with a ttlHours counts against the org's separate preview quota, not the standard app quota — see Active-app quota below. This is the main reason to set a TTL even when you don't strictly need auto-expiry.
  • Set once at creation. 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.
  • No TTL ⇒ never auto-expires. Omit ttlHours (or use a preview created before TTL enforcement existed) and the sweep leaves it alone. Such previews live until an explicit delete_preview (or a parent delete_app cascade).
  • Max one year (ttlHours ≤ 8760); fractional hours allowed (e.g. 0.5).
  • Who runs the sweep. The deletion is a privileged, cross-tenant operation (it spans every org and uses the server's internal Dokploy/Neon keys), so it is not an MCP tool. It's exposed only as a secret-gated admin route (POST /api/_admin/cleanup-previews, gated by CLEANUP_ADMIN_SECRET) and driven by a separate cron app (pnpm start:scheduler). 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 has a TTL:

  • Preview WITH a ttlHours → the preview quota (maxActivePreviewApps). 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.
  • Preview with NO TTL → the standard app quota (maxActiveApps), the same bucket prod apps use — at-limit it fails with errorCode:"active_app_limit_reached". A no-TTL preview 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 a TTL preview. The same split applies to deploy / start_app of an existing preview (the bucket is chosen from the preview's stored ttlHours). 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: give throwaway PR previews a TTL so they don't eat into the app quota your prod services need.

What NOT to do

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

Troubleshooting

  • create_preview says branch not found → push the branch first.
  • 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) — set that one via envOverrides: { THAT_KEY: "${PREVIEW_URL}" }.
  • Preview URL 404s → check list_previews for the deploy status; if error, inspect the build via Dokploy and call redeploy_preview after pushing a fix.
  • "An app already exists with that name" → there's already a preview for this (parent, ref). Call delete_preview first if you want to start over.
  • preview_app_limit_reached at check_active_app_limit → the org is at its TTL-preview ceiling (maxActivePreviewApps). Delete a preview (or let one expire) and retry. (A preview with no ttlHours hits active_app_limit_reached against the standard app quota instead — stop or delete an app, or give the new preview a TTL so it draws from the preview bucket.)
  • storage_limit_reached at setup_volumes → the org's volume quota would be exceeded. Check how many volumes the org already has (each is 20 GB; default limit is 100 GB). Free quota by deleting unused previews or dropping mounts from .deploymill/project.json and reconciling with prune: true. Alternatively, use shareVolumes: true to reuse the parent's volumes for this preview (writes will touch prod data — use with care).