.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.
Preview lifecycle is not in this file: previews are MCP-driven (create_preview / delete_app), not declarative.
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/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 |
| 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 },
"source": { "provider": "github" },
"database": { "engine": "postgres", "provider": "deploymill" },
"storage": { "provider": "r2" },
"protection": { "enabled": false, "username": "user" },
"previews": { "enabled": true, "shareDatabase": false, "shareVolumes": 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): an informational hint recording which starter stack the project was scaffolded from (e.g.node,python,static). Used only to pick a sensible platform default compute size; omitted for legacy configs.- Declarative & non-destructive, like
protection: omitting the whole block leaves whatever size the app already has untouched. 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), and a requested size is capped at the platform max (currently 2 vCPU / 4096 MB). An over-cap value is rejected (reconcile surfaces it as a warning carrying a machine-readable
code), not silently clamped. - 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) an A record matching the platform domain's IP. 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 A-record it at that host's IP for an apex), 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 500 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 }) 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. 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), declared unhealthy only afterretriesconsecutive failures spaced byintervalMs(each attempt bounded bytimeoutMs). 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), so the orchestrator won't cut over until the new container's port is accepting connections, while the strict HTTP/healthzjudgment above runs at the post-deploy edge probe. Omitting the block keeps existing apps working (the probe still defaults to/healthzwith a lenient/fallback on a 404, but no orchestrator-level health gate is wired). Workers have no HTTP edge → no health gate. Full contract:deploymill://guides/health.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.database(optional): provision a managed database. One database + role per app, the connection string injected into the app's env asDATABASE_URL. The block has two orthogonal axes so the database engine your app codes against is never conflated with the vendor that operates it (three of the legacy values,postgres/neon/supabase, were all really Postgres, which is exactly what made people think aneonapp was running "a different kind of database"):engine("postgres", default"postgres"): the database engine / wire protocol your app speaks. (A file-on-volumesqliteengine is implemented behind the seam but currently disabled as a selectable engine.)provider("deploymill"|"neon"|"supabase", default"deploymill"): who operates it.deploymillis the house operator (the internal Postgres server).neon/supabaseare the third-party managed-Postgres vendors.connection(optional string) mirrorssource.connection: it pins provisioning to a specific database connection (a bring-your-own Neon/Supabase account) by its opaque connection id; omit it to use the org's default for the provider (falling back to the house connection).
So the canonical forms are:
(A { "engine": "sqlite", "provider": "deploymill" } file-on-volume form is implemented behind the database seam but is currently disabled as a selectable backend.)
Legacy flat form (still accepted): the old single-field form ({ "provider": "postgres" | "neon" | "supabase" }, no engine) is still parsed and normalized to the two-axis form above (postgres→{engine:"postgres",provider:"deploymill"}, neon/supabase keep their vendor). It's upgraded in place the next time DeployMill rewrites the file, so prefer the two-axis form 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/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"? }): 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."workspace"(alias"organization"): 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 workspace-protected hosts. Full guide:deploymill://guides/site-protection.
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 branch-capable managed database (postgresorneon), previews get their own isolated copy by default (Neon branch fork / Postgres clone) so destructive migrations stay off prod data. Settrueto opt back into sharing the parent'sDATABASE_URL. Seedeploymill://guides/previewsfor the full behavior matrix.shareVolumes(boolean, optional, defaultfalse): when the app declaresmounts, each preview gets its own fresh volume per mountPath by default (preview writes never touch prod data). Settrueto attach the parent's actual volumes to previews instead (concurrent-writer corruption risk, read-mostly volumes only). 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: all three
share*flags are 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 (hours, 1–24) before it sleeps. 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.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.
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" }(the house default, or pick anotherprovider/engine), or set the URL yourself.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). - 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 an A record matching its IP for an apex, 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", provider: "deploymill" }(the house default, swapproviderforneon/supabase), 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.
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).