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, 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_preview), not declarative.
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": { "provider": "neon" },
"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": { "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.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) — compute resource limits (the size of the container the app runs in). Provider-neutral units:cpuis a count of CPU cores (fractions allowed —0.5,1,2; range0.001–64) andmemoryMbis a hard memory cap in megabytes (integer,16–524288). Both are optional. Declarative & non-destructive likeprotection: omitting the whole block leaves whatever limits the app already has untouched (apps created before this field keep the platform default — unbounded). When the block is present it's the full source of truth for limits, so an absent field inside it means "no limit" on that dimension ("resources": {}clears both).reconcile_projectreports the diff inplan.resources(current/desiredin neutral units, plus anactionofnone/update/clear) and applies it through the compute primitive — no Docker/Dokploy concept appears in the config, so a future non-Dokploy compute backend maps the same shape onto its own machine size. Limits take effect on the next deploy (the orchestrator re-creates the container), which reconcile flags in itsnote. Works for both web apps and workers. Use it to give a memory-hungry app more headroom (e.g. after an OOM kill) or to cap a noisy one. Applying limits is best-effort — if the backend rejects them the rest of the reconcile still succeeds and the failure is surfaced as a warning.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), and validates every entry before attaching: 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 withdomain_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. 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.) Full custom-domain playbook (both DNS steps, the coded errors, apex vs subdomain, the unproxied requirement):deploymill://guides/domains.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.source(optional{ "provider", "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" }. Omit the block entirely to fall back to the server's defaultSOURCE_PROVIDER(GitHub). Read byimport_repo; not otherwise reconciled.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).protection(optional{ "enabled", "username"? }) — gate all HTTP traffic to the app behind HTTP Basic Auth.enabled(boolean) turns it on/off;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. Omitting the block leaves any existing protection untouched (reconcile is non-destructive by default); setenabled: falseto explicitly remove protection. The tool interface isset_app_protection; add this block to persist the setting across reconciles. Provider-neutral (today's backend is the Dokploy/Traefik basicAuth middleware).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: 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: { 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 (passdeploy: { applicationId }, or calldeploy, to ship it — 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_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).