platform/docs/architecture-plan.md
Bart Van Geyt e5ff798dd4 Refine to nested customer -> site model
Adopt a two-level ownership model: a customer owns many sites, with
isolation/containers/DB/backup at the site level and grouping (one SFTP
login, recursive backup, billing, bulk delete) at the customer level.

- 03-naming-conventions: rewritten for customer/site ids and slug =
  <customer>-<site>; nested ZFS, per-customer SFTP chroot, deployments
  mirror the nesting.
- Propagated paths through docs 01, 04, 05, 06, 07 and deployments/README.
- ADR 0005 retitled/updated to per-site datasets nested under customer.
- architecture-plan.md: note pointing to doc 03 as authoritative on naming.

Clarifies subdomains: same-app subdomains are aliases on one site; a
separate-app subdomain is its own isolated site under the same customer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:37:39 +02:00

11 KiB

Rebuild: Multi-Tenant Web Hosting Platform (heleosv2)

Note: This is the point-in-time approved plan. Where it refers to the per-customer boundary/naming, the design was later refined to a nested customer → site model — see 03-naming-conventions.md, which is authoritative for naming and paths.

Context

An existing small web-hosting business runs nginx + PHP-FPM + MySQL on a shared host, serving a mix of custom PHP, WordPress, static HTML, and redirect-only sites. The goal is to rebuild on modern best practices with isolation, clean backups, and repeatable provisioning as first principles.

Core philosophy: the customer is the boundary. Isolation (containers), backup (ZFS dataset), and restore all line up on that same boundary, so a compromised or broken site is contained and can be restored without touching neighbours.

This document is an architecture + phased task plan, not a code change. No application code exists yet (greenfield). Implementation happens step-by-step after this plan is approved.

Decisions locked with the user

  • Host topology: single bare-metal host → Docker Compose per customer, no orchestrator, Ansible for host config, Terraform not needed initially.
  • Control panel: CLI/templating tooling first; customer-facing panel later once the platform is proven.
  • Database: one shared MariaDB instance (per-site DB + per-site user). Density over per-instance isolation.
  • DevOps stack: lightweight — Forgejo + built-in registry + Forgejo Actions/Woodpecker + Trivy.

Reframing carried into the design

Containers are an isolation boundary, not a hard security boundary (shared kernel). The real protections come from: non-root PHP-FPM, read-only rootfs where possible, per-customer Docker networks, egress filtering, least-privilege DB users, and (later) runtime detection. Docker alone is not the security story.


Target Architecture

Edge & routing

  • Traefik as the single edge: TLS termination, automatic Let's Encrypt (ACME), dynamic label-based routing. One shared proxy Docker network.

  • Traefik cannot speak FastCGI, so each PHP site runs its own small web server behind Traefik:

    Traefik (TLS + routing) → per-site nginx → per-site php-fpm

  • Static/redirect sites are served directly by Traefik or a tiny nginx — no PHP.

Per-customer stack (one Docker Compose project per customer/site)

Standard site profiles, each a compose template:

  • wordpress — nginx + php-fpm (+ shared-DB credentials)
  • custom-php — nginx + php-fpm (+ optional shared-DB credentials)
  • static — nginx (or Traefik direct), no PHP, no DB
  • redirect — Traefik router rule / tiny nginx, no PHP, no DB

Per-customer isolation:

  • Own Docker bridge network; only Traefik joins both proxy and the customer network, so customer containers are unreachable except through Traefik.
  • PHP-FPM runs non-root; rootfs read-only where the profile allows; web root is the only writable mount.

Storage — ZFS

  • Dataset layout, e.g.: tank/customers/<customer>/web (web root only — kept lean) and tank/platform/{docker,mariadb,db-backups,traefik,forgejo,monitoring}.
  • Docker images/layers on their own platform dataset; customer web data bind-mounted from per-customer datasets (clean per-customer snapshots).
  • The customer dataset deliberately holds only web files — no logs, no DB dumps — so an unchanged site produces a near-empty incremental and offsite zfs send stays cheap.
  • Snapshots per customer on a schedule; zfs send offsite for backup/DR.

Database (shared MariaDB) — decoupled backup stream

  • One MariaDB container; each site gets its own database + least-privilege user.
  • Live datadir lives on a platform dataset (not inside any customer dataset).
  • DB backups are a separate stream from web files (not folded into the customer ZFS dataset — that would make every incremental zfs send ship a fresh dump even for static sites):
    • automysqlbackup produces per-database dumps with daily/weekly/monthly rotation into tank/platform/db-backups/<customer> — per-customer restore granularity for free.
    • rsync those dumps offsite (lean; only changed dumps move). Optional later: put db-backups on its own dataset and zfs send it if checksummed/ immutable offsite is wanted.
  • Consistency: schedule the nightly DB dump close to the ZFS snapshot. The two streams aren't atomically consistent, but the small window is harmless for PHP/WordPress (files-on-disk + DB-rows).

File access

  • SFTP only (drop FTP — plaintext). Each customer chrooted to their dataset.
  • Optional web file manager (e.g. Filebrowser) added with the panel later.

Network security

  • Host firewall via nftables. Note explicitly: Docker manipulates iptables and bypasses ufw — firewall rules must account for Docker's chains.
  • Only 80/443 (+ SFTP, + admin SSH) exposed. Inter-customer traffic denied.
  • Egress filtering: block outbound SMTP except via an approved relay — stops a hacked WordPress becoming a spam source (common real incident).

Observability (phased)

  • Phase in: node_exporter + cAdvisor + Traefik metrics → Prometheus + Grafana; Loki + Promtail for logs. Add mysqld_exporter + Alertmanager later.
  • Uptime-Kuma for cheap uptime/status pages.

DevOps stack (lightweight)

  • Forgejo (git) + built-in container registry + Forgejo Actions (or Woodpecker) for CI.
  • Trivy (images/fs/IaC/secrets) + gitleaks in CI. Falco later for runtime detection of compromised containers. No Harbor, no GitLab, no Jenkins.

Provisioning model (CLI-first, GitOps-lite)

A CLI/templating tool takes (customer, site-profile, domain) and:

  1. Creates the ZFS dataset(s).
  2. Renders docker-compose.yml + .env from the profile template.
  3. Creates the shared-DB database + user (for profiles needing DB).
  4. Commits rendered config to the deployments repo (audit trail).
  5. Runs docker compose up -d in the customer directory. Deprovision/backup/restore are additional CLI subcommands. The customer-facing panel is a later wrapper over this same tooling.

Repository Structure

Small number of repos (not a monorepo, not one-per-thing) because secrets and per-customer state have a different lifecycle than platform code:

  • platform-infra — Ansible (Docker, ZFS, nftables, host bootstrap), base compose for Traefik / monitoring / Forgejo.
  • site-templates — Dockerfiles for standard images (wordpress, php-fpm, static) + compose templates per site profile.
  • deployments — one directory per customer; rendered compose + env refs. Secrets via SOPS/age or a vault — never plaintext.
  • control-panel — the CLI tooling now; the customer panel later.

Solo/small-team may start as one monorepo with these as top-level dirs, but keep the deployments/secrets boundary clean from day one.


Documents to Create Before Code

  1. Architecture overview + threat model — boundaries and what they do/don't protect (this doc is the seed).
  2. ADRs — one short record per major call: Traefik edge, shared MariaDB, Forgejo-vs-GitLab, CLI-first-vs-panel. Captures the why.
  3. Naming & conventions — dataset, network, container, database, domain naming.
  4. Site profile spec — exact contents of wordpress / custom-php / static / redirect.
  5. Provisioning workflow — signup → live, step by step.
  6. Backup & DR runbook — two decoupled streams (web files via ZFS snapshot/send; DB via automysqlbackup + rsync), cadence and near-simultaneous scheduling, offsite targets, single-customer two-step restore drill.
  7. Repo layout / GitOps doc.
  8. Roadmap / phased task plan (section below).

Phased Task Roadmap

Phase 0 — Documents & decisions

  • Write the 8 documents above (start from this plan).
  • Finalize naming conventions and dataset layout.

Phase 1 — Host baseline (Ansible)

  • Provision the bare-metal host: ZFS pool + datasets, Docker, nftables (with Docker-aware rules), admin SSH hardening, egress filtering.
  • platform-infra repo with idempotent Ansible.

Phase 2 — Platform services

  • Deploy Traefik (ACME/TLS, proxy network, dashboards secured).
  • Deploy shared MariaDB (own datadir dataset, tuned buffer pool).
  • Deploy Forgejo + registry.
  • Smoke test: a hello-world container routed + TLS via Traefik.

Phase 3 — Site templates & images

  • Build standard images (php-fpm non-root, nginx, static) in site-templates.
  • Trivy + gitleaks scanning in CI on those images.
  • Author compose templates for the 4 site profiles.

Phase 4 — Provisioning CLI

  • control-panel CLI: provision / deprovision / list, rendering templates, creating ZFS datasets, creating DB+user, committing to deployments, compose up.
  • SFTP access per customer (chrooted).

Phase 5 — Backup & DR

  • Web files: ZFS snapshot schedule + zfs send offsite (lean incrementals).
  • DB: automysqlbackup (per-DB, rotated) → db-backups → rsync offsite, scheduled close to the snapshot.
  • Restore drill: restore one test customer end-to-end from offsite — files from ZFS and DB from the dump repo (two coordinated steps).

Phase 6 — Observability

  • node_exporter + cAdvisor + Traefik metrics → Prometheus + Grafana; Loki + Promtail. Uptime-Kuma. Basic alerts.

Phase 7 — Migration

  • Migrate existing sites profile-by-profile (static/redirect first, then custom PHP, then WordPress). Cut DNS per site after validation.

Phase 8 — Customer panel (later)

  • Web panel wrapping the Phase 4 CLI: create/delete site, trigger backup, self-service restore, web file manager. Optionally Falco for runtime detection.

Verification / Validation

Because this is infrastructure, "done" is proven by drills, not unit tests:

  1. Routing/TLS: provision a test static site → reachable over HTTPS via Traefik with a valid Let's Encrypt cert.
  2. Isolation: from inside customer A's container, confirm you cannot reach customer B's containers/network or read B's files; confirm outbound SMTP is blocked.
  3. DB least-privilege: confirm customer A's DB user cannot see B's database.
  4. Provisioning round-trip: CLI provisions a WordPress site from zero (dataset + DB + compose + route) and it serves; then deprovision cleans up.
  5. Backup/restore drill (the critical one): ZFS snapshot + zfs send of web files, automysqlbackup + rsync of the DB, both offsite → restore that single customer onto a clean target (files from ZFS, DB from the dump repo) and verify site + data come back, neighbours untouched. Also verify an unchanged site yields a near-empty ZFS incremental (confirms the lean-dataset goal).
  6. CI security gate: Trivy/gitleaks fail a build on a known-vulnerable image or planted secret.