Skip to content

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 &#91;&#91;volumes&#93;&#93;<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 FileSystemResizePendingBound 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:

kubectl get sc -o custom-columns=NAME:.metadata.name,EXPAND:.allowVolumeExpansion

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!:

INFO cleanup: pvc preserved (preserveOnDelete=true) app_id=abc123 pvc=paas-app-abc123-data

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:

kubectl -n paas-tenant-{tenant_id} delete pvc paas-app-{app_id}-{name}

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