.deploymill/project.json reference
This file lives at .deploymill/project.json in the app's repo. It is the source of truth for everything reconcile_project reconciles to live app state: production domain, named-volume mounts, compute resource limits (CPU/memory), rollback toggle, optional managed Postgres database, optional managed object storage.
Ephemeral preview lifecycle is not in this file: previews are MCP-driven (create_preview / delete_app), not declarative. Two nearby blocks are exceptions worth knowing: previews records the defaults create_preview reads (reconcile never acts on it), and environments declares permanent named environments (staging, qa, …) that reconcile_project does ensure exist.
Capabilities you can add later
A freshly scaffolded project.json is deliberately minimal: just enough to deploy (version, name, workload, port, domains.prod, rollback, health). The optional capabilities below are not pre-stubbed into it, on purpose: each block is an active declaration reconcile_project acts on the moment it appears (it provisions a real database/bucket/volume, attaches a domain, registers a cron), not an inert placeholder, and most are gated to a paid plan, so stubbing empty ones would just create first-run noise. Add the block when you actually need the capability, then reconcile_project. That's where each one lives. This is the menu:
| Capability | Add this block | Then |
|---|---|---|
| Managed Postgres database | "database": { "engine": "postgres", "provider": "deploymill" } | reconcile → follow deploymill://guides/database/<stack> |
| Object storage (S3/R2) | "storage": { "provider": "r2" } | reconcile → follow deploymill://guides/object-storage/<stack> |
| Persistent volumes | "mounts": [{ "volumeName": "data", "mountPath": "/data" }] | reconcile → redeploy. See deploymill://guides/storage |
| Custom domains | "domains": { "prod": "…", "custom": ["www.acme.com"] } | point DNS → reconcile. See deploymill://guides/domains |
| Compute size (CPU/memory) | "resources": { "cpu": 1, "memoryMb": 1024 } | reconcile → redeploy (needs customCompute) |
| Scheduled jobs (cron) | "schedules": [{ "name": "nightly", "cron": "0 2 * * *" }] | reconcile. See deploymill://guides/schedules |
| Named environments (staging/qa) | "environments": { "staging": { "ref": "main" } } | reconcile ensures each exists. See deploymill://guides/named-environments |
| External end-user auth (BYO IdP) | "auth": { "provider": "clerk" } | set the provider's env keys → reconcile. See deploymill://guides/auth |
| Bound secrets | "secrets": ["API_KEY"] | request_secret first → reconcile. See deploymill://guides/secrets |
| Site protection (auth gate) | "protection": { "enabled": true } | reconcile / set_app_protection. See deploymill://guides/site-protection |
| Idle-sleep override | "hibernation": { "enabled": false } | reconcile / set_app_sleep_policy (pins always-on, or tunes the idle window) |
| Stop instead of sleep on idle | "hibernation": { "idleAction": "stop" } | reconcile / set_app_sleep_policy (frees the quota slot on idle, no auto-wake) |
| Preview sharing behavior | "previews": { "enabled": true } | read by create_preview (reconcile ignores it) |
Each field's exact semantics, validation, and plan/apply behavior are in Fields below; the step-by-step add/remove flows are in Common operations. Per-capability quotas/gating: get_account (and deploymill://docs/plans-and-quotas).
Schema (version 2)
{
"version": 2,
"name": "my-app",
"workload": "web",
"port": 8000,
"resources": { "cpu": 1, "memoryMb": 1024 },
"domains": {
"prod": "my-app-acme.detz.dev",
"custom": ["www.acme.com", "acme.com"]
},
"mounts": [
{ "volumeName": "my-app-data", "mountPath": "/data" }
],
"rollback": false,
"health": { "path": "/healthz", "retries": 3, "intervalMs": 3000, "timeoutMs": 5000 },
"gracePeriodSeconds": 600,
"source": { "provider": "github" },
"database": { "engine": "postgres", "provider": "deploymill" },
"storage": { "provider": "r2" },
"protection": { "enabled": false, "username": "user" },
"previews": { "enabled": true, "shareDatabase": false, "shareStorage": false },
"secrets": ["ANTHROPIC_API_KEY", { "name": "SENDGRID_KEY", "as": "EMAIL_KEY" }]
}
A worker (no HTTP edge) sets "workload": "worker" and omits port and the whole domains block: there's no domain to reconcile and no edge to probe. Everything else (mounts, rollback, database, secrets) works the same:
{
"version": 2,
"name": "my-worker",
"workload": "worker",
"mounts": [],
"rollback": false,
"database": { "engine": "postgres", "provider": "deploymill" }
}
Fields
version(2): required. v1 files are rejected with a clear migration message.name(string, 1–80): the user-facing app name.start_projectkeeps this aligned with the deployed app name. Don't rename without re-running reconcile.workload("web"|"worker", default"web"): what kind of app this is."web"is an HTTP server on aportbehind a domain and must declaredomains.prod:reconcile_project/import_repoerror ifworkloadis"web"(or defaulted) anddomains.prodis missing, so a typo can't silently mis-provision."worker"is a long-running background process with no HTTP server (a queue consumer, a scheduler, etc.): omitportand the wholedomainsblock, and reconcile attaches no domain, skips the edge health-probe, anddeployreturnsurl: null. Files written before this field existed default to"web". (Scaffold a worker withstart_project({ stack: "node" | "python", workload: "worker" }).)port(number, 1–65535, optional): the port the container listens on inside its image. This is a compute concern, kept flat rather than nested underdomainsbecause one container serves a single port that any number of domains route to. When set,reconcile_projectsyncs both the app's container port and the prod domain's target port to this value (recreating the domain row if its port drifted). When omitted, DeployMill falls back to whatever port the app row already has (older configs predate this field).import_reporeads it ahead of the stack default. If your prod URL returns a 502, the usual cause is this not matching the Dockerfile'sEXPOSE/ listening port. Set it and reconcile.resources(object, optional): the app's compute size, in provider-neutral units:cpuis a count of CPU cores (fractions allowed:0.5,1,2) andmemoryMbis memory in megabytes (integer). Compute size is platform-managed by default: deploymill derives the size per stack and database tier at reconcile, and thisresourcesblock (and theset_app_resourcesREST/dashboard control) is ignored unless your organization has thecustomComputecapability enabled. While platform-managed, you don't need to set this, since it's sized for you.stack(string, optional): provenance only — an informational hint recording which starter stack the project was scaffolded from (e.g.node,python,static). It does not affect how the app is built (every deploymill app is a Dockerfile build) and, as of DET-1220, it does not affect compute sizing either. It used to pick the platform default compute size, which was wrong in a way that bit quietly: a starter is routinely replaced wholesale on the first push, so a repo scaffolded fromstaticand then filled with a Rust/Go/Java service kept being sized as if it were still nginx. Omitted for legacy configs, and safe to delete from any config.- Declarative & non-destructive, like
protection: omitting the whole block means you are not requesting a size — it does NOT mean the app keeps whatever size it currently has. Compute size is platform-managed, so reconcile still derives the platform default and applies it (you'll seeresources.action: "update"in the plan on the first reconcile of a new app, moving it from unset to the derived tier). The block only overrides that derivation, and only when your org hascustomCompute. When the block is present it's the full source of truth, so an absent field inside it means "use the platform default" for that dimension ("resources": {}resets both to the default). - Defaults & caps: there's no "unbounded" state. An app that never declares a size runs at the platform default (currently 0.5 vCPU / 512 MB). Two separate ceilings then apply, and they behave differently:
- The platform max (operator-set, currently 2 vCPU / 4096 MB).
set_app_resourcesrejects an over-max value outright with a codedcpu_over_max/memory_over_max. - Your plan's per-app memory ceiling (
maxMemoryMbPerApp, read it fromget_account) — this is the one that bites, because it's lower than the platform max on Builder: Builder 1024 MB, Studio 4096 MB, Enterprise 4096 MB. (Explore's 256 MB is unreachable: the free tier has nocustomCompute, so its size is always platform-derived.) There is no per-plan CPU ceiling — CPU is bounded only by the platform max. - The two entry points differ on over-limit memory.
set_app_resourcesrejects it ({ ok: false, errorCode: "memory_over_plan_limit", limitMb, requestedMb }plus the upgrade-gate pointer).reconcile_projectinstead clamps down to your ceiling and says so: the plan reportsplan.resources.memoryClampedFromMbwith the original request, and the app comes up at the ceiling rather than failing the reconcile. So a Builder app whose file asks for 2048 MB deploys at 1024 MB — checkmemoryClampedFromMbrather than assuming the file's number was applied.
- The platform max (operator-set, currently 2 vCPU / 4096 MB).
- By default each app runs as an isolated micro-VM, so these numbers are the VM's size: memory is dedicated (the VM holds its full RAM), while CPU is shared/burstable: the
cpuvalue is a ceiling the app can burst to, not a reserved core, so many mostly-idle web apps pack onto one machine. - Plan & apply:
reconcile_projectreports the diff inplan.resources(current/desiredin neutral units, plus anactionofnone/update/clear) and applies it through the compute primitive. No Docker- or backend-specific concept appears in the config, so a different compute backend maps the same shape onto its own machine size. Applying is best-effort: if the backend rejects the size, the rest of the reconcile still succeeds and the failure is surfaced as a warning. Changes take effect on the next deploy (the orchestrator re-creates the workload), which reconcile flags in itsnote.
- Declarative & non-destructive, like
domains(object, optional): required for a web app (seeworkload). A web app declaresdomains.prod. A worker omits the wholedomainsblock (andport). Reconcile errors ifworkloadis"web"and this block is missing, rather than silently reclassifying the app. A worker still holds an active-app quota slot while it runs.domains.prod(string, required for web apps): the production hostname. Reconcile ensures the app has a domain row with this host pointing atport. A different host already attached is reported as drift (removed only withprune: true). A port mismatch on the prod host is auto-corrected (the row is recreated on the configured port).domains.custom(array of hostnames, optional): additional domains you own (e.g.["www.acme.com", "acme.com"]), on top of the auto-derivedprodhost. Reconcile attaches each with HTTPS (Let's Encrypt). Withprune: true, a custom domain removed from this list is detached. Full custom-domain playbook (both DNS steps, the coded errors, apex vs subdomain, the unproxied requirement):deploymill://guides/domains.- Validation before attach. Reconcile validates every entry: hostname syntax; that it isn't under the platform wildcard domain (that namespace is DeployMill's); that you've proven ownership of it (a one-time DNS TXT record: the first attempt is blocked with
domain_verification_requiredcarrying the exact record to publish, then remembered for your org); and that its DNS points at DeployMill's ingress: a CNAME to the platform domain (or, for an apex likeacme.com, anALIAS/ANAMEat that same hostname — an apex A record matching the platform domain's IP is accepted but discouraged, because it pins an IP that can change). An entry that fails validation is skipped with a warning (surfaced inplan.domains.blockedwith a machine-readablecodesuch asdns_not_pointed, carrying the exact record to create). The rest of the reconcile still applies, so fix DNS and re-run. Point DNS first, then add the host here and reconcile, or the cert won't issue. - TLS is issued for you. DeployMill issues the cert via Let's Encrypt, using an HTTP-01 challenge served from DeployMill's origin. You do not bring your own cert. Because that challenge has to reach the origin, the record must be DNS-only / unproxied: CNAME it straight at the DeployMill ingress host the error reports (or, for an apex, an
ALIAS/ANAMEat that host; a bare A record at that host's IP is accepted but discouraged), not behind your own Cloudflare/CDN proxy. An orange-clouded record intercepts the challenge and no cert issues. Each custom domain gets its own per-host cert under your domain's Let's Encrypt budget, so this scales across tenants. - For a one-off attach outside the file, use
attach_domainwith an explicithost. To remove one,detach_domain, but a host still listed here will be re-added on the next reconcile.
- Validation before attach. Reconcile validates every entry: hostname syntax; that it isn't under the platform wildcard domain (that namespace is DeployMill's); that you've proven ownership of it (a one-time DNS TXT record: the first attempt is blocked with
mounts(array, default[]): named volumes for per-app persistent storage: anything an app writes to disk that must survive a redeploy (user uploads/media, an on-disk cache, a local search/vector index, an embedded datastore). Managed Postgres should go throughdatabaseinstead. Volumes are for filesystem state, not your primary DB. Each entry is{ volumeName, mountPath, sizeGb? }:volumeName: volume name ([a-zA-Z0-9][a-zA-Z0-9_.-]*, max 60 chars).mountPath: absolute path inside the container (/data,/var/lib/uploads, etc.).sizeGb(optional integer): the volume's size in GB, honored as the PVC's storage request. Omitted ⇒ the standard 20 GB. Grow-only: raisesizeGbandreconcile_projectexpands the volume in place (online, data intact): the plan shows it underplan.mounts.expandand the apply patches the volume. The extra GB counts against your org storage quota like any new volume. LoweringsizeGbis rejected (plan.mounts.shrinkRejected, codemount_shrink_unsupported): volumes are never shrunk or recreated, since a recreate would orphan the data. The expansion is online. Reconcile'snoteflags it if a redeploy is still needed.- Quota: your org has a soft total-storage quota that depends on your plan, per
get_account(Explore has no persistent storage, Builder 10 GB, Studio 100 GB, Enterprise 5 TB), counted across all apps and previews and billed by declaredsizeGb(a 50-GB mount uses 50 GB; an omitted size uses the 20-GB default).reconcile_projectreports this inplan.storagewhen a mount add is on the table and refuses the apply withstorage_limit_reached({ limitGb, currentGb, requestedGb, perVolumeGb }) if the add would push you over. Free quota by removing mounts (reconcile withprune: true) or deleting old previews. For large/long-lived blobs (media, datasets) use object storage instead, not a volume. Seedeploymill://guides/storage. - Scope: a volume is attached to one app. There is no cross-app or cross-org shared volume. Share state through a service (the database) instead. Previews get their own fresh volume per declared mount (see
deploymill://guides/previews). - Full playbook (when to use a volume vs the database, write-under-mountPath rule, removing/renaming):
deploymill://guides/storage.
rollback(true|false|"auto"): opt into fast image-swap rollback. New projects scaffold"auto"(DET-1222) whenever the server has container-registry credentials — an agent-driven deploy has nobody watching a dashboard, so self-healing is the safer default. On a server with no registry the scaffold writesfalse, because arming rollback without one fails withregistry_not_configured. Requires the server to be configured with container-registry credentials. Once on, every deploy pushes its image to the registry so it can be restored without a rebuild."auto"additionally arms self-healing: if a deploy goes live but the health gate (seehealthbelow) comes back unhealthy,deployautomatically reverts to the last recorded-healthy image and reports it (in itsautoRollbackfield), so you don't have to watch for it. Seedeploymill://guides/rollback.health(optional object): the app's health-endpoint contract, the canonical "is this deploy good?" signal thatdeploy/rollback/get_app(itshealthblock) and auto-rollback all key off.{ "path": "/healthz", "retries": 3, "intervalMs": 3000, "timeoutMs": 5000 }.pathis probed strictly (only200is healthy, while any other status, a connection error, or a timeout is unhealthy). Edge requests useretriesas their consecutive-result threshold, waitintervalMsbetween attempts, and cap each request attimeoutMs. Setpath: "/"to opt out into the lenient gateway-only root probe. Declaring this block also wires an orchestrator-level health gate (a stack-independent TCP port-readiness probe + health-gated rolling update). After convergence, DeployMill identifies the new release through its ready, non-terminating Service endpoint and keeps strict HTTP requests on the public edge path. A200mixed only with transient gateway or network failures during a confirmed old-pod drain counts as convergence; no200or an application error still fails. Omitting the block keeps existing apps working through the edge fallback. Workers have no HTTP edge → no health gate. Full contract:deploymill://guides/health.gracePeriodSeconds(optional integer,1–1800): how many seconds the platform gives this app to finish in-flight background work after it is told to shut down, before it is killed outright. Omitted = 30s (the platform default), which is what every app has always had — so adding this field changes nothing for an app that doesn't set it. This is the platform half of a two-part contract: it only helps if your app also trapsSIGTERM, stops accepting new work, finishes what's in flight, and exits (ideally as soon as it's idle, so a quiet deploy costs nothing). Note this is not connection draining — open HTTP requests are already drained for you. It exists for work that outlives the request that started it (an agent turn a client polls for, a queued job, a webhook fan-out), which nothing else protects. Size it to your worst case: at the deadline the process isSIGKILLed with no checkpoint and no resume. A draining container holds its full memory reservation and keeps drawing GB-hours from your pool while it lasts, but only while work is actually in flight. Takes effect on the next deploy (it lives in the workload spec, and a running container's window was fixed when it started). Full contract:deploymill://guides/deploy-safety.source(optional{ "provider", "connection"?, "owner"?, "repo"?, "url"? }): where this project's source lives. Provider-neutral, mirroringdatabase:provideris one of"github"|"gitea"|"external"(today only GitHub is wired).owner/repo/urlare optional hints. For GitHub the repo location is implied by where the file lives, sostart_projectwrites just{ "provider": "github" }.connection(optional string) pins this project to a specific source connection: the opaque id of a connected GitHub account (get_accountlists usable connection ids). It's provider-neutral: it names a connection row, not a GitHub concept. Omit it to use the org's default connection (the shared house account unless the org set its own). Set it to deploy from a bring-your-own GitHub account. Once recorded,reconcile/import_repo/create_previewbind the repo to that connection's installation. Omit the whole block to fall back to the server's defaultSOURCE_PROVIDER(GitHub) via the default connection. Read byimport_repo, not otherwise reconciled.
buildPath (optional string, default "/"): the subdirectory of the repo this app builds from — the build context handed to the image builder. Set it when the app lives in a subdirectory of a larger repo, so the Dockerfile and its COPY paths resolve relative to that directory rather than the repo root:
"source": { "provider": "github", "buildPath": "/services/api" }
Normalized on read, so services/api, /services/api, and /services/api/ are the same value (otherwise two spellings of one directory would diff against each other and show as permanent phantom drift). .. segments are rejected — the build context must stay inside the repo. import_repo also looks for the Dockerfile (and package.json / pyproject.toml for stack detection) inside this directory.
This does NOT make a repo a monorepo of several deploymill apps. .deploymill/project.json is still read from the repo root, so one repo still declares exactly one app. buildPath moves the build context only. See Can I deploy a monorepo? below.
database(optional): provision a managed database. One database + role per app, the connection string injected into the app's env asDATABASE_URL. Write it as{ "engine": "postgres" }— there is no provider to pick. DeployMill runs the Postgres itself, on infrastructure we operate, so there is no vendor account to connect and no signup step in the middle of your deploy.engine("postgres"): the database engine / wire protocol your app speaks.postgresis the only selectable engine. (A file-on-volumesqliteengine is implemented behind the seam but currently disabled as a selectable engine.) It is required in this two-axis form — there is no default. Adatabaseblock that omitsengineis parsed as the legacy flat form below, whoseprovideraccepts only"postgres"/"neon"/"supabase"— so{ "provider": "deploymill" }on its own is not valid and fails withfailed schema validationon thedatabasepath. Write{ "engine": "postgres" }.provider(optional,"deploymill"): who operates it.deploymillis the only selectable value, and it is not baked in as a parse-time default: omitting the field means "this app needs Postgres, don't pin a vendor", and the vendor is resolved when the database is provisioned (your org's default, falling back to the housedeploymillbackend). Omitting it is the recommended form — but only alongsideengine.
So the canonical form is:
Third-party database vendors are not an option. Naming one ("neon", "supabase", or any other external vendor) is refused when the database is provisioned, with errorCode: "database_provider_unavailable". A small number of long-lived apps still run on an external vendor from before this changed — those keep working and keep reconciling untouched — but no new database can be provisioned onto one. If you want a database DeployMill doesn't operate, don't declare a database block at all: set DATABASE_URL yourself with set_env_vars and manage that database wherever you already run it.
Legacy flat form (still accepted): the old single-field form ({ "provider": "postgres" }, no engine) is still parsed and normalized to the two-axis form above. It's upgraded in place the next time DeployMill rewrites the file, so prefer { "engine": "postgres" } for new configs.
After provisioning, fetch deploymill://guides/database/<stack> and follow it before the next deploy.
storage(optional{ "provider": "r2" }): provision a managed, S3-compatible object-storage bucket for blobs (user uploads/media, datasets, anything you serve to clients). One bucket + bucket-scoped credentials per app.S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEYare injected into the app's env. Requires the object-storage backend to be configured on the server. After provisioning, fetchdeploymill://guides/object-storage/<stack>and follow it before the next deploy. Use this for blobs, not a volume (single-host, fixed-size) and not Postgres (structured data).protection(optional{ "enabled", "mode"?, "username"?, "access"?, "publicPaths"?, "previews"? }): gate all HTTP traffic to the app.enabled(boolean) turns it on/off.modepicks the mechanism (default"basic"):"basic": HTTP Basic Auth.username(default"user") is the Basic-Auth user. The password is never stored in this file. It's returned once at creation time byset_app_protectionorreconcile_projectand must be saved by the caller."organization"(the canonical name;"workspace"is still accepted as a backward-compatible alias, and remains the stored/returned mode value): a DeployMill sign-in gate in front of the site.accesspicks who: omitted or{ "scope": "org" }= the whole owning org;{ "scope": "any-authenticated" }= any signed-in DeployMill user (no org membership required, so you lean on DeployMill auth without inviting viewers into the org);{ "scope": "selected", "emails": [...] }= only those members (emails are resolved to stable user ids at apply time, and unknown addresses warn and are skipped). The automation bypass token is surfaced once inapplied.protectionon enable./.deploymill/auth/*is a reserved path on organization-protected hosts. Full guide:deploymill://guides/site-protection.
Two more fields apply to a gated app:
Omitting the block leaves any existing protection untouched (reconcile is non-destructive by default). Set enabled: false to explicitly remove protection. The tool interface is set_app_protection. Add this block to persist the setting across reconciles. Provider-neutral (enforced by the platform's ingress, applied to every domain attached to the app). Because it's enforced on the ingress route, the app must have at least one attached domain. Enabling protection on an app with no domain is rejected (set_app_protection returns { ok: false, errorCode: "no_ingress_route" }, and reconcile defers it with a warning until a domain is attached).
previews(optional): preview-deployment config. Reconcile does NOT act on it. These fields are read bycreate_preview.enabled(boolean): informational. Tells agents whether this app expects previews.shareDatabase(boolean, optional, defaultfalse): when the app has a managed database, previews get their own isolated copy by default (a Postgres clone taken while the parent keeps serving) so destructive migrations stay off prod data. Settrueto opt back into sharing the parent'sDATABASE_URL. Seedeploymill://guides/previewsfor the full behavior matrix.database(optional): the forward-looking, extensible form ofshareDatabase— what data a preview's database starts with. Accepts"branch"(an isolated clone of the parent, the default),"shared"(the parent's ownDATABASE_URL), or{ "from": "backup", "backupId": "…" }to seed the preview's own isolated database by restoring a chosen backup of the parent (a known-good past snapshot instead of live prod data). Backup-seeding is paid and works on the DeployMill-operated Postgres (a parent on a legacy external-vendor database returns a coded error). It overridesshareDatabase, and a per-callcreate_preview({ database })overrides it.backupIdcomes fromlist_backupson the parent.volumes(optional,{ "from": "backup", "mounts": [{ "mountPath", "backupId" }] }): the volume analog — seed the named per-preview volume(s) by restoring a file-level volume backup of the parent instead of starting empty. Paid, and requires the portable volume-backup feature to be enabled on the server (otherwisecreate_previewreturnsvolume_seed_unavailableand you should create the preview without it). EachmountPathmust match a mount the parent declares; eachbackupIdis a successful volume backup fromlist_backups→volumeBackups. A per-callcreate_preview({ volumes })overrides it. Omitted ⇒ fresh, empty volumes.shareVolumes(boolean, optional): not supported — rejected, not honored. Previews never share volumes: each declared mount always gets its own fresh, empty per-preview volume. Settingtruewhile the app declaresmountsmakescreate_previewfail withshare_volumes_unsupported— remove the flag. (It is still parsed, so an old config carrying it doesn't breakreconcile_project.) To start a preview volume from the parent's data, seed it withvolumes: { from: "backup", ... }oncreate_preview, or keep shared state in object storage. Seedeploymill://guides/previews.shareStorage(boolean, optional, defaultfalse): when the app declaresstorage, each preview gets its own fresh, empty bucket by default (preview writes never touch prod blobs). Settrueto point the preview'sS3_*at the parent's actual bucket instead. Seedeploymill://guides/previews.- Note:
shareDatabaseandshareStorageare opt-ins that an org admin can disable workspace-wide by settingallowSharedPreviewResources: falsein the org's deploy preferences. When disabled they become no-ops and previews always isolate where feasible. Regardless of the flags,create_previewreturns ahitsProdDataboolean. Branch on it to know whether a given preview ended up on prod data. Seedeploymill://guides/previews.
hibernation(optional{ "enabled"?, "idleHours"?, "idleAction"? }): idle-sleep behavior. When the platform has hibernation armed, a web app that has gone its idle window with no recorded activity is scaled to zero to free its memory and is woken transparently on the next HTTP request to its URL (a brief cold start). You don't have to do anything, and a wake never rebuilds (the existing image just starts back up). Use this block to override the default:enabled: omit to inherit the platform default. Setfalseto pin the app always-on (never auto-slept, for a latency-sensitive prod app). Settrueto opt a web app in explicitly. Workers default to always-on (there's no HTTP request to wake them, so they're never auto-slept unless you setenabled: trueand give them a non-request wake path).idleHours: this app's idle window in hours before it sleeps — any positive number up to 24 (fractions allowed, e.g.0.5for 30 minutes; there is no1floor at the schema level). Omit to use the platform default. A shorter window reclaims memory sooner (more cold starts). A longer one keeps the app warm longer. It's capped at 24h. A longer window would just be a free stand-in for keeping the app always-on, so "never sleeps" is the paidenabled: false(always-alive) lever, not a giantidleHours. It tunes the window within an armed platform. It doesn't by itself arm hibernation when the platform default is off.- Tuning the idle window is a PAID capability (
idleWindowTunable). On the free Explore tier it'sfalse: the window is fixed at 30 minutes, and anidleHoursyou write here is accepted by the parser and then ignored. Paid plans tune it from a 1-hour default/floor up to the 24-hour cap (or always-on viaenabled: false). Checkget_accountbefore promising a user a different sleep window.
- Tuning the idle window is a PAID capability (
idleAction: what to do when the app goes idle."sleep"(the default) scales the app to zero but keeps its URL live (it auto-wakes on the next request) and the app keeps its active-app quota slot."stop"instead fully stops the app. It frees its quota slot and does not auto-wake (its URL stops responding until you bring it back withstart_app). Use"stop"for occasional tooling you'd rather have release a slot when unused than hold one asleep. Only matters while the app is sleep-eligible. Anenabled: falseapp never idles at all. You can also set this (and the rest of the block) without editing the file via theset_app_sleep_policytool.
You can also wake a slept app on demand with start_app (no rebuild). Both get_app (top-level and in its health block) and each row of list_apps report hibernated: true for an auto-slept app (its status still reads stopped, so hibernated is what distinguishes an auto-slept app from one you explicitly stopped). get_app also reports the resolved idleAction. Note that a sleeping app still counts toward your active-app limit. It's instantly wakeable, so it keeps its slot. An app that's stopped (whether you stopped it with stop_app or it auto-stopped under idleAction: "stop") frees one. Plain sleep saves memory, not a quota slot. idleAction: "stop" frees the slot too (at the cost of no auto-wake).
secrets(optional array): bind org-vault secrets into the app's env. Each entry is either a bare string (the vault secret name, used verbatim as the env key) or{ "name", "as" }to map a vault name onto a different env key. On every reconcile the current vault value is resolved and written into the app's env (rotating the vault re-syncs here). Only names live in the file. Values never do. Requires the server to have the secrets store configured. Declared-but-missing secrets are reported inplan.secrets.missingwith a warning (reconcile doesn't fail). Removing an entry does NOT prune the env var. Usedelete_env_vars. Enter the secret value first viarequest_secret(browser hand-off, so the value never passes through the agent). Seedeploymill://guides/secrets.schedules(optional array, max 40): time-triggered jobs for this app. Each entry is{ "name", "cron" }—nameis a lowercase slug (^[a-z][a-z0-9-]*$, ≤ 60 chars, unique within the app) andcronis a standard 5-field expression ("0 2 * * *"; no seconds field, no@hourlymacros — a malformed cadence fails schema validation rather than registering a job that can't fire).reconcile_projectregisters the diff with the platform scheduler and, when a cadence is due, POSTs the app's/_system/ticknaming the job; your app dispatches to its own handler. Reconcile mints a per-appDM_SCHEDULE_TICK_SECRETonce and injects it into the app's env (so a fresh registration needs a redeploy — reconcile says so). Schedules are skipped with a warning if the app has nodomains.prod(nowhere to send the tick), if the secrets vault isn't configured, or if the prod host resolves to a non-public address. Handlers must be idempotent. Full contract:deploymill://guides/schedules.environments(optional object, max 50): first-class named environments (staging, qa, …) — each is a permanent preview tagged with a stable name. The key is the environment name; the value is{ "ref", "shareDatabase"? }, whererefis the git branch it deploys from andshareDatabase(defaultfalse) chooses between sharing the parent's database and getting its own isolated copy.reconcile_projectensures each declared environment exists: it creates a missing one (via the same primitivecreate_previewuses), skips one that's already live, and never deletes — a live named environment absent from this block is left alone. A per-environment failure is a warning, not a failed reconcile. A named environment counts against the standard app quota, not the preview quota; tear one down withdelete_app. Seedeploymill://guides/named-environments.auth(optional{ "provider", "audience"? }): declares which external identity provider the app uses for end-user login ("clerk"|"auth0"|"supabase"|"workos";audienceis only"users"today and defaults to it). DeployMill operates nothing here — no user store, no login UI, no edge token validation. Reconcile provisions nothing: it records the choice and runs a read-only presence check that the provider's expected env-var key names are set on the app, emitting a typedauth_secret_missinghint plus a guide pointer when they aren't (values are never read or echoed). Set the values the normal way —set_env_varsorrequest_secret. Seedeploymill://guides/authand the per-providerdeploymill://guides/auth/<provider>.
Where this file comes from
start_project writes it for a brand-new repo. To adopt an existing repo, use import_repo: if the repo already has a .deploymill/project.json, that file is the source of truth. If it doesn't (e.g. a repo from a freshly-connected GitHub account that DeployMill never bootstrapped), import_repo scaffolds one for you. It infers a default web config (name from the repo, port auto-detected, prod host derived), commits it to the repo, and continues the import. The tool returns scaffolded: true with a scaffoldedConfig block. Treat the inferred config as a starting point. Review and update it (set workload: "worker" if it's a background process, adjust domains/port/mounts, add a database/storage/secrets block) and re-run reconcile_project to apply your edits. Pass scaffoldConfig: false to import_repo to fail fast (config_not_found) instead of writing to the repo.
You don't need to know a connection id: import_repo auto-picks the source connection from the repo owner in the URL (ryanrdetzel/Belongings → the org's connected ryanrdetzel account), falling back to your default connection, with an explicit connection arg as the override.
Read the readiness report. Every import returns a structured "what will probably break, and how to fix it", so you're not left discovering a broken deploy in the logs. It inspects the repo's Dockerfile against the resulting config and flags, each with a machine-readable code, severity, and a concrete fix:
no_dockerfile: DeployMill builds from a Dockerfile. Without one the build fails.env_vars_expected: the env/build vars the Dockerfile declares (readiness.expectedEnv). Set real/secret values withset_env_varsor asecretsblock. They're never read from the image.database_url_expected: the app'sDATABASE_URLis a connection string (postgres://…) but no managed database is declared. Adddatabase: { engine: "postgres", provider: "deploymill" }, or set the URL yourself withset_env_varsif you're pointing at a database you run.local_sqlite_ephemeral: the app'sDATABASE_URLis a file path (e.g./app/data/app.db), i.e. local SQLite, not a managed DB. DeployMill's container storage is ephemeral, so that file is wiped on every redeploy unless its directory is mounted. Fix by mounting the parent dir (a few GB is plenty) or migrating to managed Postgres.unpersisted_path: an env var whose value is a filesystem path under a dir the image writes to (uploads, cache, data) that the config doesn't mount, so writes there vanish on every redeploy. Add amountsentry on that dir, or point it at object storage if it's blobs. (This catches the common case where the app writes to a normal dir with no DockerfileVOLUME.)unpersisted_volume: a DockerfileVOLUMEpath the config doesn't mount. (A scaffold pre-wires these intomounts, so they won't show here.)assumed_web_workload: scaffolding guessedweb. Switch toworkerif that's wrong.
The workflow
- Edit the file in the repo, locally or via
push_files. The file is the source of truth. - Run
reconcile_projectwith the app'sapplicationIdand eitherrepoUrl(read from GitHub) orconfig(pass the parsed object directly). - Read the
plan:additionsare things reconcile will create,driftis live app state not declared in the file,conflictsare mismatches that need a human. - Apply. By default reconcile applies non-destructive changes immediately. Pass
dryRun: trueto skip applying. Passprune: trueto also remove drift (destructive, opt-in). - Watch
deployforconfigDrift.deployships your code; it does not apply this file. So a waiteddeployruns a reconcile dry-run (zero mutations) and, when the committed config declares something the live app hasn't reconciled, returns a non-fatalconfigDriftadvisory:driftReasons(the drifting plan sections, e.g.["rollback","rollback.auto"]), anote, andremediation: { tool: "reconcile_project", applicationId }. Branch on it and runreconcile_project— that's how an app ends up running without the rollback, resources, domain, database, or schedules its own config declares. It never fails the deploy, and it's simply omitted when the check can't run. - Redeploy if needed. Mount and database changes require a redeploy. Reconcile's response includes a
notefield that calls this out. When you ran reconcile for a config-only change (no source edit), the follow-updeploywill reportrolledOver: falsebecause the rebuilt image is byte-identical. That's expected and not the cache footgun. The deploy still re-applies the app's pod spec (the response carriesspecReapplied: true), so your mount / resource / env change has taken effect, and nonoCacheis needed.
Common operations
- Adding a mount: add an entry to
mounts, commit, reconcile, thendeploy. - Removing a mount: drop the entry from
mounts, commit, then run reconcile withprune: true. The volume is detached from the app; the underlying volume may or may not be deleted depending on the platform's retention setting. - Changing the prod domain: change
domains.prod, commit, reconcile. The old domain is reported as drift; passprune: trueto remove it. - Adding a custom domain: two DNS records from the domain owner. (1) The first time your org attaches a host, reconcile blocks it with
domain_verification_required(inplan.domains.blocked) carrying a TXT record to publish (proof you own it). (2) Point the domain's DNS at DeployMill's ingress (a CNAME to the platform domain, or anALIAS/ANAMEat it for an apex — a bare apex A record at its IP is accepted but discouraged; DNS-only / unproxied). Add the hostname todomains.custom, commit, reconcile. Reconcile validates ownership + DNS and attaches it with a Let's Encrypt cert. Anything not yet satisfied is reported inplan.domains.blocked(domain_verification_required/dns_not_pointed, each carrying the exact record) and skipped. Fix it and reconcile again. Remove it from the list +prune: trueto detach. Full playbook:deploymill://guides/domains. - Sizing the app (CPU/memory): add or edit
resources(e.g.{ "cpu": 2, "memoryMb": 2048 }), commit, reconcile, thendeploy. Limits take effect on the next deploy. To remove a limit, drop the field (or set"resources": {}to clear both). Common after an OOM kill: bumpmemoryMb. - Changing the container port: set/change
port, commit, reconcile. Reconcile updates the app's container port and recreates the prod domain row on the new port (nopruneneeded, a wrong port is broken, not optional). Thendeployso the container rebuilds on the new port if its image changed too. - Enabling rollback: set
rollback: true, commit, reconcile. The next deploy pushes the first rollback target. See the rollback guide. - Provisioning a database: add
database: { engine: "postgres" }, commit, reconcile. Reconcile returns anextSteps.guideUripointing at the per-stack database playbook. Fetch and follow it before the next deploy. - Removing a database: drop the
databasefield. Withoutprune: truereconcile warns about drift but leavesDATABASE_URLalone. Withprune: trueit removes the env var AND drops the managed database + role. - Provisioning object storage: add
storage: { provider: "r2" }, commit, reconcile. Reconcile returns anextSteps.guideUripointing at the per-stack object-storage playbook. Fetch and follow it before the next deploy. - Removing object storage: drop the
storagefield. Withoutprune: truereconcile warns about drift but leaves theS3_*env vars alone. Withprune: trueit removes the env vars AND drops the managed bucket + scoped credentials. - Binding a secret: enter its value once via
request_secret(browser hand-off), add its name to thesecretsarray, commit, reconcile, thendeploy. Seedeploymill://guides/secrets. - Provisioning a preview: out of scope for this file. Call
create_preview({ parentApplicationId, ref }). Seedeploymill://guides/previews.
Can I deploy a monorepo?
Short answer: one repo declares one app. .deploymill/project.json is read from the repo root, and that file names exactly one app — so a single repo cannot host several deploymill apps.
What you can do is point an app at a subdirectory. source.buildPath sets the build context, so an app whose code lives in /services/api builds from there (its Dockerfile, and the COPY paths inside it, resolve relative to that directory):
{
"version": 2,
"name": "api",
"source": { "provider": "github", "buildPath": "/services/api" },
"domains": { "prod": "api-acme.example.com" },
"port": 8080
}
That covers "my app is not at the root of its repo". It does not cover "I want five apps in one repo" — for that, use one repo per app. A common pattern is to keep a working monorepo locally and mirror each directory to its own repo (push_files, or get_clone_credentials + a real git push), with each mirrored repo carrying its own root .deploymill/project.json.
Reading the reconcile result: drift and driftReasons
drift is a boolean: does live state differ from the config? Because a bare boolean can't say how, every result that sets it also carries driftReasons — the list of plan sections that contributed ("domains.add", "mounts.expand", "database", …). Branch on driftReasons rather than re-deriving the cause by walking the whole plan:
{ "drift": true, "driftReasons": ["domains.add", "health"] }
driftReasons is absent when drift is false. Note that org-wide orphan databases are only scanned on a pruning reconcile — they used to be reported (and to flip drift) on every per-app reconcile, which meant an app could report drift because of leftovers belonging to some other app entirely.
What reconcile does NOT do
- Doesn't manage arbitrary env vars (except
DATABASE_URLduring database provisioning, theS3_*vars during object-storage provisioning, and any keys bound via thesecretsarray). Useset_env_vars/delete_env_varsfor everything else. - Doesn't push code. Use
push_filesto commit code (passdeploy: { applicationId }, or calldeploy, to ship it, because a commit no longer auto-deploys). - Doesn't run migrations. Migrations happen at container start (see database guides).
- Doesn't manage previews. Use
create_preview/delete_app.
What NOT to do
- Don't edit reconciled state out-of-band. The platform doesn't know about
.deploymill/project.json. The file in the repo is the only source of truth, and changes made elsewhere will surface as drift. - Don't add fields the schema doesn't define. The schema is
strict(), so unknown fields fail validation. - Don't depend on
prune: truebeing safe. It deletes drift unconditionally. Always rundryRun: truefirst if you're not sure what drift exists.
Troubleshooting
failed schema validation→ the file has a typo or missing required field. The error message points at the offending field path.version 1 is no longer supported→ bumpversionto2and removedomains.previews(previews are now MCP-driven).Unsupported version→ the file hasversionnot equal to 2. Either fix it or upgrade DeployMill.- Drift you can't explain → someone changed app state out-of-band. Either update the file to match (
prune: false) or let reconcile remove it (prune: true).