Docs / Persistent storage (volumes) reference
Guidedeploymill://guides/storage

Persistent storage (volumes) reference (deploymill)

Use this when an app needs to keep files on disk across redeploys — an on-disk cache, a local search/vector index, an embedded datastore, in-flight working files. deploymill gives an app persistent disk through named-volume mounts declared in .deploymill/project.json and reconciled onto the app.

First decision: volume, database, or object storage?

The container filesystem is ephemeral — every deploy starts from the image, so anything written outside a mounted volume is gone on the next build/restart. To persist, pick the right primitive:

  • Managed Postgres (database: { provider: "neon" }) — for structured/relational data, anything you'd query. This is the default for app data. See deploymill://guides/database/<stack>.
  • A volume (mounts) — for short-term filesystem state that isn't a good fit for Postgres: a cache directory, a Lucene/vector index on disk, an embedded on-disk store, working files. Single-host, fixed standard size (see below).
  • Object storage (storage: { provider: "r2" }) — for large or long-lived blobs: user-uploaded images and video, big datasets, anything you'd serve to clients. This is not a volume — it's a managed S3-compatible bucket. Declare it in .deploymill/project.json and reconcile_project provisions a per-app bucket + scoped credentials and injects S3_* env vars. See deploymill://guides/storage/<stack>. Use this for media/datasets rather than parking them on a volume.

If you're reaching for a volume to run SQLite as your primary database, prefer the managed Postgres path instead — it's backed up, branchable for previews, and doesn't tie the app to one host's disk. If you're reaching for a volume to store user-uploaded media, prefer object storage — a volume is single-host, fixed-size, and can't be served directly.

Declaring a volume

Volumes are file-as-truth: there is no add_mount tool. Edit .deploymill/project.json, commit, then reconcile.

{
  "version": 2,
  "name": "my-app",
  "domains": { "prod": "my-app-acme.detz.dev" },
  "mounts": [
    { "volumeName": "my-app-uploads", "mountPath": "/data/uploads" }
  ],
  "rollback": false
}
  • volumeName — the named volume. Docker naming rules ([a-zA-Z0-9][a-zA-Z0-9_.-]*, max 60 chars). Pick something app-scoped and stable.
  • mountPath — absolute path inside the container where the volume is mounted (/data, /data/uploads, /var/lib/index). Your app writes here.

Then:

reconcile_project({ applicationId, repoUrl })   // diffs config ↔ live, attaches the mount
deploy({ applicationId })                        // mounts only take effect on the next deploy

reconcile_project reports the mount in its plan/applied output and sets a note reminding you a deploy is required. Run it with dryRun: true first if you want to preview.

Size

There is no per-mount size knob — every volume is provisioned at deploymill's single standard size (currently 20 GB). That's plenty for the short-term persistent storage volumes are meant for (caches, on-disk indexes, in-flight uploads/working files). You don't declare a size and you can't pick a different one.

A couple of honest caveats:

  • Your org has a total storage quota. Each volume (20 GB) counts against a per-org allowance (default 100 GB, i.e. 5 volumes — prod and preview volumes both count). reconcile_project reports the picture in plan.storage and refuses a mount add that would push you over with the storage_limit_reached error ({ limitGb, currentGb, requestedGb }); create_preview enforces the same before provisioning fresh preview volumes. To free quota, drop a mount from .deploymill/project.json and reconcile with prune: true, or delete an unused preview.
  • The 20 GB per-volume size is nominal, not yet kernel-enforced. The quota above bounds how many volumes you can reserve, but a single named volume can still grow into the host's shared disk past 20 GB until the disk fills. Hard, per-volume enforcement of the ceiling is tracked as a follow-up. Until then, design the app to bound its own disk use (rotate/expire caches, cap upload sizes).
  • Volumes are short-term / single-host persistence, not a media store. For large or long-lived blobs — user-uploaded images and video, big datasets — a volume is the wrong tool: it's single-host, pre-allocated, and can't be served directly. An object-storage primitive (S3-compatible) is the right home for that and is on the roadmap.

There is no resize: a volume isn't resizable today, and the standard size doesn't change per app.

Using it from app code

  • Write only under a mountPath. Files written anywhere else (the working dir, /tmp, the image filesystem) do not survive a redeploy.
  • Create subdirectories yourself on boot if your app expects them — the volume starts empty the first time it's attached.
  • The volume persists across deploys and restarts, keyed by volumeName on the host. Renaming volumeName orphans the old data (see below).

Scope: one app per volume (no shared volumes)

A volume is attached to one application. deploymill intentionally has no shared-volume-across-apps / across-orgs feature:

  • Named volumes are single-host Docker volumes, and most other compute backends' volume primitives are single-attach/single-writer — a "share one volume across N apps" model wouldn't port to a second backend.
  • Two containers writing one volume with no locking is a data-corruption footgun.

**Share state through a service, not a disk.** If two apps in an org need the same data, put it in the database (or, later, an object-store primitive) and have both apps connect to it.

Previews get their own fresh volume

When the parent app declares mounts, create_preview attaches a fresh, empty volume to the preview at each mountPath (named <parentVolumeName>-pv-<hash8>) — never the parent's. Preview writes can't reach prod data. Opt into sharing the parent's actual volumes (risky) with previews: { shareVolumes: true }. Full behavior in deploymill://guides/previews.

Removing a volume

Drop the entry from mounts, commit, then:

reconcile_project({ applicationId, repoUrl, prune: true })

Without prune: true, reconcile reports the orphaned mount as drift but leaves it attached (safe default — no accidental data loss). With prune: true the mount is detached on the next deploy. Whether the underlying volume's data is deleted depends on the platform's retention; treat removal as destructive and back up first if the data matters.

Renaming a volume

Changing volumeName for an existing mountPath is not auto-applied — reconcile surfaces it as a conflict warning rather than silently orphaning the old volume's data. To rename: detach the old mount (remove it, reconcile with prune: true, deploy), then add the new one and reconcile again. Migrate data yourself if needed.

Backups

Named volumes are the mount type backed by the platform's volume-backups feature. There is no MCP tool to drive volume backups/restores yet — it's out of band for v1. If the data is important, design the app so it can rebuild from a durable source (the database, object storage) rather than treating the volume as the only copy.

What NOT to do

  • Don't write app data outside a mountPath expecting it to persist — the container filesystem is wiped on every deploy.
  • Don't use a volume as your primary relational database — use managed Postgres (database).
  • Don't try to share one volume across two apps — there's no such feature; share via a service.
  • Don't rename volumeName in place and expect the data to follow — it orphans the old volume.