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.
| Store | Role | Replication |
|---|---|---|
| pg01 — PostgreSQL VM | Tenant DBs + platform DBs | Daily logical dumps to SOS |
| Redis / Valkey | Sessions, bus, queues, events | No persistence |
| NFS filestore | Per-tenant binary attachments | Periodic tarball to SOS |
| Exoscale SOS | Backups, template seeds, image mirrors | S3-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.
- Backups —
pg_dumpto SOS runs as a cron on the VM, independent of Kubernetes.
Databases on pg01
| Database | Owner role | Purpose |
|---|---|---|
{subdomain}.planapulse.app | plana | Tenant Odoo data (one per tenant) |
basic-template-{17,18,19}.planapulse.app | plana | Template DBs for fast provisioning |
pro-template-{17,18,19}.planapulse.app | plana | Pro tier templates |
plana_pulse_account | plana | Account portal app DB |
authentik | authentik | Authentik DB |
forgejo | forgejo | Forgejo DB |
nextcloud | nextcloud | Nextcloud DB |
vaultwarden | vaultwarden | Vaultwarden DB |
pulse_account | plana | Pulse account portal data |
openphysica_website | openphysica | External 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:
| Tier | Retention |
|---|---|
| Tenant DBs (Starter) | 30 days |
| Tenant DBs (Pro) | 90 days |
| Tenant DBs (Enterprise) | 1 year |
| Platform DBs | 90 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 prefix | Purpose | TTL |
|---|---|---|
PLANA:events | CloudEvents stream (pulse-events) | MAXLEN ~50,000 |
PLANA:executions:{workspace} | Agent tool-call audit | per-workspace TTL |
PLANA:sessions:{tenant_id}:{session_id} | AI agent chat sessions | 24h 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 cache | short |
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:
| Bucket | Purpose | Retention |
|---|---|---|
plana-pulse-backups | Per-tenant PG dumps + filestore tarballs | Per-tier (above) |
plana-pulse-templates | Template DB seeds | Indefinite — versioned by SHA |
plana-pulse-registry-mirror | Cached upstream image layers | 30 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 registry —
git.planapulse.comForgejo registry. Backed by pg01 and SOS, not a separate store. - Secrets — SOPS-encrypted YAML in the
infrarepo, 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
infrarepo.
Where to read more
- Operations → Restoring from backup
- Operations → Provisioning a tenant — see how
TemplateSnapshotproduces a clean DB for cloning - Tenant isolation — what isolation guarantees come from the data layer
- Crossplane — the per-tenant side-effects that touch each store