Kubernetes
Audience
PLANA staff working on infrastructure. Customers do not need this page.
PLANA runs on a single Kubernetes cluster operated by Exoscale SKS (Scalable Kubernetes Service). One cluster across all environments — we do not run a separate staging cluster. Isolation between environments is by namespace, not by cluster.
The cluster
| Property | Value |
|---|---|
| Name | plana-pulse-bg-sof-1 |
| Provider | Exoscale SKS |
| Region | bg-sof-1 (Sofia, Bulgaria) |
| Kubernetes version | 1.35.3 |
| Nodes | 3 |
| Container runtime | containerd |
| CNI | Calico |
| Load balancer | Exoscale NLB plana-pulse-eg-lb at 194.182.177.67 |
| kubeconfig | ~/.kube/config (default context) |
| Exoscale org | b51076cb-0409-45a9-8acf-d1153a7e86e9 |
The control plane is managed by Exoscale; we operate only the data plane (worker nodes) and the workloads on top.
Namespaces
A namespace is a per-concern boundary. Tenant data lives in plana-odoo[-{17,18,19}]; everything else is a per-product or per-tool namespace.
Tenant namespaces
| Namespace | Purpose | Notes |
|---|---|---|
plana-odoo | v17 tenant workers + the shared eg-gateway | Legacy version; will be emptied as v17 tenants migrate. The eg-gateway Gateway resource lives here permanently. |
plana-odoo-18 | v18 tenant workers — current default | Where new tenants land |
plana-odoo-19 | v19 tenant workers — limited release | Empty Deployment scaled to 0 until first tenant lands |
Each version namespace has its own worker-odoo Deployment with HPA, its own filestore PVC, and its own quota.
Product namespaces
| Namespace | Purpose |
|---|---|
pulse-account | pulse-account (Nuxt), pulse-account-api, bos-portal (prod) |
bos-demo | BOS development / preview |
authentik | Authentik server + worker — PLANA staff SSO |
pulse-events | Redis Streams event bus consumer + producer wrappers |
crossplane-system | Crossplane reconciler + per-tenant side-effect Jobs |
cert-manager | Let's Encrypt issuance |
envoy-gateway-system | Envoy Gateway control + data plane |
crowdsec | LAPI, agents, bouncer (ExtAuthz target for Envoy) |
monitoring | Prometheus, Grafana, Alertmanager, Loki, Blackbox Exporter |
falco | Falco — deprecated, kept disabled (see Security → Runtime security) |
backup | PostgreSQL backup CronJobs (one per tenant) |
ai-bos-agent | Multi-tenant BOS agent runtime |
ai-marketing-agent | Multi-tenant marketing agent runtime |
Tools namespaces
| Namespace | Purpose |
|---|---|
forgejo | Git server (git.planapulse.com) |
forgejo-runner | CI runners for the main org |
matrix | Synapse, Element Web, hookshot |
nextcloud | Nextcloud + Collabora + Talk |
penpot | Penpot (self-hosted Figma) |
matomo | Matomo 5 + MariaDB 11 |
vaultwarden | Vaultwarden (Authentik-only SSO) |
pgadmin | pgAdmin for ad-hoc DB inspection |
openphysica | External client project on shared infra |
Customer cluster operations
Customer-visible operations all happen by applying CRs in crossplane-system. There is no operator-only namespace customers can see.
Worker nodes
Three Exoscale instances, each Medium (4 vCPU, 8 GiB) or larger depending on load. Autoscaling is handled at the cluster level — nodes can be added when the scheduler pressures, but the default is a fixed pool of three.
Each tenant Odoo worker runs as a normal Deployment with HorizontalPodAutoscaler on CPU. We do not pin tenants to nodes — pods are scheduled wherever there is capacity. Anti-affinity rules ensure HPA replicas of the same Deployment land on different nodes.
Resource quotas
Every product namespace has a ResourceQuota to prevent one tenant or service from consuming the entire cluster:
| Namespace | CPU limit | Memory limit | Pod cap |
|---|---|---|---|
plana-odoo | 10 | 14 GiB | 14 |
plana-odoo-18 | 10 | 14 GiB | 14 |
plana-odoo-19 | 4 | 6 GiB | 8 |
pulse-account | 4 | 6 GiB | 8 |
ai-bos-agent | 2 | 3 GiB | 4 |
Quotas are intentionally tight. When CI bumps them — for example when running a TemplateSnapshot Job alongside HPA worker replicas — we re-size afterwards rather than leaving headroom open.
Storage classes
| Class | Provider | Use |
|---|---|---|
exoscale-bs-retain | Exoscale block storage (retained on PVC delete) | Tool databases (Matomo MariaDB, Nextcloud, Forgejo, Vaultwarden, etc.) |
exoscale-bs-delete | Exoscale block storage (deleted with PVC) | Ephemeral build caches |
| (NFS, via PV) | nfs01.planapulse.com | Per-tenant Odoo filestore |
The NFS filestore is mounted into each Odoo worker pod under /var/lib/odoo/filestore/<database-name>. The mount is sub-path-scoped per tenant, so a worker pod for tenant A cannot read tenant B's files even though both are on the same NFS export.
kubectl and Flux
Day-to-day cluster work is split between two access patterns:
kubectl— for ad-hoc reads, debugging, and one-off Jobs.- Flux GitOps — for everything declared in
infra/k8s/. Flux reconciles themainbranch ofplana-pulse/infrainto the cluster on a short interval. Manifests merged tomainare deployed without human intervention.
A strict rule: never kubectl apply a manifest that is also managed by Flux. The reconciler will revert your change within seconds, and you risk clobbering an in-flight Flux change at the same time. Edits go through Pull Request → Flux reconcile. See Flux GitOps for the procedure.
CRI, networking, and DNS
- Container runtime: containerd (Exoscale default).
- CNI: Calico, with NetworkPolicy enforcement enabled.
- DNS: CoreDNS in
kube-system. Pod DNS resolves*.svc.cluster.localand an internal*.planapulse.comzone via the Exoscale resolver. - Hairpin NAT: the Exoscale NLB does not hairpin — a pod inside the cluster cannot reach a
*.planapulse.comhostname over the public LB IP. We work around this in two patterns: a hostAlias inside the pod pointing to the Envoy ClusterIP, or in-cluster Service URLs everywhere. See Shared infrastructure → Mailu for an example.
Where to read more
- Envoy Gateway — how the ingress works in detail
- Crossplane — declarative provisioning
- Tenant isolation — what each tenant cannot do to another
- Operations → Flux GitOps — the change-control process