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. Seedeploymill://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.jsonandreconcile_projectprovisions a per-app bucket + scoped credentials and injectsS3_*env vars. Seedeploymill://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_projectreports the picture inplan.storageand refuses a mount add that would push you over with thestorage_limit_reachederror ({ limitGb, currentGb, requestedGb });create_previewenforces the same before provisioning fresh preview volumes. To free quota, drop a mount from.deploymill/project.jsonand reconcile withprune: 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
volumeNameon the host. RenamingvolumeNameorphans 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
mountPathexpecting 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
volumeNamein place and expect the data to follow — it orphans the old volume.