Skip to content

Storage Requirements

The RunWhen Platform uses two kinds of storage:

  • PersistentVolumeClaim (PVC) — for state that must survive pod restarts (Postgres, Vault, Neo4j, object store, Redis, Qdrant, Mimir).
  • emptyDir — for scratch, caches, and working directories that can be recreated. Every first-party emptyDir has a sizeLimit that signals the kubelet to evict the pod if it exceeds the limit.

Required StorageClass

You need at least one default StorageClass (or a named class you configure per component). The chart works with any CSI driver that provides ReadWriteOnce volumes. For Postgres HA (Spilo) each replica needs its own PV — a non-shared block storage with low latency (SSD/NVMe) is strongly recommended.

ScenarioStorageClass requirementNotes
ProductionRegional SSD-backed RWOPostgres needs low-latency writes
NFS onlyNo RWO StorageClass availablePostgres on NFS is never supported — NFS lacks the POSIX locking and fsync semantics Postgres requires (see PostgreSQL and NFS). Use postgresql.spilo.persistence.kind: emptyDir with WAL-G enabled, or an external PostgreSQL. Other components can use emptyDir (see NFS-only cluster below).
OpenShiftAny provisionerNo special requirements beyond a working default StorageClass

Per-component storage matrix

ComponentBacking storeDefault sizeRebuildable?Notes
Postgres (Spilo)PVC (persistence.kind: pvc)10 GiYes — WAL-G → S3Durability anchor. HA via Patroni.
VaultPVC10 GiYes — snapshot tarball → S3Durability anchor. Raft backend.
Neo4jPVC (volumes.data.mode: defaultStorageClass)10 GiYes — re-index from PostgresGraph + vector index. Loss = re-index cost (minutes).
Object store (SeaweedFS)PVC (volume server), PVC (filer)100 Gi volume, 5 Gi filerNo — durability anchor for Vault/Postgres backupsS3 gateway that backs all backup pipelines.
RedisPVC (master.persistence.enabled: true)8 GiYes — cache loss means cold-cache startup costSession store, cache, task queues.
Qdrant (subchart)PVC (useSubchart: true)5 GiYes — re-embed from Postgres/LLMVector DB for RAG.
MetricStore (Mimir)PVC (persistence.kind: pvc)10 GiYes — re-sync from S3 bucket storeTSDB blocks.
PgBounceremptyDir (socket)10 Mi limitn/aUnix socket only — negligible.

Total PVC footprint

ScopeSum of default sizes
Full install~150 Gi (all components at default sizes)
Production500 Gi+ (SeaweedFS volume server dominates)

NFS-only cluster

If your cluster has no RWO StorageClass (NFS is the only option), the following components must switch to emptyDir-backed storage because NFS is not suitable for their access patterns:

ComponentSettingImpact
Postgrespostgresql.spilo.persistence.kind: emptyDirHigh volume requirement. PGDATA is wiped on every pod restart. Recovery requires WAL-G → S3 base backup + WAL replay. The emptyDir sizeLimit must be set generously (default 10 Gi in the chart) to hold the database plus WAL accumulation between archive cycles. A stalled S3 backend can grow pg_wal/ quickly — monitor closely. For production, use an external PostgreSQL instead.
MetricStore (Mimir)metricstore.persistence.kind: emptyDirMimir re-syncs TSDB blocks from S3 on startup. Slower first query after restart, then normal.
Qdrantqdrant.useSubchart: falseChart’s minimal Deployment on emptyDir. Full re-embed on restart.

Example overlay for NFS-only clusters:

postgresql:
spilo:
persistence:
kind: emptyDir
size: 20Gi # generous — WAL accumulates between archives
emptyDirMedium: "" # node disk, not tmpfs (tmpfs would be RAM-capped)
walg:
enabled: true # required — only recovery path when PGDATA is wiped
archiveTimeout: 60
baseBackupSchedule: "0 */2 * * *"
metricstore:
persistence:
kind: emptyDir
size: 10Gi
qdrant:
useSubchart: false
persistence:
size: 10Gi

emptyDir scratch volumes

Every first-party pod has writable scratch volumes (/tmp + /home/app) backed by emptyDir with sizeLimit: 1Gi (configurable via global.scratchVolumes.sizeLimit). Additional component-specific emptyDirs handle binary copies, caches, and working directories.

What sizeLimit does (and doesn’t do)

sizeLimit on an emptyDir is an eviction signal, not a hard quota. The kubelet periodically checks usage and may evict the pod if it exceeds the limit. However:

  • The check is periodic (default --node-status-update-frequency is 10s) — a pod can write well past the limit before the kubelet reacts.
  • The pod can consume as much node disk as is available until the kubelet evicts it.
  • On managed Kubernetes (GKE, EKS, AKS) the eviction thresholds are often configured with long grace periods or relaxed entirely.

The real protection against node disk overrun requires a defense-in-depth approach — see Resource quotas for disk protection below.

Per-component emptyDir volumes

ComponentVolumessizeLimitEst. actual usage
Vault (server)vault-tmp1 Gi~5 MiB (config render)
Vault initvault-bin100 Mi~70 MiB (binary copy)
Vault backupvault-bin, tmp100 Mi, 1 Gi~80 MiB combined
Vault unsealervault-bin100 Mi~70 MiB (binary copy)
PgBouncersocket10 Mi<1 MiB
Spilo (Postgres)spilo-run, spilo-tmp, spilo-varlog1 Gi each~50 MiB combined
LLM Gatewaylitellm-tmp, litellm-cache, litellm-migrations, litellm-prisma-work1 Gi each~200 MiB combined
User Pages v2html100 Mi~50 MiB
CC Catalogdata (SQLite), tmp1 Gi each~30 MiB combined
Neo4jneo4j-confdir, neo4j-run, neo4j-tmp100 Mi, 100 Mi, 1 Gi~150 MiB combined
SeaweedFS (each of 4 components)tmp1 Gi each~100–500 MiB (multipart staging)
Global scratch (× 20 deployments)platform-tmp + platform-home1 Gi each~50 MiB per pod

Estimated total emptyDir footprint: ~3–5 GiB across all pods (vs. ~95 GiB sum of sizeLimits). The sizeLimit is an eviction ceiling, not a reservation.


Resource quotas for disk protection

Because emptyDir.sizeLimit is an eviction signal (not a hard quota), configure namespace-level ResourceQuota and per-container LimitRange with ephemeral-storage limits for write-time enforcement:

apiVersion: v1
kind: ResourceQuota
metadata:
name: runwhen-platform-quota
spec:
hard:
# Cap total ephemeral storage across all pods in the namespace.
# Adjust based on your node disk size and replica count.
requests.ephemeral-storage: "50Gi"
limits.ephemeral-storage: "100Gi"
# PVC caps — match or exceed the per-component defaults.
requests.storage: "200Gi"
persistentvolumeclaims: 20
# Optional CPU / memory quotas.
# requests.cpu: "32"
# requests.memory: "128Gi"
# limits.cpu: "64"
# limits.memory: "256Gi"
---
apiVersion: v1
kind: LimitRange
metadata:
name: runwhen-platform-limits
spec:
limits:
- type: Container
default:
ephemeral-storage: "2Gi" # per-container hard limit
defaultRequest:
ephemeral-storage: "256Mi" # per-container request (scheduling)
max:
ephemeral-storage: "10Gi" # single container cap

OpenShift: auto-apply via ProjectRequestTemplate

On OpenShift, bake the quota and limit range into the cluster’s ProjectRequestTemplate so every new namespace gets them automatically:

apiVersion: template.openshift.io/v1
kind: Template
metadata:
name: project-request
objects:
- apiVersion: project.openshift.io/v1
kind: Project
metadata:
name: ${PROJECT_NAME}
- apiVersion: v1
kind: ResourceQuota
metadata:
name: platform-quota
namespace: ${PROJECT_NAME}
spec:
hard:
requests.ephemeral-storage: "50Gi"
limits.ephemeral-storage: "100Gi"
requests.storage: "200Gi"
persistentvolumeclaims: 20
- apiVersion: v1
kind: LimitRange
metadata:
name: platform-limits
namespace: ${PROJECT_NAME}
spec:
limits:
- type: Container
default:
ephemeral-storage: "2Gi"
defaultRequest:
ephemeral-storage: "256Mi"
max:
ephemeral-storage: "10Gi"

Verify the template is active:

Terminal window
oc get project.config.openshift.io/cluster -o jsonpath='{.spec.projectRequestTemplate.name}'

If unset, apply and configure the cluster to use it:

Terminal window
oc apply -f project-request-template.yaml
oc patch project.config.openshift.io/cluster --type=merge \
-p '{"spec":{"projectRequestTemplate":{"name":"project-request"}}}'

TopicLink
Self-hosted prerequisitesSelf-Hosted Deployment Requirements
External PostgreSQLExternal PostgreSQL
External S3External S3
OpenShift / OKD installOpenShift & OKD — Self-Hosted
Pod security hardeningSecurity Hardening