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", provider: "deploymill" }, the house default, orproviderneon/supabase): 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 database, that's now a supported path. Tenant volumes are backed up nightly (see Backups below), so a SQLite .db on a volume has a real durability story. Managed Postgres is still the better fit for anything you'd query relationally (it's also branchable for previews and not tied to one host's disk), but "SQLite on a volume" is no longer an un-backed-up footgun. 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 metered by GB-time. A per-org ceiling guards against runaway. Volumes are metered. You pay $0.10/GB-month for the storage you have provisioned, for as long as you have it (see the existence-billing note below, and the other storage kinds meter too: object storage at $0.02/GB-month and house-provisioned managed-database storage at $0.35/GB-month — that rate matches Neon's retail storage price and bundles nightly backups and preview-database cloning, which vendors bill separately). On top of the meter there's a per-org ceiling that depends on your plan (Explore has no persistent volume, so state goes to the managed database. Paid orgs get a high anti-abuse ceiling, Enterprise higher still. Check your org's actual ceiling via
get_account'sstorage.limitGb). 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.reconcile_projectreports the picture inplan.storageand refuses a mount add that would breach the ceiling withstorage_limit_reached({ limitGb, currentGb, requestedGb }), or, if the org is over its monthly spend cap, with a resumablecap_reachedgate.create_previewenforces the same before provisioning fresh preview volumes. To stop paying for storage, 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 accrues by GB-time while it EXISTS. Stopping an app does not stop it. A volume, an object-storage bucket, and the app's managed database are all metered for as long as they're provisioned, independent of whether the app is awake: reserved bytes cost while they're allocated, asleep or not. So
stop_apphalts compute spend but NOT storage spend, and the same is true for the managed database — sleeping an app does NOT stop its database storage bill. The bytes are still on disk.stop_appsays so in its result (storageBillingNote) when the app still holds storage. The storage spend is visible inget_account.spend(thebreakdown.volumeStorageUsd/objectStorageUsd/databaseStorageUsdfigures) and counts toward your org spend cap. To actually stop storage charges, delete the app (delete_app) or drop the mount / bucket and reconcile. Deleting the app is what drops its managed database and ends that meter — stopping or sleeping it does not. A preview counts too: its cloned database is real bytes on your org total until the preview is reaped or deleted. Only removing the storage ends the meter.- Only house-provisioned databases are metered. If you bring your own Neon or Supabase (a BYO connection string), those bytes are billed by that vendor, not by deploymill — deploymill never meters BYO database storage.
- The managed database has its own per-org size ceiling (
maxDatabaseGb). Like the volumemaxStorageGbceiling, it's an anti-abuse bound (not a purchased quota) and is operator-raisable: Explore = 1 GB, paid = 50 GB, Enterprise custom/higher. Explore isn't billed for its database, 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 raise the ceiling — your data is never deleted.
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 billed 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 anti-abuse ceiling),currentGb(provisioned across all volumes),remainingGb(headroom to the ceiling). For what storage is costing you, read thespendblock'sbreakdown.volumeStorageUsd/objectStorageUsd, live month-to-date storage dollars, folded into your combinedmtdUsdand spend cap.
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
Tenant volumes are backed up automatically, a nightly snapshot shipped offsite (to S3-compatible object storage), with rolling retention. This is what makes "SQLite on a volume" durable: the .db file rides the same backup as any other volume data, with no app cooperation needed (the snapshot is crash-consistent, which SQLite in WAL mode recovers from by design).
You can see the posture per mount on get_app. Each mount carries a backup block:
"backup": { "enabled": true, "schedule": "0 3 * * *", "retentionDays": 7, "lastBackupAt": "2026-06-12T03:00:00Z" }
enabled: whether automatic backups are configured for this deployment.schedule(cron) +retentionDays: when backups run and how many are kept.lastBackupAt: the most recent successful backup (ISO timestamp, ornullif none has run yet / it can't be read).
Restore is currently operator-driven. If you need to roll a volume back to a backup, open a support request with the app and the point-in-time you want. A self-serve restore flow is a follow-up. Backups are a platform-provided safety net, not a substitute for app-level care, but they mean volume-backed data is no longer a single disk failure away from gone.
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. 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.
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.