# Architecture ![Placeholder diagram: architecture-dual-backend](../_images/screenshots/architecture-dual-backend.svg) *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 API** — `dk_customer_portal_api`, an Odoo module exposing that REST API as `auth="none"` HTTP controllers with their own bearer-token auth (see [Authentication](authentication.md)), 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 backend** — `saas.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](api/instances.md)), 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-`) 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. :::note 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](../getting_started/first-instance.md)). The provisioner itself fully supports both. ::: See [Domains](../administrator/domains.md), [Resource limits](../administrator/resource-limits.md), and [Security](../administrator/security.md) for how DNS, sizing, and tenant isolation work on each backend in more detail. :::note 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-` PVC, mounted at `/mnt/extra-addons`. - **Docker:** a per-tenant `DOCKER_ADDONS_BASE_PATH//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](api/modules.md)) 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 `` element (not the deprecated ``), `widget="badge"` for status fields, and the current `invisible` attribute syntax rather than `attrs`.