Architecture

Placeholder diagram: architecture-dual-backend Placeholder image — replace with a real diagram.

Platform layers

The DK SaaS platform is a multi-tenant Odoo 18 deployment with three main layers:

  1. Customer portal — React frontend (dk_customer_portal, served at portal.dishonkadoh.com) talking to a REST API.

  2. Portal APIdk_customer_portal_api, an Odoo module exposing that REST API as auth="none" HTTP controllers with their own bearer-token auth (see Authentication), backed by a set of saas.* models: saas.tenant, saas.instance, saas.plan, saas.subscription, saas.configuration, saas.invoice, saas.ticket, saas.backup, saas.activity, saas.notification, saas.instance.metric, saas.user.invitation, saas.custom.module, saas.demo, plus dk.mobile.token for API auth.

  3. Provisioning backendsaas.provisioner, an Odoo AbstractModel that creates and manages the actual per-tenant Odoo instances.

Dual provisioning backend

saas.instance dispatches provisioning to one of two backends, selected per instance (the backend field on POST /api/instances/create — see Instances), via a _backend_for(instance) helper on saas.provisioner that falls back to SAAS_DEFAULT_PROVISION_BACKEND (default kubernetes) when unset:

  • Kubernetes (AWS EKS) — each tenant gets its own namespace (saas-<subdomain>) with a per-tenant Deployment, Service, and ALB Ingress. Postgres is shared, reached at postgres.odoo.svc.cluster.local; each instance gets its own filestore PVC and a separate custom-addons PVC. Tenant secrets (odoo-env, dockerhub-credentials) are copied from a source saas namespace on first provisioning. DNS is a Cloudflare CNAME pointed at the ALB’s hostname.

  • Docker — per-tenant containers on a shared bridge network, routed by Traefik labels on the container itself (no separate ingress object — the container’s labels are the routing config). Filestore and custom-addons are per-tenant host directories, bind-mounted in — set via DOCKER_FILESTORE_HOST_PATH and DOCKER_ADDONS_BASE_PATH respectively, which must be real paths on the Docker host’s filesystem (the provisioner itself runs inside a container, so it can’t assume visibility into these paths, and creates/inspects them through a throwaway container instead). DNS is a Cloudflare A/CNAME record pointed directly at DOCKER_HOST_PUBLIC_ADDRESS, since there’s one known host rather than a load balancer to wait on.

Both paths are implemented behind that dispatch layer inside the saas.provisioner abstract model — every public entry point (provision_instance, reprovision_instance, collect_metrics, create_user_in_instance, etc.) just checks instance.backend and forwards to the matching _..._kubernetes / _..._docker implementation, so no caller needs its own backend branching. The Kubernetes path uses the kubernetes Python client (including pod exec over the websocket API for running commands inside a tenant’s container); the Docker path uses the docker SDK.

As of this writing, the frontend only lets customers select Docker when creating an instance — Kubernetes is implemented and used internally but temporarily disabled in the create-instance UI (see Create your first ERP instance). The provisioner itself fully supports both.

See Domains, Resource limits, and Security for how DNS, sizing, and tenant isolation work on each backend in more detail.

The provisioner originally used SSH orchestration (paramiko, Fernet-encrypted credentials, Jinja2-rendered Docker Compose templates) before migrating to the Kubernetes/Docker dual-backend design described above.

Custom addons

Instance creation does not depend on custom_module_ids — on both backends, the custom-addons volume starts completely empty:

  • Kubernetes: a per-tenant addons-<subdomain> PVC, mounted at /mnt/extra-addons.

  • Docker: a per-tenant DOCKER_ADDONS_BASE_PATH/<subdomain>/addons/ directory, bind-mounted to the same path.

Adding custom addon code is a separate step from installing it. Registering a Git source (POST /api/addon-sources, see Modules) and choosing a module to deploy triggers deploy_custom_module_to_instance(), which:

  1. Clones the source repo to a temp directory (shallow, single branch).

  2. Validates the selected module’s technical name and path (rejecting any ../ traversal or a path that doesn’t match the module’s own technical name).

  3. Packages only that one addon into an in-memory tar archive.

  4. Transfers it into the tenant’s own storage — via put_archive() through a throwaway container on Docker, or over the pod-exec stdin stream (base64-encoded) on Kubernetes — and restarts only that tenant.

The customer still has to explicitly install the module from Apps afterward — deployment never calls install_custom_module() itself. Git ADD ≠ Odoo INSTALL.

Frontend conventions

dk_customer_portal’s HTML shell follows the same template as the main dishonkadoh.com site (Google Analytics, full SEO/OG/JSON-LD tags), with noindex set since the portal itself is behind authentication. Tailwind is configured with the site’s navy (#1B3A7A family) and teal design tokens, League Spartan for headings and Quicksand for body text — the same design system used across DK’s other frontends.

Odoo view conventions

All views in this module and its siblings use Odoo 18’s <list> element (not the deprecated <tree>), widget="badge" for status fields, and the current invisible attribute syntax rather than attrs.