High-level architecture
Audience
PLANA staff, technical partners, customer CTOs evaluating us.
PLANA runs on a single Kubernetes cluster in Exoscale's Sofia region (bg-sof-1). One cluster, one ingress, dedicated namespaces per concern. This document describes the layout end-to-end.
The picture
Internet
│
▼
Exoscale NLB 194.182.177.67
│
▼
┌─────────────────────────────────────────────────┐
│ Envoy Gateway v1.7.1 │
│ (Kubernetes Gateway API) │
│ Gateway: eg-gateway · ns: plana-odoo │
│ ExtAuthz: CrowdSec · WAF: Coraza (failClose) │
└────────┬────────────────────────────┬───────────┘
│ │
│ HTTPRoute │ HTTPRoute
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Tenant Odoo workers │ │ Account portal + BOS │
│ ns: plana-odoo, │ │ ns: pulse-account │
│ plana-odoo-18, │ │ · pulse-account │
│ plana-odoo-19 │ │ (Nuxt) │
│ │ │ · pulse-account-api │
│ · worker-odoo (1..N) │ │ (Fastify) │
│ · per-tenant DB │ │ · bos-portal │
│ on pg01 │ │ (Vue 3 SPA) │
│ · NFS filestore │ └─────────────────────────┘
└─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Shared services │
│ pg01 Redis/Valkey NFS filestore │
│ 10.10.0.11 redis:6379 nfs01.planapulse.com │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Exoscale Simple Object Storage (SOS) │
│ Daily PG dumps · filestore archives · template seeds │
└─────────────────────────────────────────────────────────┘The five layers
1. Edge — Exoscale NLB
A single network load balancer at 194.182.177.67 receives every request for *.planapulse.{ai,app,online,dev,com}. The NLB is a thin TCP proxy; it does not terminate TLS itself. From the NLB, traffic flows to the cluster's Envoy Gateway pods over the in-cluster Service network.
2. Ingress — Envoy Gateway
There is exactly one ingress: eg-gateway in the plana-odoo namespace. We migrated to Envoy Gateway from NGINX in April 2026 and removed all other ingress controllers at the same time. Every public service exposes itself by declaring an HTTPRoute that references eg-gateway as its parent.
Two cross-cutting policies sit on the gateway:
- CrowdSec bouncer as an ExtAuthz filter, configured to fail closed — if the bouncer is unreachable, requests are denied rather than silently let through.
- Coraza as an EnvoyExtensionPolicy implementing the OWASP Core Rule Set — also fail-closed.
TLS is terminated by Envoy Gateway, using certificates issued by cert-manager from Let's Encrypt. There is a single wildcard certificate per second-level domain.
3. Workloads — namespaces by concern
Each product family lives in its own namespace. The current set:
| Namespace | What runs there |
|---|---|
plana-odoo | v17 tenant Odoo workers + the shared eg-gateway |
plana-odoo-18 | v18 tenant Odoo workers — current default |
plana-odoo-19 | v19 tenant Odoo workers — limited release |
pulse-account | pulse-account-api, pulse-account Nuxt site, bos-portal (prod) |
bos-demo | BOS preview environment for development |
authentik | Authentik server + worker (staff SSO) |
pulse-events | Redis Streams event bus |
crossplane-system | Crossplane reconciler + per-tenant side-effect jobs |
cert-manager | Let's Encrypt automation |
envoy-gateway-system | Envoy control plane + data plane pods |
crowdsec | CrowdSec LAPI + agents + bouncer |
monitoring | Prometheus, Grafana, Alertmanager, Loki, Blackbox |
forgejo, forgejo-runner | Git server + CI runners |
matrix | Synapse + Element Web + hookshot |
nextcloud | Nextcloud + Collabora + Talk |
penpot, vaultwarden, matomo, pgadmin | The tools |
backup | Per-tenant PostgreSQL backup CronJobs |
ai-bos-agent, ai-marketing-agent | The agent runtimes |
Tenant Odoo namespaces share one Deployment with HPA. Each tenant gets a dedicated PostgreSQL database on pg01 and a dedicated subdirectory in the NFS filestore. Tenant isolation is enforced by Odoo's dbfilter=^%h$ — the HTTP Host header is the literal database name. See Tenant isolation for the threat-model view.
4. State — pg01, Redis, NFS, SOS
PLANA has four state stores:
- pg01 (
10.10.0.11,pg01.planapulse.com) — a single PostgreSQL VM outside Kubernetes, running on Exoscale block storage. Houses every tenant database and the platform databases (plana_pulse_account, Crossplane state, etc.). Connection pooling viapgbouncerfor tenants. - Redis / Valkey —
redis.redis.svc.cluster.local:6379. Used for Odoo sessions and bus, BullMQ queues, thePLANA:eventsstream, and caches. - NFS filestore —
nfs01.planapulse.com. Per-tenant filestore subdirectory mounted into the Odoo workers. - Exoscale SOS — S3-compatible object storage. Daily
pg_dumparchives, filestore tarballs, template snapshots, image registry mirrors.
5. Provisioning — Crossplane
Tenant lifecycle (create, restore, upgrade, delete) is declarative. You do not call an API; you apply a Kubernetes custom resource. Crossplane's reconciliation loop turns the CR into the actual cluster objects.
Four CRs handle the entire tenant lifecycle:
PLANAClient— a customer's full ERP environmentTenantEnvironment— a single Odoo instance (most tenants have one)TenantUpgrade— same-major or cross-major upgradeTemplateSnapshot— produce or refresh a template database
This replaced a custom Node.js orchestrator that we removed entirely in May 2026. See Crossplane for details.
How traffic reaches a tenant
When a user visits acme.planapulse.app:
- DNS resolves to the NLB at
194.182.177.67. - NLB forwards TCP to one of the cluster's Envoy Gateway pods.
- Envoy terminates TLS using the
*.planapulse.appwildcard cert. - CrowdSec and Coraza filters run; if either blocks, the request stops here.
- Envoy matches an
HTTPRoutekeyed on hostacme.planapulse.app. - The route targets
worker-odoo:8069in one ofplana-odoo,plana-odoo-18orplana-odoo-19— whichever namespace serves the tenant's current major version. - Odoo reads the
Hostheader, appliesdbfilter=^%h$, opens the databaseacme.planapulse.appon pg01. - The response goes back through Envoy to the user.
For my.planapulse.ai/{slug} (the BOS / account portal entrypoint), the flow is similar, but the HTTPRoute first tries known SPA paths against the pulse-account and pulse-account-api services and only falls through to the BOS workspace if nothing else matches.
Where to go next
- Kubernetes — the cluster details, nodes, namespaces, quotas
- Envoy Gateway — listeners, routes, ExtAuthz wiring
- Crossplane — the four XRDs and how reconciliation works
- Domains — the full subdomain table and which service answers
- Multi-version Odoo — how 17/18/19 coexist
- Tenant isolation — what is shared, what is not, and why