Persistent storage (volumes) reference
Looking for object storage (S3/R2 buckets for blobs)? That's a different primitive. See
deploymill://guides/object-storage/{node|python}for wiring a bucket into the app. This page is about named-volume mounts (persistent disk).
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. This is persistent disk, distinct from object storage (S3/R2 blobs) and from the managed database (see the decision table below).
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 database (
database: { engine: "postgres" }): 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 such as 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/object-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 datastore, read Backups first. Volume backups are not running by default — the backup path ships off unless the operator has enabled it, so on a default deployment a .db file on a volume is not backed up and a lost volume is lost data. The managed Postgres database is backed up automatically (encrypted pg_dump on a per-org schedule, with self-service restore_backup), so it is the safer default for anything you can't afford to lose — and each preview gets its own isolated copy of it, which a volume-backed SQLite file doesn't get by default. Before putting primary data on a volume, check get_app's per-mount backup.enabled: if it's false, treat the volume as un-backed-up storage and keep your own copy. 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 ([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 a per-mount size knob: set sizeGb on a mount and that's the size DeployMill provisions for the volume (it becomes the PVC's requests.storage). Omit it and the volume defaults to the standard 20 GB, plenty for the short-term persistent storage volumes are meant for (caches, on-disk indexes, in-flight uploads/working files).
"mounts": [
{ "volumeName": "uploads", "mountPath": "/var/lib/uploads", "sizeGb": 50 },
{ "volumeName": "cache", "mountPath": "/data" }
]
A couple of honest caveats:
- Storage is a plain included limit, not a meter. Volumes are not billed by the GB-hour — there's no per-GB-month rate to track. Instead each plan includes a fixed ceiling on total provisioned storage (
get_account'sstorage.limitGb): Explore has no persistent volume at all (state goes to the managed database instead), Builder includes 10 GB, Studio 100 GB, Enterprise more, operator-adjustable. The ceiling is counted by each volume's declaredsizeGb(a 50-GB mount counts 50, an omitted size uses the 20-GB default). Prod and preview volumes both count; object-storage (R2/S3) bytes do not —currentGbsums declared volume GB only.reconcile_projectreports the picture inplan.storageand refuses a mount add that would breach the ceiling withstorage_limit_reached({ limitGb, currentGb, requestedGb }).create_previewenforces the same before provisioning fresh preview volumes. Unlike the plan's awake-compute pool, the storage ceiling isn't self-serve-raisable — it's set per-org by the operator/admin app. To free headroom, drop a mount from.deploymill/project.jsonand reconcile withprune: true, or delete an unused preview.
plan.storageis volumes only, not object storage. The reconcile plan has two independent, easily-confused storage sections:plan.storageis this page's primitive (persistent volume mounts and their GB quota), whileplan.objectStoragecovers the R2 object-storage bucket (storage: { provider: "r2" }, theS3_*env vars). R2 changes show up only underplan.objectStorage, so when a reconcile is doing object-storage workplan.storage.actionstays"none". That's expected, not a missed change. If you're watching for an R2 provision/removal, readplan.objectStorage, notplan.storage.
- The declared size is enforced as a real disk ceiling. The volume is a real block device sized to
sizeGb(or 20 GB by default): write past it and that volume hitsENOSPC(disk full), but only that volume, never the host or another app. Design the app to handle a full disk gracefully and to bound its own use (rotate/expire caches, cap upload sizes). Grow the volume when you genuinely need more headroom. The per-org quota is a separate, soft cap on how much storage you can reserve across all your volumes. - 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. The managed object-storage primitive (S3-compatible) is the right home for that. Declare
storage: { provider: "r2" }in.deploymill/project.jsonandreconcile_projectprovisions a per-app bucket + scopedS3_*credentials (seedeploymill://guides/object-storage/<stack>). - Storage counts against the org's ceiling while it EXISTS. Stopping an app does not free that headroom. A volume and the app's managed database count against the org's
maxStorageGb/maxDatabaseGbceiling respectively for as long as they're provisioned (an object-storage bucket counts against neither), independent of whether the app is awake — reserved bytes count while they're allocated, asleep or not. Sostop_apphalts the app's draw on the awake-compute pool but does NOT free storage headroom, and the same is true for the managed database — sleeping an app does NOT release its database storage against the ceiling. The bytes are still on disk.stop_appsays so in its result (storageBillingNote) when the app still holds storage. Usage is visible inget_account'sstorageblock (currentGbvslimitGb) and itsdatabaseblock (usedGbvslimitGb) — note the two blocks name the used-bytes field differently. To actually free the headroom, delete the app (delete_app) or drop the mount and reconcile. Deleting the app is what drops its managed database and frees that ceiling — stopping or sleeping it does not. A preview counts too: its cloned database is real bytes against your org total until the preview is reaped or deleted. Only removing the storage frees the headroom.- Only DeployMill-provisioned databases count against the ceiling. If your app talks to a database you run elsewhere via a
DATABASE_URLyou set yourself, those bytes live with that host, not with DeployMill — DeployMill never measures or limits a database it doesn't operate. - The managed database has its own per-org size ceiling (
maxDatabaseGb). Like the volumemaxStorageGbceiling, it's an included limit, not a purchased quota, and is operator-adjustable (not self-serve-raisable): Explore = 1 GB, Builder = 10 GB, Studio = 50 GB, Enterprise custom/higher. Explore's database isn't part of any pool, but its data lives in that managed database and is bounded at 1 GB. Over the ceiling the app is paused until you free space or an operator raises the ceiling — your data is never deleted.
- Only DeployMill-provisioned databases count against the ceiling. If your app talks to a database you run elsewhere via a
sizeGb is grow-only: raise it and reconcile to expand in place. To give a volume more room, increase sizeGb in .deploymill/project.json and run reconcile_project: the plan shows the change under plan.mounts.expand ({ volumeName, mountPath, fromGb, toGb }) and the apply grows the volume online, with the data intact, no recreate, no export/restore. The extra GB counts against your org storage quota like any new volume (an over-quota expansion is refused with storage_limit_reached, same as an add). The growth is online. If a redeploy is still needed to surface the larger filesystem, reconcile says so in its note.
Shrinking is not supported. Lowering sizeGb below the provisioned size is rejected. Reconcile surfaces it under plan.mounts.shrinkRejected with the machine-readable code mount_shrink_unsupported and leaves the volume untouched. Volumes are never shrunk or recreated automatically (a recreate would start the volume empty and orphan your data). If you genuinely need a smaller volume, provision a new mount at the smaller size and migrate the data yourself.
Watching usage (so you never hit the wall by surprise)
Because the size is enforced, an app that runs its volume to 100% gets ENOSPC (disk full) on the next write. The read surface lets an agent see that coming and grow the volume before it bites. No new tool is needed, it's on the calls you already make:
get_app: each mount now reports{ volumeName, mountPath, sizeGb, usedBytes, usedPercent }.sizeGbis the provisioned/ceiling-counted size.usedBytes/usedPercentare live (they readnullif usage can't be measured, since usage is best-effort and never blocks the read). Itshealthblock also carries astoragearray (the same per-mount usage) and, when any mount is ≥ 80 % full, a machine-readable warning you can branch on:"warnings": [{ "code": "volume_nearly_full", "volumeName": "uploads", "mountPath": "/data/uploads", "usedPercent": 92.4 }]get_account: thestorageblock shows your org-wide picture:limitGb(the plan's included ceiling),currentGb(provisioned across all volumes),remainingGb(headroom to the ceiling) — a plain quota check, not a cost readout. Awake-compute usage (the pool that actually governs whether an app can run) is a separateusage.poolblock — seedeploymill://docs/upgradingandget_account's own description for that shape.
The loop: poll get_app (its health block) → if usedPercent climbs past ~80 % (or the volume_nearly_full warning fires), raise sizeGb in .deploymill/project.json and reconcile_project (the grow-in-place path above). That turns "disk full at 3 a.m." into a routine, agent-driven resize.
Backups
Volume backups ship OFF by default. Do not assume a volume is backed up. The platform has a file-level volume-backup path (portable, deduped, encrypted copies shipped to S3-compatible object storage with rolling retention), but it is gated behind an operator flag (VOLUME_BACKUP_RESTIC_ENABLED) that defaults to off, and the older storage-class snapshot job it replaced has been retired. So on a default deployment no volume backup runs at all, and volume data is one disk failure away from gone.
The managed Postgres database is a different story and IS backed up automatically — encrypted pg_dumps on a per-org schedule with a self-service restore_backup. See deploymill://guides/backups. That asymmetry is the reason to keep anything you can't lose in the managed database rather than on a volume.
Check the real posture per mount, at runtime, on get_app. Never assume it — each mount carries a backup block:
"backup": {
"enabled": false,
"schedule": null,
"retentionDays": null,
"lastBackupAt": null,
"health": "none",
"lastVerifiedGoodAt": null
}
enabled(boolean): whether the volume-backup path is turned on for this deployment.falseon a default deployment — when it'sfalse, nothing is backing this volume up.schedule: alwaysnull. There is no per-mount cron; cadence is the org's backup frequency (surfaced at app level viabackupPolicy). Don't branch on this field expecting a cron string.retentionDays(number ornull): restic's keep-last count when the path is on;nullwhile it's off.lastBackupAt(ISO string ornull): the most recent successful backup,nullif none has run.health:"healthy"·"stale"·"failing"·"none"."none"while the path is off or nothing has run yet.lastVerifiedGoodAt(ISO string ornull): when this mount's backup was last proven restorable by an automated restore drill,nullif never verified.
Restore of a volume is operator-driven — there is no self-serve volume-restore tool. It also presupposes a backup exists: if backup.enabled is false on your mounts, there is nothing to restore from, and support cannot recover the data. Treat a volume as durable-against-redeploy storage, not as a backed-up system of record.
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:
- A volume is a single-attach, single-writer PVC (one PersistentVolumeClaim per mount, ReadWriteOnce), and most other compute backends' volume primitives are single-attach/single-writer too. 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 the managed object-storage bucket) 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 — previews never share volumes (previews.shareVolumes in .deploymill/project.json is rejected, not honored). To seed a preview volume with prod-shaped data instead, restore a point-in-time backup with volumes: { from: "backup", ... }. 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.
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.