Docs / .deploymill/project.json reference
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_project keeps 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 under domains because one container serves a single port that any number of domains route to. When set, reconcile_project syncs 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_repo reads it ahead of the stack default. If your prod URL returns a 502, the usual cause is this not matching the Dockerfile's EXPOSE / 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 declares domains.prod; a worker (a long-running background process with no HTTP server — a queue consumer, a scheduler, etc.) omits the whole domains block (and port). For a worker, reconcile attaches no domain and skips the edge health-probe, deploy returns url: null, and import_repo doesn't try to resolve a port. There is no separate workload field — "no domains.prod" is the worker signal. A worker still holds an active-app quota slot while it runs. (Scaffold one with start_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 at port. A different host already attached is reported as drift (removed only with prune: 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-derived prod host. 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 like acme.com) an A record matching the platform domain's IP. An entry that fails validation is skipped with a warning (surfaced in plan.domains.blocked with a machine-readable code such as dns_not_pointed, carrying the exact record to create) — the rest of the reconcile still applies, so fix DNS and re-run. With prune: 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, use attach_domain with an explicit host; 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 through database instead — 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_project reports this in plan.storage when a mount add is on the table and refuses the apply with storage_limit_reached ({ limitGb, currentGb, requestedGb }) if the add would push you over. Free quota by removing mounts (reconcile with prune: true) or deleting old previews. For large/long-lived blobs (media, datasets) use object storage instead, not a volume. See deploymill://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 (see health below) comes back unhealthy, deploy automatically reverts to the last recorded-healthy image and reports it (in its autoRollback field) — you don't have to watch for it. See deploymill://guides/rollback.
  • health (optional object) — the app's health-endpoint contract, the canonical "is this deploy good?" signal that deploy/rollback/get_app_health and auto-rollback all key off. { "path": "/healthz", "retries": 3, "intervalMs": 3000, "timeoutMs": 5000 }path is probed strictly (only 200 is healthy; any other status, a connection error, or a timeout is unhealthy), declared unhealthy only after retries consecutive failures spaced by intervalMs (each attempt bounded by timeoutMs). Set path: "/" 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 /healthz with 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, pooled DATABASE_URL injected into the app's env. Requires the database provider to be configured on the server. 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_KEY are injected into the app's env. Requires the object-storage backend to be configured on the server. After provisioning, fetch deploymill://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 by create_preview.
    • enabled (boolean) — informational. Tells agents whether this app expects previews.
    • shareDatabase (boolean, optional, default false) — when the app has a Neon database, previews fork their own Neon branch by default so destructive migrations stay off prod data. Set true to opt back into sharing the parent's DATABASE_URL. See deploymill://guides/previews for the full behavior matrix.
    • shareVolumes (boolean, optional, default false) — when the app declares mounts, each preview gets its own fresh volume per mountPath by default (preview writes never touch prod data). Set true to attach the parent's actual volumes to previews instead (concurrent-writer corruption risk — read-mostly volumes only). See deploymill://guides/previews.
    • shareStorage (boolean, optional, default false) — when the app declares storage, each preview gets its own fresh, empty bucket by default (preview writes never touch prod blobs). Set true to point the preview's S3_* at the parent's actual bucket instead. See deploymill://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 in plan.secrets.missing with a warning (reconcile doesn't fail). Removing an entry does NOT prune the env var — use delete_env_vars. Enter the secret value first via request_secret (browser hand-off — the value never passes through the agent). See deploymill://guides/secrets.

The workflow

  1. Edit the file in the repo — locally or via push_files. The file is the source of truth.
  2. Run reconcile_project with the app's applicationId and either repoUrl (read from GitHub) or config (pass the parsed object directly).
  3. Read the planadditions are things reconcile will create, drift is live app state not declared in the file, conflicts are mismatches that need a human.
  4. Apply — by default reconcile applies non-destructive changes immediately. Pass dryRun: true to skip applying. Pass prune: true to also remove drift (destructive — opt-in).
  5. Redeploy if needed — mount and database changes require a redeploy. Reconcile's response includes a note field that calls this out.

Common operations

  • Adding a mount: add an entry to mounts, commit, reconcile, then deploy.
  • Removing a mount: drop the entry from mounts, commit, then run reconcile with prune: 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; pass prune: true to 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 in plan.domains.blocked (code dns_not_pointed) and skipped — fix the record and reconcile again. Remove it from the list + prune: true to 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 (no prune needed — a wrong port is broken, not optional). Then deploy so 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 a nextSteps.guideUri pointing at the per-stack database playbook — fetch and follow it before the next deploy.
  • Removing a database: drop the database field. Without prune: true reconcile warns about drift but leaves DATABASE_URL alone. With prune: true it removes the env var AND drops the managed database + role.
  • Provisioning object storage: add storage: { provider: "r2" }, commit, reconcile. Reconcile returns a nextSteps.guideUri pointing at the per-stack object-storage playbook — fetch and follow it before the next deploy.
  • Removing object storage: drop the storage field. Without prune: true reconcile warns about drift but leaves the S3_* env vars alone. With prune: true it 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 the secrets array, commit, reconcile, then deploy. See deploymill://guides/secrets.
  • Provisioning a preview: out of scope for this file. Call create_preview({ parentApplicationId, ref }). See deploymill://guides/previews.

What reconcile does NOT do

  • Doesn't manage arbitrary env vars (except DATABASE_URL during database provisioning, the S3_* vars during object-storage provisioning, and any keys bound via the secrets array). Use set_env_vars / delete_env_vars for everything else.
  • Doesn't push code. Use push_files to 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: true being safe. It deletes drift unconditionally. Always run dryRun: true first 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 → bump version to 2 and remove domains.previews (previews are now MCP-driven).
  • Unsupported version → the file has version not 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).