Persistent Volumes¶
Persistent volumes (PVCs) survive container restarts and pod re-schedules so your app can keep state between deploys. paas provisions them per-app, attaches them to the pod template the deploy reconciler builds, and (optionally) preserves them when the app is deleted so a re-create can re-attach to the same data.
At a glance¶
| Capability | Default | Where to configure |
|---|---|---|
| Backing storage | OVH-managed CSI (ovh-csi-rbd block, ovh-csi-nfs shared) |
accessMode in paas.toml |
| Access modes | rwo (RBD, single-pod) and rwx (NFS, multi-pod) |
accessMode |
| Online resize | Supported on rwo (RBD) and rwx (NFS) — unsupported on local-path |
PUT /v1/apps/{id}/volumes/{name} |
| Preserve on delete | false (cascade delete) |
preserveOnDelete = true |
| Scale-out compat | rwo capped at 1 replica; rwx unlimited |
enforced by API at POST /v1/apps/{id}/scale |
Lifecycle¶
flowchart LR
A["paas.toml [[volumes]]<br/>name, size, accessMode"]
A --> B["Deploy reconciler<br/>service::deploy()"]
B --> C["persistent_volumes::ensure_volume_pvc<br/>idempotent get-or-create"]
C --> D["K8s PVC<br/>paas-app-{app_id}-{name}"]
D --> E["nixpacks_apply::apply_nixpacks<br/>injects volumeMount + volume"]
E --> F["Pod mount<br/>at vol.mountPath"]
G["app delete"] --> H["service::cleanup_app_resources"]
H --> I{"preserveOnDelete?"}
I -- true --> J["PVC preserved<br/>(re-attach on re-create)"]
I -- false --> K["PVC deleted<br/>(cascade)"]
Access modes¶
paas.toml value |
K8s mode | Backing StorageClass | Use case | Replica cap |
|---|---|---|---|---|
rwo (default) |
ReadWriteOnce |
ovh-csi-rbd (block) |
Postgres data dir, media uploads owned by a single writer | 1 |
rwx |
ReadWriteMany |
ovh-csi-nfs (shared) |
Worker fan-out, shared session store, multi-pod cache | N |
The mapping is pinned in
paas_deploy::persistent_volumes::storage_class_for_access and
covered by tests against drift. Unknown values fall back to
ovh-csi-rbd (the safer default — RWX-only workloads need
explicit opt-in).
RWO + scale > 1 → 409 Conflict
K8s can't co-attach a ReadWriteOnce PVC to multiple pods —
the second replica would silently CrashLoopBackOff on
FailedAttachVolume. paas refuses
POST /v1/apps/{id}/scale with replicas > 1 when any
attached volume is ReadWriteOnce, returning 409 Conflict:
{
"error": {
"code": "conflict",
"message": "cannot scale to 3 replicas: volume 'data' is ReadWriteOnce (max 1 replica). Re-create the volume with accessMode 'ReadWriteMany' to scale out."
}
}
Recovery: scale back to 1 (always allowed even on RWO), or
delete + re-create the volume with accessMode = "rwx".
paas.toml example¶
A complete entry exercising every cycle-2 field:
[[volumes]]
name = "data"
mountPath = "/var/data"
size = "10Gi"
accessMode = "rwo" # rwo (default) or rwx
storageClass = "ovh-csi-rbd" # optional override; default per accessMode
preserveOnDelete = true # AE/48 cycle 2 (48g) — opt out of cascade delete
[[volumes]]
name = "uploads"
mountPath = "/var/uploads"
size = "50Gi"
accessMode = "rwx" # multi-pod fan-out
preserveOnDelete = false # default — delete cascades from `app delete`
The reconciler creates two PVCs (paas-app-{app_id}-data,
paas-app-{app_id}-uploads) on first deploy and re-attaches
them on subsequent deploys without re-creating (idempotent).
Resize procedure¶
PUT /v1/apps/{app_id}/volumes/{name} with body
{"new_size_gi": N} patches the PVC's
spec.resources.requests.storage. K8s rolls the expansion
through the CSI controller asynchronously — the PVC walks
through FileSystemResizePending → Bound over a few minutes.
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"new_size_gi": 20}' \
https://runtime.di2amp.com/v1/apps/$APP_ID/volumes/data
Response:
{
"data": {
"name": "data",
"pvc_name": "paas-app-abc123-data",
"size_gi": 20,
"status": "Resizing",
"access_mode": "ReadWriteOnce"
}
}
Failure modes¶
| Condition | HTTP | Message signature |
|---|---|---|
new_size_gi <= current_size_gi |
422 | "size must be strictly greater than current size (no shrink)" |
new_size_gi < 1 or > 1000 |
422 | "new_size_gi must be between 1 and 1000" |
StorageClass allowVolumeExpansion: false |
422 | "storage class 'X' does not allow expansion" |
Volume name not in app_volumes |
404 | "volume 'X' not found on app 'Y'" |
| Anything else (kube down, CSI error) | 500 | passthrough |
StorageClass eligibility
The dev cluster's local-path provisioner has
allowVolumeExpansion: false. Production clusters use
ovh-csi-rbd and ovh-csi-nfs, both of which allow online
expansion. Check eligibility ahead of time:
Preserve on delete¶
By default, app delete cascades through every PVC the app
created (the historical behavior). Setting
preserveOnDelete = true on a [[volumes]] entry opts that
PVC out of the cascade. The bound PersistentVolume stays
around so a later app create with the same app_id re-attaches
to the same data instead of silently spinning up an empty
volume.
The cleanup loop reads the nixpacks ConfigMap (the source of
truth for the deploy's volume topology) before listing PVCs;
each preserved name is logged via tracing::info!:
Re-attach on re-create is automatic: the cycle-2 reconciler
(ensure_volume_pvc) is idempotent — it GETs the PVC by
canonical name and short-circuits if it already exists. The new
deploy's pod template will mount it via the same claimName the
old deploy used.
Hard-delete a preserved PVC
There's no API surface for force-deleting a preserved PVC —
by design, since the data-loss risk would be enormous from a
single typo. Operators with kubectl access can do it
manually:
Implementation pointers¶
| Concern | File |
|---|---|
paas.toml [[volumes]] schema |
crates/contracts/src/nixpacks.rs::VolumeKind::Pvc |
| Plan→StorageClass mapping | crates/deploy/src/persistent_volumes.rs::storage_class_for_access |
| Idempotent get-or-create | crates/deploy/src/persistent_volumes.rs::ensure_volume_pvc |
| RWO scale predicate | crates/deploy/src/persistent_volumes.rs::rwo_scale_violation |
| Resize validator (no shrink) | crates/deploy/src/persistent_volumes.rs::validate_resize |
| Preserve cascade predicate | crates/deploy/src/persistent_volumes.rs::should_delete_pvc |
| Pod-side mount injection | crates/deploy/src/nixpacks_apply.rs::apply_nixpacks |
| Cleanup with preserve skip | crates/deploy/src/service.rs::cleanup_app_resources |
| Live access mode read | crates/control-plane/src/volume_service.rs::list_volumes |
| Resize endpoint | crates/control-plane/src/routes/volumes.rs::resize_volume |
Related concepts¶
- Add-ons — managed Postgres / Valkey / OpenSearch
use their own CRDs (CNPG, Oracle MySQL Operator), separate
from the per-app
[[volumes]]flow. - Manual scaling — RWO scale guard details.
- Blueprint paas.toml — full
[[volumes]]schema with all fields.