HomeDocs › Production Deployment Topologies

Production Deployment Topologies

SpatiumDDI is designed to scale from a single homelab VM all the way to a distributed multi-region deployment with the control plane in the cloud and DNS/DHCP agents running on-prem. This guide walks through seven reference topologies and shows which knobs each one flexes — including the self-contained multi-node appliance HA path (Topology 7), where you promote OS appliances into a 3/5/7-node control-plane cluster from the web UI with no Patroni / Helm / kubectl to operate.

For the per-platform install steps, see:


What needs to scale, and how

Concern Where it lives How it scales
Control plane API (api) FastAPI process Stateless — run N copies behind a load balancer
Async work (worker) Celery worker process N copies, sharded by queue (ipam, dns, dhcp, default)
Scheduler (beat) Celery beat Exactly one instance — not horizontally scalable. Run two with leader-election (celery-beat-leader) only if you need HA
Frontend (frontend) nginx + static Vite build Stateless — horizontally scalable, often co-located with the API behind the same LB
PostgreSQL Stateful, primary write source Patroni (bare-metal/VM), CloudNativePG (K8s + the OS appliance) for HA. On the appliance, CNPG instances scale automatically with the promoted control-plane member count (#272). Read replicas optional — SpatiumDDI doesn’t currently route reads to replicas
Redis Cache + Celery broker + factory-reset lock Sentinel for HA; single-node fine until you need >1 worker host. On the appliance, Sentinel replicas scale with the member count
Appliance control plane (k3s) Single-binary k3s on each OS-appliance node Promote Appliances in the Fleet UI → embedded-etcd quorum (3/5/7) + a MetalLB control-plane VIP. Self-contained — no external DCS / load balancer to run (Topology 7)
DNS agents (BIND9 + sidecar) Per DNS server One agent per DNS server. 2-node HA via primary/secondary or split-horizon view
DHCP agents (Kea + sidecar) Per DHCP server One agent per DHCP server. Group-centric Kea HA across 2+ peers
Backup destinations External (S3/SCP/Azure/SMB/FTP/GCS/WebDAV) Out-of-band — operator’s responsibility. SpatiumDDI just writes archives there

The control plane’s blast radius is roughly the API container’s writable layer (which is empty when scheduled targets push to a remote destination). The agents survive control-plane outages because they long-poll the /config endpoint with an ETag and cache the last-known-good config locally — non-negotiable #5 in CLAUDE.md.


Topology 1 — Single VM (dev / homelab)

Everything in one Docker Compose stack. This is the default make up target and what most homelab installs run.

Topology 1 — Single VM

Pros: simplest to run, fastest to bring up, single backup unit. Cons: every service shares one host. A noisy neighbor (e.g. a DHCP storm filling Kea’s lease store) can degrade the API. No HA.

Sizing: 4 vCPU / 8 GB / 50 GB SSD comfortably handles up to ~5,000 IP addresses across ~50 subnets, ~50 DNS zones, and a modest DHCP scope set. Past that, split off the agents (Topology 2).


Topology 2 — Control plane + separate DNS/DHCP appliances

The shape most production deployments reach for first. Control plane on its own VM; DNS and DHCP agents on dedicated, network-edge machines. Agents long-poll the control plane’s API.

Topology 2 — Control plane + separated DNS/DHCP appliances

What this gets you:

The two compose files:

# On vm-dns (BIND9 — pick `agent-dns-powerdns.yml` instead for PowerDNS):
export CONTROL_PLANE_URL=https://spatium-cp.corp.local
export DNS_AGENT_KEY=<paste-from-Settings→Security→Agent bootstrap keys>
export DNS_HOSTNAME=vm-dns.corp.local
docker compose -f docker-compose.agent-dns-bind9.yml up -d

# On vm-dhcp:
export SPATIUM_API_URL=https://spatium-cp.corp.local
export SPATIUM_AGENT_KEY=<paste-from-Settings→Security→Agent bootstrap keys>
export DHCP_HOSTNAME=vm-dhcp.corp.local
docker compose -f docker-compose.agent-dhcp.yml up -d

The agent registers with the control plane on first boot, exchanges its PSK for a rotating JWT, then long-polls forever. See DNS_AGENT.md for the protocol.

OS-appliance agents take a different path. The compose + SPATIUM_AGENT_KEY recipe above is the raw-Docker route. If your agents are SpatiumDDI OS appliances, install the Appliance role and onboard them with an 8-digit pairing code from /appliance → Pairing — no hex key to copy. The agent’s supervisor registers, the operator approves it on /appliance → Fleet, and roles are assigned from the UI. See the README’s “Joining DNS / DHCP agents” section + APPLIANCE.md.


Topology 3 — DNS + DHCP HA pairs

Production-grade availability. Multiple DNS servers in a server group (primary + secondary, or split-horizon views), Kea DHCP HA across two peers. Each DNS or DHCP host is its own VM.

Topology 3 — DNS + DHCP HA pairs

Configured via the UI:

The DNS and DHCP agents continue to long-poll the control plane; if the API is unreachable, both sides serve from cache and Kea HA continues operating against its peer regardless of control-plane state.


Topology 4 — HA control plane (Patroni + Redis Sentinel)

Removes the control plane as a single point of failure. Two or more API hosts behind a load balancer; PostgreSQL via Patroni (3 nodes is the standard quorum); Redis via Sentinel. Beat stays single-instance (see the table at the top — it’s not horizontally scalable without leader-election).

Running the OS appliance? See Topology 7 instead — it gives you this same HA shape self-contained (bundled CNPG + Redis Sentinel + embedded etcd + a MetalLB VIP), scaled by promoting appliances in the Fleet UI with no Patroni / LB / kubectl to run. Topology 4 is for hand-rolled bare-metal / Docker control planes.

Topology 4 — HA control plane with Patroni + Redis Sentinel

DNS / DHCP agents per Topology 2 / 3 above, pointing at the LB URL.

What’s in the repo for this:

The API is stateless — no in-memory session state — so a request hitting either api host gets the same answer. Sticky LB only matters for the SSE streams (chat orchestrator, scheduled-target-archives list); the LB just needs to keep the connection on the same backend once it’s open.

Beat HA caveat. Celery beat is intentionally single-instance. Running two beats without leader-election double-fires every scheduled task. If you need beat HA, use celery-beat-leader (Redis- based leader election) and configure each beat instance with the same lock key. We don’t ship that today — for most installs the “recreate beat from a healthy backup” recovery path is fine.


Topology 5 — Hybrid cloud: control plane in cloud, agents on-prem

Control plane runs in your cloud account (AWS / Azure / GCP / Hetzner / DigitalOcean / Vultr — any provider). DNS and DHCP agents stay on-prem because they’re network-path dependencies — DNS recursion has to terminate close to the queriers, DHCP has to be on the same broadcast domain as its clients.

Topology 5 — Hybrid cloud control plane + on-prem agents

Why this works:

  1. The control plane’s only outbound dependency is the database. Cloud provides better postgres HA than most on-prem racks.
  2. The agents don’t need a persistent control-plane connection. They long-poll over HTTPS; the connection can drop for hours and they keep serving from cache.
  3. Backup destinations don’t have to be in the same region. A cloud control plane can write nightly archives to an on-prem SCP target (or vice versa). See the SCP / S3 / Azure / SMB / FTP / GCS / WebDAV driver matrix in docs/features/SYSTEM_ADMIN.md §2.9.

Caveats:

Cloud variants of each component:

Component AWS Azure GCP Comment
API + worker + beat EC2 + ALB / ECS Fargate / EKS VM Scale Set + App Gateway / AKS GCE + L7 LB / GKE Stateless; treat as cattle
Postgres RDS (Multi-AZ) Azure Database for PostgreSQL Cloud SQL All three honor postgresql+asyncpg:// URLs
Redis ElastiCache (cluster mode) Azure Cache for Redis Memorystore Sentinel not required when the managed service does failover
Backup destination S3 native Azure Blob native GCS native All three driver kinds ship in the registry
Frontend (static) CloudFront + S3 Azure CDN + Blob Cloud CDN + GCS Or just serve from the same LB

Topology 6 — Kubernetes (single chart, multi-node)

The Kubernetes flavour of Topology 4. The umbrella Helm chart (charts/spatiumddi) ships every component — api / worker / beat / frontend / migrate / Postgres + Redis subcharts (or external endpoints) / optional DNS+DHCP agent StatefulSets.

Topology 6 — Kubernetes via the umbrella Helm chart

The chart’s relevant values:

postgresql.kind: cnpg           # HA Postgres via the CloudNativePG operator
redis.kind: sentinel            # HA Redis via the in-chart sentinel shape
api.replicas: 3
worker.replicas: 4
worker.netRawCapability: true   # for passive DHCP fingerprinting
dnsAgents.enabled: true         # spawns the bind9 StatefulSet
dhcpAgents.enabled: true        # spawns the kea StatefulSet (group-centric HA)

Beat is always a single-replica singleton — there’s no toggle for it.

See ../../k8s/README.md for the Helm-vs-raw-manifest walkthrough, the RWX PVC overlay needed for local_volume backup targets, and the upgrade-flow recipe.


Topology 7 — Appliance multi-node control-plane HA (#272)

The self-contained version of Topology 4, built without touching Patroni, Helm values, or kubectl. Every SpatiumDDI OS appliance is a single-node k3s cluster; you scale the control plane to 3 / 5 / 7 nodes by promoting appliances from the web UI (/appliance → Fleet → Manage control plane cluster…). This is the recommended HA path for operators who installed from the ISO.

Topology 7 — Appliance multi-node control-plane HA

How it differs from Topology 4 / 6:

Bring-up:

  1. Install the first appliance (ISO, Control plane role) — that’s the etcd seed + a working one-node control plane.
  2. Install two more appliances and pair them (Appliance role, or a second/third Control-plane install).
  3. /appliance → Fleet → Manage control plane cluster… → select the two → promote. Within ~60 s: etcd quorum across 3, CNPG at 3 instances, Redis Sentinel 3-node, api/worker/frontend at 3 replicas.
  4. Appliance → Network & Host → set a MetalLB pool + a control-plane VIP. The frontend moves onto the VIP; the cert auto-covers it. Point every off-cluster DNS/DHCP agent’s control-plane URL at this VIP.

Failure behaviour: lose one of three nodes → etcd keeps quorum (2/3), CNPG fails over to a replica, the MetalLB VIP re-homes to a surviving node, the UI stays up on the same address. Bring the node back, or replace it — operator-driven dead-node replacement is the Phase 9 follow-up tracked on #272.

Demote / teardown: demoting members in the Fleet UI reverses the scale (the etcd seed can’t be demoted; demoting to an even count is refused). A control-plane node can’t be revoked while it’s a live cluster member — demote it first.

When to use Topology 4 / 6 instead: you already run a managed Postgres (RDS / Cloud SQL) or an existing Kubernetes platform, or you want read-scale / cross-region shapes the appliance doesn’t bundle. Topology 7 is the “I want HA without becoming a Postgres + Kubernetes operator” answer. Full design + the live shake-out log live in issue #272; the appliance internals are in APPLIANCE.md.


PowerDNS-primary + BIND-secondary hybrid (driver crossover)

SpatiumDDI ships two authoritative DNS drivers: BIND9 (default) and PowerDNS. Each DNSServerGroup is single-driver — but multiple groups, each on a different driver, can serve overlapping namespaces via plain AXFR or RFC 9432 catalog zones. Two operator-relevant shapes:

Shape A — PowerDNS-primary + BIND-secondary on the same zones. PowerDNS handles writes (online DNSSEC signing, ALIAS records, LUA records); BIND fans the zones out to a battle-tested edge. Catalog-zone-driven AXFR keeps both drivers in sync because the producer-side catalog renderer emits identical wire bytes from either driver (issue #127 Phase 3d).

Operators ─→ SpatiumDDI control plane
                │
                ├── DNSServerGroup "internal-pdns" (driver=powerdns)
                │     ├── pdns1.corp  (primary, signs DNSSEC)
                │     └── pdns2.corp  (secondary)
                │
                └── DNSServerGroup "edge-bind"  (driver=bind9)
                      ├── bind1.edge  (catalog consumer of internal-pdns)
                      └── bind2.edge  (catalog consumer of internal-pdns)

Wire it up:

  1. In internal-pdns set catalog_zones_enabled=true + catalog_zone_name=catalog.example.com.. The PowerDNS primary publishes the catalog zone alongside member zones via the agent’s REST patch path.
  2. In edge-bind, create each fanned-out zone as zone_type=secondary (or stub) with its per-zone masters list pointing at the PowerDNS primary (an ip or ip@port string, issue #336). BIND loads each as type slave; and AXFRs from the master; a catalog_zones_enabled=true + catalog_zone_name=catalog.example.com. setting on the edge-bind group additionally distributes the member list across every BIND member of the group via one RFC 9432 catalog (BIND 9.18+).
  3. Operator changes land via the SpatiumDDI UI → write-through to PowerDNS REST → PowerDNS bumps zone serial → BIND notices via NOTIFY-then-IXFR.

Shape B — Per-zone driver placement (not crossover). Some zones live on PowerDNS (those with ALIAS / LUA / DNSSEC needs), some on BIND9 (those with first-class views or RPZ blocklists). Each zone is owned by exactly one group; the operator picks the right group at zone-create time. The Operator Copilot’s propose_create_dns_zone tool accepts a driver_hint arg that picks a matching group automatically — dnssec_enabled=true requires driver_hint=powerdns and rejects against any group with no PowerDNS member.

The driver-feature gate (server-side, _DRIVER_GATED_RECORD_TYPES + _DRIVER_GATED_OPERATIONS) means a PowerDNS-only feature called against a BIND group returns 422 with a remediation message (“move the zone to a PowerDNS-only group”). Mixed-driver groups are also rejected — an “internal-pdns” group cannot have one PowerDNS plus one BIND9 server, by design.


Migrating a BIND9 group to PowerDNS without DNS downtime

Replacing a BIND9 group with a PowerDNS group on the same zones is a four-step recipe. The IPAM ↔ DNS sync, RBAC, and DDNS pipeline don’t change — only the driver underneath the zones.

Pre-flight check. Inventory zones with PowerDNS-incompatible features: classic views (PowerDNS does views via tags — no auto-translation today), RPZ blocklists (recursor-only, not authoritative). If any zone in the group uses these, leave that zone on BIND9 and only migrate the others. The control plane allows mixed installs precisely so you can split the unmigratable zones off into a separate group.

1. Stand up the PowerDNS group alongside BIND9. Deploy a PowerDNS agent (Compose --profile dns-powerdns, Helm dnsAgents.servers[].flavor: powerdns, or a standalone-VM docker-compose.agent-dns-powerdns.yml) and let it auto-register. In the SpatiumDDI UI, create a new DNSServerGroup with driver=powerdns and the registered server as its primary. Both groups are now live; the BIND group still serves all zones.

2. Secondary-zone copy via AXFR. For each zone you’re migrating, create a matching zone_type=secondary zone in the PowerDNS group with its per-zone masters list pointing at the BIND primary (an ip or ip@port string, issue #336). PowerDNS loads each as a slave and AXFRs the current contents from BIND. Wait one sync cycle (~60 s), then verify dig +short SOA <zone> @<powerdns-pod> matches the BIND primary’s serial. No record edits should land during this window — schedule the migration for a low-write period.

3. Promote PowerDNS to primary. In the UI, edit each zone and switch its group_id to the PowerDNS group, then flip its zone_type from secondary back to primary (which clears the masters pointer). The control plane: (a) write-through-PATCHes the zone to PowerDNS as primary, (b) creates the primary entry, (c) rebuilds the SOA. BIND can stay on the zone as a plain secondary (read-only, with its masters now pointing at PowerDNS) for a soft cutover, or be removed entirely. DDNS / IPAM auto-sync continue uninterrupted because they target the zone by ID, not by driver.

4. Sign zones (optional). With the zones now on PowerDNS, signed zones are a single click — open the zone-edit modal, hit Sign zone in the DNSSEC card. PowerDNS generates KSK + ZSK (online signing), rectifies the zone, and the agent reports DS records back to the control plane. Re-publish DS records to your registrar — that’s the only out-of-band step. The DNSSEC card has a per-DS copy-to-clipboard button. See issue #127 Phase 3c for the full flow.

Rollback recipe. If something goes wrong between step 2 and step 3, the BIND group is still authoritative — flip the zones back via the UI. If something goes wrong after step 3, the secondary on BIND is still serving the last-known-good zone state; promote it back to primary by switching the zone’s group back to BIND and setting its zone_type to primary. The IPAM ↔ DNS reconciler will pick up any record drift on the next 60-s sync cycle and re-stamp the zone.

Post-restore caveat for DNSSEC zones. PowerDNS DNSSEC keys live on the agent’s LMDB volume, not in the control-plane backup. A factory-reset + restore-from-backup that wipes the agent’s volume regenerates keys and produces NEW DS records — which means a new round-trip to the registrar. The restore endpoint surfaces this as a RestoreOutcomeResponse.warnings[] advisory listing every signed zone; the BackupPage UI renders it as an amber callout. See issue #127 Phase 4d.


Picking a topology

You have… Start with
A single homelab box, 3 people max using it Topology 1
One DC, network-edge appliances expected Topology 2
One DC, downtime-sensitive DNS or DHCP Topology 3
Hand-rolled bare-metal / Docker HA control plane Topology 4
Branch offices feeding back to a HQ control plane Topology 5
Anything K8s-native Topology 6
HA control plane from the OS appliance, no Patroni/kubectl Topology 7
Need ALIAS / LUA / one-toggle DNSSEC Add a PowerDNS group to any of the above

You can move between topologies without re-installing. Going from Topology 1 → 2 → 3 → 4 is purely additive: new VMs join the existing control plane via the agent-key bootstrap. The database doesn’t move; you just point more agents at the same API URL with the bootstrap key from Settings → Security → Agent bootstrap keys, and each agent registers itself on first boot. On the OS appliance, the 1 → 3 → 5 control-plane scale (Topology 7) is likewise additive — you promote more appliances from the Fleet UI, no reinstall and no database move.


Backup + factory-reset across topologies

Backup and factory-reset (issues #117 + #116) work the same regardless of topology — they hit the API endpoints, which are stateless. A few shape notes:


Fleet firewall posture across topologies (#285)

The declarative fleet firewall (full operator story in the “Fleet firewall” section of APPLIANCE.md) applies to the appliance topologies (1, 2, 3, 7) — Docker-Compose / generic K8s control planes (4, 5, 6) manage host firewalling with their own tooling. It’s dark by default (the appliance.firewall module + the firewall_enabled master switch); when enabled, each node’s drop-in is compiled server-side from the fleet → role → appliance policy layers:

Air-gapped / manual join under enforcement

A joining k3s node must reach the seed’s apiserver on 6443 before the supervisor can render its own peer-scoped drop-in (a bootstrap chicken-and-egg). The appliance keeps 6443 LAN-reachable via a baked sentinel drop-in (/etc/nftables.d/00-spatium-k3s-bootstrap.nft) for exactly this window — etcd


What’s NOT supported (yet)