Browse docs All docs
Docs / .deploymill/project.json reference
Guidedeploymill://guides/project-config

.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:

CapabilityAdd this blockThen
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_project keeps 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 a port behind a domain and must declare domains.prod: reconcile_project / import_repo error if workload is "web" (or defaulted) and domains.prod is 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.): omit port and the whole domains block, and reconcile attaches no domain, skips the edge health-probe, and deploy returns url: null. Files written before this field existed default to "web". (Scaffold a worker with start_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 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.
  • resources (object, optional): the app's compute size, in provider-neutral units: cpu is a count of CPU cores (fractions allowed: 0.5, 1, 2) and memoryMb is memory in megabytes (integer). Compute size is platform-managed by default: deploymill derives the size per stack and database tier at reconcile, and this resources block (and the set_app_resources REST/dashboard control) is ignored unless your organization has the customCompute capability 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 cpu value 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_project reports the diff in plan.resources (current / desired in neutral units, plus an action of none / 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 its note.
  • domains (object, optional): required for a web app (see workload). A web app declares domains.prod. A worker omits the whole domains block (and port). Reconcile errors if workload is "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 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). With prune: 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_required carrying 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 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. 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_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, 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: raise sizeGb and reconcile_project expands the volume in place (online, data intact): the plan shows it under plan.mounts.expand and the apply patches the volume. The extra GB counts against your org storage quota like any new volume. Lowering sizeGb is rejected (plan.mounts.shrinkRejected, code mount_shrink_unsupported): volumes are never shrunk or recreated, since a recreate would orphan the data. The expansion is online. Reconcile's note flags 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 declared sizeGb (a 50-GB mount uses 50 GB; an omitted size uses the 20-GB default). 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), so 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 (its health block) and auto-rollback all key off. { "path": "/healthz", "retries": 3, "intervalMs": 3000, "timeoutMs": 5000 }. path is probed strictly (only 200 is healthy, while 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 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 /healthz judgment above runs at the post-deploy edge probe. Omitting the block keeps existing apps working (the probe still defaults to /healthz with 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, mirroring database: provider is one of "github" | "gitea" | "external" (today only GitHub is wired). owner/repo/url are optional hints. For GitHub the repo location is implied by where the file lives, so start_project writes just { "provider": "github" }. connection (optional string) pins this project to a specific source connection: the opaque id of a connected GitHub account (get_account lists 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_preview bind the repo to that connection's installation. Omit the whole block to fall back to the server's default SOURCE_PROVIDER (GitHub) via the default connection. Read by import_repo, not otherwise reconciled.
  • database (optional): provision a managed database. One database + role per app, the connection string injected into the app's env as DATABASE_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 a neon app was running "a different kind of database"):
    • engine ("postgres", default "postgres"): the database engine / wire protocol your app speaks. (A file-on-volume sqlite engine is implemented behind the seam but currently disabled as a selectable engine.)
    • provider ("deploymill" | "neon" | "supabase", default "deploymill"): who operates it. deploymill is the house operator (the internal Postgres server). neon/supabase are the third-party managed-Postgres vendors.
    • connection (optional string) mirrors source.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_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).
    • protection (optional { "enabled", "mode"?, "username"?, "access"? }): gate all HTTP traffic to the app. enabled (boolean) turns it on/off. mode picks 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 by set_app_protection or reconcile_project and must be saved by the caller.
      • "workspace" (alias "organization"): a DeployMill sign-in gate in front of the site. access picks 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 in applied.protection on 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 by create_preview.
      • enabled (boolean): informational. Tells agents whether this app expects previews.
      • shareDatabase (boolean, optional, default false): when the app has a branch-capable managed database (postgres or neon), previews get their own isolated copy by default (Neon branch fork / Postgres clone) 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.
      • Note: all three share* flags are opt-ins that an org admin can disable workspace-wide by setting allowSharedPreviewResources: false in the org's deploy preferences. When disabled they become no-ops and previews always isolate where feasible. Regardless of the flags, create_preview returns a hitsProdData boolean. Branch on it to know whether a given preview ended up on prod data. See deploymill://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. Set false to pin the app always-on (never auto-slept, for a latency-sensitive prod app). Set true to 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 set enabled: true and 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 paid enabled: false (always-alive) lever, not a giant idleHours. 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 with start_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. An enabled: false app never idles at all. You can also set this (and the rest of the block) without editing the file via the set_app_sleep_policy tool.

    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 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, so the value never passes through the agent). See deploymill://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 with set_env_vars or a secrets block. They're never read from the image.
    • database_url_expected: the app's DATABASE_URL is a connection string (postgres://…) but no managed database is declared. Add database: { engine: "postgres", provider: "deploymill" } (the house default, or pick another provider/engine), or set the URL yourself.
    • local_sqlite_ephemeral: the app's DATABASE_URL is 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 a mounts entry 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 Dockerfile VOLUME.)
    • unpersisted_volume: a Dockerfile VOLUME path the config doesn't mount. (A scaffold pre-wires these into mounts, so they won't show here.)
    • assumed_web_workload: scaffolding guessed web. Switch to worker if that's wrong.

    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 plan: additions 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. When you ran reconcile for a config-only change (no source edit), the follow-up deploy will report rolledOver: false because 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 carries specReapplied: true), so your mount / resource / env change has taken effect, and no noCache is needed.

    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: two DNS records from the domain owner. (1) The first time your org attaches a host, reconcile blocks it with domain_verification_required (in plan.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 to domains.custom, commit, reconcile. Reconcile validates ownership + DNS and attaches it with a Let's Encrypt cert. Anything not yet satisfied is reported in plan.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: true to detach. Full playbook: deploymill://guides/domains.
    • Sizing the app (CPU/memory): add or edit resources (e.g. { "cpu": 2, "memoryMb": 2048 }), commit, reconcile, then deploy. 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: bump memoryMb.
    • 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: { engine: "postgres", provider: "deploymill" } (the house default, swap provider for neon/supabase), 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 (pass deploy: { applicationId }, or call deploy, 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: 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).