Skip to content

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:

NamespaceWhat runs there
plana-odoov17 tenant Odoo workers + the shared eg-gateway
plana-odoo-18v18 tenant Odoo workers — current default
plana-odoo-19v19 tenant Odoo workers — limited release
pulse-accountpulse-account-api, pulse-account Nuxt site, bos-portal (prod)
bos-demoBOS preview environment for development
authentikAuthentik server + worker (staff SSO)
pulse-eventsRedis Streams event bus
crossplane-systemCrossplane reconciler + per-tenant side-effect jobs
cert-managerLet's Encrypt automation
envoy-gateway-systemEnvoy control plane + data plane pods
crowdsecCrowdSec LAPI + agents + bouncer
monitoringPrometheus, Grafana, Alertmanager, Loki, Blackbox
forgejo, forgejo-runnerGit server + CI runners
matrixSynapse + Element Web + hookshot
nextcloudNextcloud + Collabora + Talk
penpot, vaultwarden, matomo, pgadminThe tools
backupPer-tenant PostgreSQL backup CronJobs
ai-bos-agent, ai-marketing-agentThe 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 via pgbouncer for tenants.
  • Redis / Valkeyredis.redis.svc.cluster.local:6379. Used for Odoo sessions and bus, BullMQ queues, the PLANA:events stream, and caches.
  • NFS filestorenfs01.planapulse.com. Per-tenant filestore subdirectory mounted into the Odoo workers.
  • Exoscale SOS — S3-compatible object storage. Daily pg_dump archives, 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 environment
  • TenantEnvironment — a single Odoo instance (most tenants have one)
  • TenantUpgrade — same-major or cross-major upgrade
  • TemplateSnapshot — 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:

  1. DNS resolves to the NLB at 194.182.177.67.
  2. NLB forwards TCP to one of the cluster's Envoy Gateway pods.
  3. Envoy terminates TLS using the *.planapulse.app wildcard cert.
  4. CrowdSec and Coraza filters run; if either blocks, the request stops here.
  5. Envoy matches an HTTPRoute keyed on host acme.planapulse.app.
  6. The route targets worker-odoo:8069 in one of plana-odoo, plana-odoo-18 or plana-odoo-19 — whichever namespace serves the tenant's current major version.
  7. Odoo reads the Host header, applies dbfilter=^%h$, opens the database acme.planapulse.app on pg01.
  8. 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

© PLANA Digital Ltd.