Browse docs All docs
Docs / Persistent storage (volumes) reference
Guidedeploymill://guides/storage

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. 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 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.json and reconcile_project provisions a per-app bucket + scoped credentials and injects S3_* env vars. See deploymill://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's storage.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 declared sizeGb (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 notcurrentGb sums declared volume GB only. reconcile_project reports the picture in plan.storage and refuses a mount add that would breach the ceiling with storage_limit_reached ({ limitGb, currentGb, requestedGb }). create_preview enforces 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.json and reconcile with prune: true, or delete an unused preview.

plan.storage is volumes only, not object storage. The reconcile plan has two independent, easily-confused storage sections: plan.storage is this page's primitive (persistent volume mounts and their GB quota), while plan.objectStorage covers the R2 object-storage bucket (storage: { provider: "r2" }, the S3_* env vars). R2 changes show up only under plan.objectStorage, so when a reconcile is doing object-storage work plan.storage.action stays "none". That's expected, not a missed change. If you're watching for an R2 provision/removal, read plan.objectStorage, not plan.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 hits ENOSPC (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.json and reconcile_project provisions a per-app bucket + scoped S3_* credentials (see deploymill://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 / maxDatabaseGb ceiling 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. So stop_app halts 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_app says so in its result (storageBillingNote) when the app still holds storage. Usage is visible in get_account's storage block (currentGb vs limitGb) and its database block (usedGb vs limitGb) — 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_URL you 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 volume maxStorageGb ceiling, 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.

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 }. sizeGb is the provisioned/ceiling-counted size. usedBytes/usedPercent are live (they read null if usage can't be measured, since usage is best-effort and never blocks the read). Its health block also carries a storage array (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: the storage block 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 separate usage.pool block — see deploymill://docs/upgrading and get_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. false on a default deployment — when it's false, nothing is backing this volume up.
  • schedule: always null. There is no per-mount cron; cadence is the org's backup frequency (surfaced at app level via backupPolicy). Don't branch on this field expecting a cron string.
  • retentionDays (number or null): restic's keep-last count when the path is on; null while it's off.
  • lastBackupAt (ISO string or null): the most recent successful backup, null if none has run.
  • health: "healthy" · "stale" · "failing" · "none". "none" while the path is off or nothing has run yet.
  • lastVerifiedGoodAt (ISO string or null): when this mount's backup was last proven restorable by an automated restore drill, null if 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 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:

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