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>
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
proxyDocker 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
proxyand 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) andtank/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 sendstays cheap. - Snapshots per customer on a schedule;
zfs sendoffsite 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 sendship 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 sendit if checksummed/ immutable offsite is wanted.
- automysqlbackup produces per-database dumps with daily/weekly/monthly
rotation into
- 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:
- Creates the ZFS dataset(s).
- Renders
docker-compose.yml+.envfrom the profile template. - Creates the shared-DB database + user (for profiles needing DB).
- Commits rendered config to the
deploymentsrepo (audit trail). - Runs
docker compose up -din 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
- Architecture overview + threat model — boundaries and what they do/don't protect (this doc is the seed).
- ADRs — one short record per major call: Traefik edge, shared MariaDB, Forgejo-vs-GitLab, CLI-first-vs-panel. Captures the why.
- Naming & conventions — dataset, network, container, database, domain naming.
- Site profile spec — exact contents of wordpress / custom-php / static / redirect.
- Provisioning workflow — signup → live, step by step.
- 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.
- Repo layout / GitOps doc.
- 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-infrarepo with idempotent Ansible.
Phase 2 — Platform services
- Deploy Traefik (ACME/TLS,
proxynetwork, 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-panelCLI: provision / deprovision / list, rendering templates, creating ZFS datasets, creating DB+user, committing todeployments,compose up.- SFTP access per customer (chrooted).
Phase 5 — Backup & DR
- Web files: ZFS snapshot schedule +
zfs sendoffsite (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:
- Routing/TLS: provision a test
staticsite → reachable over HTTPS via Traefik with a valid Let's Encrypt cert. - 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.
- DB least-privilege: confirm customer A's DB user cannot see B's database.
- Provisioning round-trip: CLI provisions a WordPress site from zero (dataset + DB + compose + route) and it serves; then deprovision cleans up.
- Backup/restore drill (the critical one): ZFS snapshot +
zfs sendof 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). - CI security gate: Trivy/gitleaks fail a build on a known-vulnerable image or planted secret.