Skip to content

Data stores

Audience

PLANA staff. Customers wondering about durability, residency or backups should read Plana extras → Backups and Policies → Data residency.

PLANA has four state stores. Everything in the cluster is otherwise stateless and can be killed without losing data.

StoreRoleReplication
pg01 — PostgreSQL VMTenant DBs + platform DBsDaily logical dumps to SOS
Redis / ValkeySessions, bus, queues, eventsNo persistence
NFS filestorePer-tenant binary attachmentsPeriodic tarball to SOS
Exoscale SOSBackups, template seeds, image mirrorsS3-class durability

pg01 — the PostgreSQL VM

A single Exoscale instance running PostgreSQL outside Kubernetes. Hostname pg01.planapulse.com (10.10.0.11). Why outside the cluster:

  • Durability — PostgreSQL on persistent block storage with its own lifecycle is simpler than statefulset-on-K8s. If the SKS cluster has a bad day, the database is unaffected.
  • Connection model — Odoo workers, the account portal, the AI agents, and the platform services all need PostgreSQL. A single shared cluster is operationally simpler than per-product Postgres pods.
  • Backupspg_dump to SOS runs as a cron on the VM, independent of Kubernetes.

Databases on pg01

DatabaseOwner rolePurpose
{subdomain}.planapulse.appplanaTenant Odoo data (one per tenant)
basic-template-{17,18,19}.planapulse.appplanaTemplate DBs for fast provisioning
pro-template-{17,18,19}.planapulse.appplanaPro tier templates
plana_pulse_accountplanaAccount portal app DB
authentikauthentikAuthentik DB
forgejoforgejoForgejo DB
nextcloudnextcloudNextcloud DB
vaultwardenvaultwardenVaultwarden DB
pulse_accountplanaPulse account portal data
openphysica_websiteopenphysicaExternal client app

The plana Postgres role owns most databases. This is convenient but has a side effect: any process connecting as plana can \l all databases owned by that role. Odoo's ir_cron iterates every database the connecting role owns — we added plana_cron_db_filter (a system-extension that monkey-patches list_dbs) to keep cron from touching non-Odoo DBs and generating noise.

Connection pooling

Tenant Odoo workers use pgbouncer running on pg01 itself, in transaction-pool mode. Without pooling, Odoo's per-request connection pattern would exhaust pg01's max_connections at ~100 active tenants. With pgbouncer transaction pooling we serve hundreds of tenants on a few hundred actual Postgres connections.

The pgbouncer instance listens on port 6432. Workers connect via PGHOST=pg01.planapulse.com PGPORT=6432.

Backups

Logical pg_dump per database, every 24 hours, to s3://plana-pulse-backups/.

Retention:

TierRetention
Tenant DBs (Starter)30 days
Tenant DBs (Pro)90 days
Tenant DBs (Enterprise)1 year
Platform DBs90 days

Insurance backups taken before any destructive operation (cross-major upgrades, schema changes) are kept for at least 7 days and named with the operation timestamp.

The restore procedure is at Operations → Restoring from backup.

Redis / Valkey

A single Valkey 7 instance at redis.redis.svc.cluster.local:6379. Valkey is the open-source Redis fork; we use it for license clarity and FOSS alignment. It is otherwise a drop-in Redis.

What lives in Redis:

Key prefixPurposeTTL
PLANA:eventsCloudEvents stream (pulse-events)MAXLEN ~50,000
PLANA:executions:{workspace}Agent tool-call auditper-workspace TTL
PLANA:sessions:{tenant_id}:{session_id}AI agent chat sessions24h sliding
odoo:session:{db}:*Odoo session storage (plana_session_redis)configurable per tenant
odoo:bus:*Odoo bus channels (plana_bus_redis)short
bull:*BullMQ queues (account portal, billing)per-job
coraza:*WAF cacheshort

Redis is not persistent. The dump file is disabled. Everything we store is either ephemeral (sessions, queues, events) or can be regenerated. We trade some startup latency on a Redis restart for a far simpler operational model — no replication, no AOF, no failover dance.

Authentication: Redis runs with requirepass enabled; the password is in SOPS at redis.password. NetworkPolicies in every consuming namespace allow egress to redis.redis:6379 and nowhere else.

NFS filestore

Single NFS export at nfs01.planapulse.com, mounted into Odoo worker pods as a PersistentVolume. Path inside each pod: /var/lib/odoo/filestore/<db_name>.

Per-tenant subdirectories — the NFS export is the parent, and each tenant gets /var/lib/odoo/filestore/{subdomain}.planapulse.app. SubPath mounts prevent a worker for tenant A from reading tenant B's filestore even though both run from the same NFS export.

Why NFS rather than per-pod PVC

  • Odoo's filestore stores binary attachments (PDFs, images on records, signed documents) that must survive worker pod restarts.
  • ReadWriteMany is required because HPA scales the worker Deployment beyond one replica; every replica needs the same view of the filestore.
  • Exoscale block storage is RWO only. The available cluster-level RWX options were NFS (simple, what we use) or distributed file systems (over-engineered for this workload).

Cross-major upgrade implication

Filestore PVCs are namespace-scoped. When a tenant migrates from plana-odoo (v17) to plana-odoo-18 (v18), the filestore is not automatically copied. The migration procedure tar-pipes the filestore between holder Pods in the source and target namespaces, with --no-same-owner --no-same-permissions to handle the v17 → v18 UID change (101 → 100). See Operations → Upgrading a tenant.

Filestore backups

Per-tenant tar.gz to SOS on the same daily cadence as PostgreSQL dumps. Same retention rules apply.

Exoscale Simple Object Storage (SOS)

S3-compatible object storage in bg-sof-1. Three buckets:

BucketPurposeRetention
plana-pulse-backupsPer-tenant PG dumps + filestore tarballsPer-tier (above)
plana-pulse-templatesTemplate DB seedsIndefinite — versioned by SHA
plana-pulse-registry-mirrorCached upstream image layers30 days inactive

Access keys are scoped per-purpose. The backup CronJobs use a key with PutObject and ListBucket only on plana-pulse-backups. The restore runbook uses a separate key with read access.

All buckets are private. There is no public-read on any object.

Why Exoscale SOS

  • Data residency — same region (bg-sof-1) as the cluster. Cross-region durability via Exoscale's internal replication; data never leaves the EU.
  • Same vendor — one bill, one support relationship.
  • S3-compatible — standard tooling (aws s3, boto3, provider-exoscale) just works.

What is NOT a data store

  • Image registrygit.planapulse.com Forgejo registry. Backed by pg01 and SOS, not a separate store.
  • Secrets — SOPS-encrypted YAML in the infra repo, mirrored to Vaultwarden. Both are sources of truth depending on the secret category.
  • State for Crossplane — Kubernetes etcd (managed by Exoscale SKS). The control-plane manages this for us; we do not back it up directly. Recovery story relies on Flux GitOps reconciling everything back from the infra repo.

Where to read more

© PLANA Digital Ltd.