Guidedeploymill://guides/project-config
.deploymill/project.json reference (deploymill)
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, rollback toggle, optional managed Postgres database, optional managed object storage.
Preview lifecycle is not in this file — previews are MCP-driven (create_preview / delete_preview), not declarative.
Schema (version 2)
{
"version": 2,
"name": "my-app",
"port": 8000,
"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 },
"database": { "provider": "neon" },
"storage": { "provider": "r2" },
"previews": { "enabled": true, "shareDatabase": false, "shareVolumes": false, "shareStorage": false },
"secrets": ["ANTHROPIC_API_KEY", { "name": "SENDGRID_KEY", "as": "EMAIL_KEY" }]
}
A worker (no HTTP edge) omits port and the whole domains block — that absence is the signal that 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",
"mounts": [],
"rollback": false,
"database": { "provider": "neon" }
}
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.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.domains(object, optional) — its presence vs. absence is what distinguishes a web app from a worker. A web app declaresdomains.prod; a worker (a long-running background process with no HTTP server — a queue consumer, a scheduler, etc.) omits the wholedomainsblock (andport). For a worker, reconcile attaches no domain and skips the edge health-probe,deployreturnsurl: null, andimport_repodoesn't try to resolve a port. There is no separateworkloadfield — "nodomains.prod" is the worker signal. A worker still holds an active-app quota slot while it runs. (Scaffold one withstart_project({ stack: "node" | "python", workload: "worker" }).)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), and validates every entry before attaching: hostname syntax, that it isn't under the platform wildcard domain (that namespace is deploymill's), 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. Withprune: true, a custom domain removed from this list is detached. Point DNS first, then add the host here and reconcile, or the cert won't issue. deploymill issues the TLS cert for you — Let's Encrypt, via 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, useattach_domainwith an explicithost; to remove one,detach_domain— but a host still listed here will be re-added on the next reconcile.)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 }: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.).- Size: there's no size knob — every volume is the standard 20 GB (nominal, not yet kernel-enforced per-volume). Your org has a soft total-storage quota (default 100 GB, i.e. 5 volumes across all apps and previews).
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) — 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_healthand auto-rollback all key off.{ "path": "/healthz", "retries": 3, "intervalMs": 3000, "timeoutMs": 5000 }—pathis probed strictly (only200is healthy; 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 the endpoint into the container's Swarm HEALTHCHECK (start-first / failure-action=rollback), so the orchestrator won't cut over to an unhealthy new task. Omitting the block keeps existing apps working (the probe still defaults to/healthzwith a lenient/fallback on a 404, but no Swarm HEALTHCHECK is wired). Workers have no HTTP edge → no health gate. Full contract:deploymill://guides/health.database(optional{ "provider": "neon" }) — provision a managed Postgres database. One database + role per app, pooledDATABASE_URLinjected into the app's env. Requires the database provider to be configured on the server. After provisioning, fetchdeploymill://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).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 Neon database, previews fork their own Neon branch by default 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.
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 — the value never passes through the agent). Seedeploymill://guides/secrets.
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.
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: point the domain's DNS at deploymill's ingress first (a CNAME to the platform domain, or an A record matching its IP for an apex). Then add the hostname to
domains.custom, commit, reconcile. Reconcile validates DNS and attaches it with a Let's Encrypt cert; if DNS isn't pointed yet it's reported inplan.domains.blocked(codedns_not_pointed) and skipped — fix the record and reconcile again. Remove it from the list +prune: trueto detach. - 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: { provider: "neon" }, 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; the platform auto-deploys on push. - Doesn't run migrations. Migrations happen at container start (see database guides).
- Doesn't manage previews. Use
create_preview/delete_preview.
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()— 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).