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.

Ephemeral preview lifecycle is not in this file: previews are MCP-driven (create_preview / delete_app), not declarative. Two nearby blocks are exceptions worth knowing: previews records the defaults create_preview reads (reconcile never acts on it), and environments declares permanent named environments (staging, qa, …) that reconcile_project does ensure exist.

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/object-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
Named environments (staging/qa)"environments": { "staging": { "ref": "main" } }reconcile ensures each exists. See deploymill://guides/named-environments
External end-user auth (BYO IdP)"auth": { "provider": "clerk" }set the provider's env keys → reconcile. See deploymill://guides/auth
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 },
  "gracePeriodSeconds": 600,
  "source": { "provider": "github" },
  "database": { "engine": "postgres", "provider": "deploymill" },
  "storage": { "provider": "r2" },
  "protection": { "enabled": false, "username": "user" },
  "previews": { "enabled": true, "shareDatabase": 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): provenance only — an informational hint recording which starter stack the project was scaffolded from (e.g. node, python, static). It does not affect how the app is built (every deploymill app is a Dockerfile build) and, as of DET-1220, it does not affect compute sizing either. It used to pick the platform default compute size, which was wrong in a way that bit quietly: a starter is routinely replaced wholesale on the first push, so a repo scaffolded from static and then filled with a Rust/Go/Java service kept being sized as if it were still nginx. Omitted for legacy configs, and safe to delete from any config.
    • Declarative & non-destructive, like protection: omitting the whole block means you are not requesting a size — it does NOT mean the app keeps whatever size it currently has. Compute size is platform-managed, so reconcile still derives the platform default and applies it (you'll see resources.action: "update" in the plan on the first reconcile of a new app, moving it from unset to the derived tier). The block only overrides that derivation, and only when your org has customCompute. 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). Two separate ceilings then apply, and they behave differently:
      • The platform max (operator-set, currently 2 vCPU / 4096 MB). set_app_resources rejects an over-max value outright with a coded cpu_over_max / memory_over_max.
      • Your plan's per-app memory ceiling (maxMemoryMbPerApp, read it from get_account) — this is the one that bites, because it's lower than the platform max on Builder: Builder 1024 MB, Studio 4096 MB, Enterprise 4096 MB. (Explore's 256 MB is unreachable: the free tier has no customCompute, so its size is always platform-derived.) There is no per-plan CPU ceiling — CPU is bounded only by the platform max.
      • The two entry points differ on over-limit memory. set_app_resources rejects it ({ ok: false, errorCode: "memory_over_plan_limit", limitMb, requestedMb } plus the upgrade-gate pointer). reconcile_project instead clamps down to your ceiling and says so: the plan reports plan.resources.memoryClampedFromMb with the original request, and the app comes up at the ceiling rather than failing the reconcile. So a Builder app whose file asks for 2048 MB deploys at 1024 MB — check memoryClampedFromMb rather than assuming the file's number was applied.
    • 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 ALIAS/ANAME at that same hostname — an apex A record matching the platform domain's IP is accepted but discouraged, because it pins an IP that can change). 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, for an apex, an ALIAS/ANAME at that host; a bare A record at that host's IP is accepted but discouraged), 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 10 GB, Studio 100 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, perVolumeGb }) 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. New projects scaffold "auto" (DET-1222) whenever the server has container-registry credentials — an agent-driven deploy has nobody watching a dashboard, so self-healing is the safer default. On a server with no registry the scaffold writes false, because arming rollback without one fails with registry_not_configured. 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). Edge requests use retries as their consecutive-result threshold, wait intervalMs between attempts, and cap each request at 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). After convergence, DeployMill identifies the new release through its ready, non-terminating Service endpoint and keeps strict HTTP requests on the public edge path. A 200 mixed only with transient gateway or network failures during a confirmed old-pod drain counts as convergence; no 200 or an application error still fails. Omitting the block keeps existing apps working through the edge fallback. Workers have no HTTP edge → no health gate. Full contract: deploymill://guides/health.
  • gracePeriodSeconds (optional integer, 11800): how many seconds the platform gives this app to finish in-flight background work after it is told to shut down, before it is killed outright. Omitted = 30s (the platform default), which is what every app has always had — so adding this field changes nothing for an app that doesn't set it. This is the platform half of a two-part contract: it only helps if your app also traps SIGTERM, stops accepting new work, finishes what's in flight, and exits (ideally as soon as it's idle, so a quiet deploy costs nothing). Note this is not connection draining — open HTTP requests are already drained for you. It exists for work that outlives the request that started it (an agent turn a client polls for, a queued job, a webhook fan-out), which nothing else protects. Size it to your worst case: at the deadline the process is SIGKILLed with no checkpoint and no resume. A draining container holds its full memory reservation and keeps drawing GB-hours from your pool while it lasts, but only while work is actually in flight. Takes effect on the next deploy (it lives in the workload spec, and a running container's window was fixed when it started). Full contract: deploymill://guides/deploy-safety.
  • 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.

buildPath (optional string, default "/"): the subdirectory of the repo this app builds from — the build context handed to the image builder. Set it when the app lives in a subdirectory of a larger repo, so the Dockerfile and its COPY paths resolve relative to that directory rather than the repo root:

  "source": { "provider": "github", "buildPath": "/services/api" }

Normalized on read, so services/api, /services/api, and /services/api/ are the same value (otherwise two spellings of one directory would diff against each other and show as permanent phantom drift). .. segments are rejected — the build context must stay inside the repo. import_repo also looks for the Dockerfile (and package.json / pyproject.toml for stack detection) inside this directory.

This does NOT make a repo a monorepo of several deploymill apps. .deploymill/project.json is still read from the repo root, so one repo still declares exactly one app. buildPath moves the build context only. See Can I deploy a monorepo? below.

  • database (optional): provision a managed database. One database + role per app, the connection string injected into the app's env as DATABASE_URL. Write it as { "engine": "postgres" } — there is no provider to pick. DeployMill runs the Postgres itself, on infrastructure we operate, so there is no vendor account to connect and no signup step in the middle of your deploy.
    • engine ("postgres"): the database engine / wire protocol your app speaks. postgres is the only selectable engine. (A file-on-volume sqlite engine is implemented behind the seam but currently disabled as a selectable engine.) It is required in this two-axis form — there is no default. A database block that omits engine is parsed as the legacy flat form below, whose provider accepts only "postgres" / "neon" / "supabase" — so { "provider": "deploymill" } on its own is not valid and fails with failed schema validation on the database path. Write { "engine": "postgres" }.
    • provider (optional, "deploymill"): who operates it. deploymill is the only selectable value, and it is not baked in as a parse-time default: omitting the field means "this app needs Postgres, don't pin a vendor", and the vendor is resolved when the database is provisioned (your org's default, falling back to the house deploymill backend). Omitting it is the recommended form — but only alongside engine.

So the canonical form is:

    Third-party database vendors are not an option. Naming one ("neon", "supabase", or any other external vendor) is refused when the database is provisioned, with errorCode: "database_provider_unavailable". A small number of long-lived apps still run on an external vendor from before this changed — those keep working and keep reconciling untouched — but no new database can be provisioned onto one. If you want a database DeployMill doesn't operate, don't declare a database block at all: set DATABASE_URL yourself with set_env_vars and manage that database wherever you already run it.

    Legacy flat form (still accepted): the old single-field form ({ "provider": "postgres" }, no engine) is still parsed and normalized to the two-axis form above. It's upgraded in place the next time DeployMill rewrites the file, so prefer { "engine": "postgres" } 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/object-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"?, "publicPaths"?, "previews"? }): 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.
      • "organization" (the canonical name; "workspace" is still accepted as a backward-compatible alias, and remains the stored/returned mode value): 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 organization-protected hosts. Full guide: deploymill://guides/site-protection.

      Two more fields apply to a gated app:

        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 managed database, previews get their own isolated copy by default (a Postgres clone taken while the parent keeps serving) 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.
          • database (optional): the forward-looking, extensible form of shareDatabase — what data a preview's database starts with. Accepts "branch" (an isolated clone of the parent, the default), "shared" (the parent's own DATABASE_URL), or { "from": "backup", "backupId": "…" } to seed the preview's own isolated database by restoring a chosen backup of the parent (a known-good past snapshot instead of live prod data). Backup-seeding is paid and works on the DeployMill-operated Postgres (a parent on a legacy external-vendor database returns a coded error). It overrides shareDatabase, and a per-call create_preview({ database }) overrides it. backupId comes from list_backups on the parent.
          • volumes (optional, { "from": "backup", "mounts": [{ "mountPath", "backupId" }] }): the volume analog — seed the named per-preview volume(s) by restoring a file-level volume backup of the parent instead of starting empty. Paid, and requires the portable volume-backup feature to be enabled on the server (otherwise create_preview returns volume_seed_unavailable and you should create the preview without it). Each mountPath must match a mount the parent declares; each backupId is a successful volume backup from list_backupsvolumeBackups. A per-call create_preview({ volumes }) overrides it. Omitted ⇒ fresh, empty volumes.
          • shareVolumes (boolean, optional): not supported — rejected, not honored. Previews never share volumes: each declared mount always gets its own fresh, empty per-preview volume. Setting true while the app declares mounts makes create_preview fail with share_volumes_unsupported — remove the flag. (It is still parsed, so an old config carrying it doesn't break reconcile_project.) To start a preview volume from the parent's data, seed it with volumes: { from: "backup", ... } on create_preview, or keep shared state in object storage. 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: shareDatabase and shareStorage 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 in hours before it sleeps — any positive number up to 24 (fractions allowed, e.g. 0.5 for 30 minutes; there is no 1 floor at the schema level). 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.
            • Tuning the idle window is a PAID capability (idleWindowTunable). On the free Explore tier it's false: the window is fixed at 30 minutes, and an idleHours you write here is accepted by the parser and then ignored. Paid plans tune it from a 1-hour default/floor up to the 24-hour cap (or always-on via enabled: false). Check get_account before promising a user a different sleep window.
          • 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.
        • schedules (optional array, max 40): time-triggered jobs for this app. Each entry is { "name", "cron" }name is a lowercase slug (^[a-z][a-z0-9-]*$, ≤ 60 chars, unique within the app) and cron is a standard 5-field expression ("0 2 * * *"; no seconds field, no @hourly macros — a malformed cadence fails schema validation rather than registering a job that can't fire). reconcile_project registers the diff with the platform scheduler and, when a cadence is due, POSTs the app's /_system/tick naming the job; your app dispatches to its own handler. Reconcile mints a per-app DM_SCHEDULE_TICK_SECRET once and injects it into the app's env (so a fresh registration needs a redeploy — reconcile says so). Schedules are skipped with a warning if the app has no domains.prod (nowhere to send the tick), if the secrets vault isn't configured, or if the prod host resolves to a non-public address. Handlers must be idempotent. Full contract: deploymill://guides/schedules.
        • environments (optional object, max 50): first-class named environments (staging, qa, …) — each is a permanent preview tagged with a stable name. The key is the environment name; the value is { "ref", "shareDatabase"? }, where ref is the git branch it deploys from and shareDatabase (default false) chooses between sharing the parent's database and getting its own isolated copy. reconcile_project ensures each declared environment exists: it creates a missing one (via the same primitive create_preview uses), skips one that's already live, and never deletes — a live named environment absent from this block is left alone. A per-environment failure is a warning, not a failed reconcile. A named environment counts against the standard app quota, not the preview quota; tear one down with delete_app. See deploymill://guides/named-environments.
        • auth (optional { "provider", "audience"? }): declares which external identity provider the app uses for end-user login ("clerk" | "auth0" | "supabase" | "workos"; audience is only "users" today and defaults to it). DeployMill operates nothing here — no user store, no login UI, no edge token validation. Reconcile provisions nothing: it records the choice and runs a read-only presence check that the provider's expected env-var key names are set on the app, emitting a typed auth_secret_missing hint plus a guide pointer when they aren't (values are never read or echoed). Set the values the normal way — set_env_vars or request_secret. See deploymill://guides/auth and the per-provider deploymill://guides/auth/<provider>.

        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" }, or set the URL yourself with set_env_vars if you're pointing at a database you run.
        • 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. Watch deploy for configDrift. deploy ships your code; it does not apply this file. So a waited deploy runs a reconcile dry-run (zero mutations) and, when the committed config declares something the live app hasn't reconciled, returns a non-fatal configDrift advisory: driftReasons (the drifting plan sections, e.g. ["rollback","rollback.auto"]), a note, and remediation: { tool: "reconcile_project", applicationId }. Branch on it and run reconcile_project — that's how an app ends up running without the rollback, resources, domain, database, or schedules its own config declares. It never fails the deploy, and it's simply omitted when the check can't run.
        6. 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 ALIAS/ANAME at it for an apex — a bare apex A record at its IP is accepted but discouraged; 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" }, 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.

        Can I deploy a monorepo?

        Short answer: one repo declares one app. .deploymill/project.json is read from the repo root, and that file names exactly one app — so a single repo cannot host several deploymill apps.

        What you can do is point an app at a subdirectory. source.buildPath sets the build context, so an app whose code lives in /services/api builds from there (its Dockerfile, and the COPY paths inside it, resolve relative to that directory):

        {
          "version": 2,
          "name": "api",
          "source": { "provider": "github", "buildPath": "/services/api" },
          "domains": { "prod": "api-acme.example.com" },
          "port": 8080
        }

        That covers "my app is not at the root of its repo". It does not cover "I want five apps in one repo" — for that, use one repo per app. A common pattern is to keep a working monorepo locally and mirror each directory to its own repo (push_files, or get_clone_credentials + a real git push), with each mirrored repo carrying its own root .deploymill/project.json.

        Reading the reconcile result: drift and driftReasons

        drift is a boolean: does live state differ from the config? Because a bare boolean can't say how, every result that sets it also carries driftReasons — the list of plan sections that contributed ("domains.add", "mounts.expand", "database", …). Branch on driftReasons rather than re-deriving the cause by walking the whole plan:

        { "drift": true, "driftReasons": ["domains.add", "health"] }

        driftReasons is absent when drift is false. Note that org-wide orphan databases are only scanned on a pruning reconcile — they used to be reported (and to flip drift) on every per-app reconcile, which meant an app could report drift because of leftovers belonging to some other app entirely.

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