Phase 2: platform service stacks + CLAUDE.md

Add base Docker Compose projects under platform-infra/stacks, using the
Phase 1 ZFS platform datasets and shared networks:

- bootstrap-networks.sh: idempotent creation of shared `proxy` (edge) and
  `platform` (internal) Docker networks.
- traefik: edge router with TLS/ACME (HTTP-01), global HTTP->HTTPS redirect,
  file-based security-headers/TLS middlewares, and a basic-auth dashboard.
  Docker access via a least-privilege tecnativa/docker-socket-proxy instead
  of mounting docker.sock directly.
- mariadb: shared instance on tank/platform/mariadb, utf8mb4, tuned; not
  published to the host (internal `platform` network only).
- forgejo: Git + container registry over HTTPS (SSH disabled to avoid extra
  inbound ports), SQLite backend, data on tank/platform/forgejo.

Each stack ships a committed .env.example (real .env is git-ignored) and the
stacks README documents bring-up order, secrets, and verification.

Also add root CLAUDE.md so fresh sessions orient from files cheaply:
philosophy, doc pointers, naming quick-ref, repo map, commands, agreements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bart Van Geyt 2026-07-07 16:58:18 +02:00
parent d715244b76
commit 41a01c60ef
11 changed files with 342 additions and 0 deletions

65
CLAUDE.md Normal file
View file

@ -0,0 +1,65 @@
# CLAUDE.md — heleosv2 platform
Multi-tenant web hosting platform: **container-per-site + ZFS-dataset-per-site**,
CLI-driven provisioning, decoupled per-site backup/restore. Single bare-metal
host, Docker Compose per site, no orchestrator.
> **Read `docs/` first — it is the source of truth.** Start with
> [docs/00-roadmap.md](docs/00-roadmap.md). This file is just the orientation
> map so a fresh session gets up to speed cheaply.
## Core philosophy
1. **The customer is the boundary** — isolation, backup, restore align on it.
2. **Two levels:** a `customer` owns many `site`s. Isolation/containers/DB/network
are per **site**; grouping (one SFTP login, recursive backup, billing) is per
**customer**. See [docs/03-naming-conventions.md](docs/03-naming-conventions.md).
3. **Containers isolate, they don't secure by themselves** — non-root FPM,
read-only rootfs, per-site networks, egress filtering, least-priv DB users.
4. **Each backup stream matches its change pattern** — web files via ZFS
snapshot/`send`; DB via automysqlbackup + rsync; logs via Loki. Never fold the
DB dump into the web dataset (it bloats every incremental).
5. **CLI/templates before UI.** Prove the platform, then wrap it in a panel.
## Status
- ✅ Phase 0 — design docs, ADRs, scaffold ([docs/](docs/00-roadmap.md))
- ✅ Phase 1 — host baseline Ansible ([platform-infra/ansible/](platform-infra/ansible/README.md))
- 🚧 Phase 2 — platform services (Traefik, MariaDB, Forgejo) in `platform-infra/stacks/`
- ⬜ Phases 38 — site templates, provisioning CLI, backup/DR, observability, migration, panel
## Repo map
| Path | Purpose |
|------|---------|
| `docs/` | Architecture, ADRs, runbooks, conventions (**authoritative**). |
| `platform-infra/ansible/` | Phase 1 host baseline (ZFS, Docker, firewall, SSH). |
| `platform-infra/stacks/` | Phase 2 base compose projects (Traefik/MariaDB/Forgejo). |
| `site-templates/` | Dockerfiles + per-profile compose templates (Phase 3). |
| `deployments/` | Rendered per-site configs `<customer>/<site>/` (GitOps state). |
| `control-panel/` | Provisioning CLI (Phase 4), panel later. |
## Naming quick-ref (see docs/03)
- `customer` + `site` ids; `slug = <customer>-<site>` (Docker/DB key).
- ZFS: `tank/customers/<customer>/<site>/web`; platform on `tank/platform/*`.
- DB: `db_<customer>_<site>` + user `u_<customer>_<site>` on shared MariaDB.
- Networks: shared `proxy` (edge) + `platform` (DB); per-site `<slug>_net`.
## Common commands
```bash
# Phase 1 — host baseline (from platform-infra/ansible, against the VM)
ansible-galaxy collection install -r requirements.yml
ansible-playbook site.yml # tags: base|zfs|docker|firewall|ssh
# Phase 2 — platform services (on the host, once Phase 1 is applied)
platform-infra/stacks/bootstrap-networks.sh
cd platform-infra/stacks/<svc> && cp .env.example .env && docker compose up -d
```
## Working agreements
- **Docs are source of truth.** When a design decision changes, update the
relevant `docs/` file and add/adjust an ADR in `docs/adr/`.
- **Never commit plaintext secrets.** Only `*.enc.*` (SOPS/age) are allowed;
`.env` is git-ignored (`.env.example` is committed). See `.gitignore`.
- **Line endings:** LF for scripts/Dockerfiles/YAML (enforced by `.gitattributes`)
— these run on Linux.
- **Commit per phase/step** with a descriptive message; the repo is the memory.
- Environment is Windows (authoring) → Linux host (runtime). Prefer the Bash tool
for POSIX one-offs.

View file

@ -0,0 +1,58 @@
# Phase 2 — Platform services
Base Docker Compose projects that every customer site depends on. Each is its
own compose project on the host, using data directories on the ZFS platform
datasets created in Phase 1.
| Stack | Role | Network(s) | Data |
|-------|------|------------|------|
| `traefik` | Edge router: TLS/ACME, dynamic routing, dashboard. Talks to Docker via a **socket-proxy** (least privilege). | `proxy`, `socketproxy` | `tank/platform/traefik` |
| `mariadb` | Shared database. Per-site DB + user created later by the CLI. Not published to the host. | `platform` | `tank/platform/mariadb` |
| `forgejo` | Git + container registry, git over HTTPS (via Traefik). | `proxy` | `tank/platform/forgejo` |
## Prerequisites
- Phase 1 applied (ZFS datasets, Docker with the `zfs` driver, firewall).
- DNS A/AAAA records for the platform hostnames (e.g. `traefik.example.com`,
`git.example.com`) pointing at the host, so ACME can issue certificates.
## Bring-up order
```bash
# 1. Shared networks (once)
./bootstrap-networks.sh
# 2. Traefik (edge first, so ACME/routing is ready)
cd traefik && cp .env.example .env && $EDITOR .env && docker compose up -d && cd ..
# 3. Shared MariaDB
cd mariadb && cp .env.example .env && $EDITOR .env && docker compose up -d && cd ..
# 4. Forgejo
cd forgejo && cp .env.example .env && $EDITOR .env && docker compose up -d && cd ..
```
## Secrets
For now each stack reads a git-ignored `.env` (copy from `.env.example`). Real
secrets move to SOPS/age when the provisioning CLI lands (Phase 4). Generate
strong values, e.g.:
```bash
openssl rand -base64 24 # MariaDB root password
htpasswd -nbB admin 'your-password' # Traefik dashboard basic-auth line
```
## Verify
```bash
docker compose ls # all three up
curl -I https://traefik.example.com # dashboard (401 = auth working)
docker exec -it mariadb mariadb -uroot -p -e 'SELECT VERSION();'
curl -I https://git.example.com # Forgejo reachable
```
## Notes / hardening
- **Traefik ↔ Docker via socket-proxy:** Traefik never mounts `docker.sock`
directly. `tecnativa/docker-socket-proxy` exposes a read-only, minimal subset
on an internal network. This limits blast radius if Traefik is compromised.
- **MariaDB is not published** to the host; it is reachable only by containers on
the internal `platform` network (sites join it to reach the DB).
- **Git over HTTPS only** (SSH disabled) so no extra inbound port is needed — the
Phase 1 firewall only opens 80/443 + admin SSH. Enable Forgejo SSH later only
if you also open its port in the firewall.

View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Create the two shared Docker networks the platform relies on. Idempotent.
# proxy — edge network: Traefik <-> per-site web containers.
# platform — internal network: MariaDB, monitoring. `--internal` = no egress.
# Run once on the host after Phase 1, before bringing up the stacks.
set -euo pipefail
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker network inspect platform >/dev/null 2>&1 || docker network create --internal platform
echo "Shared networks ready:"
docker network ls --filter name='^proxy$' --filter name='^platform$'

View file

@ -0,0 +1,6 @@
# Copy to .env (git-ignored) and fill in.
# Hostname for Forgejo (git + registry). Needs a DNS record pointing at the host.
# The container registry is served at the same host, e.g.:
# docker login git.example.com
FORGEJO_DOMAIN=git.example.com

View file

@ -0,0 +1,33 @@
# Forgejo: Git hosting + built-in container registry. Git over HTTPS only (SSH
# disabled) so no extra inbound port is needed behind the Phase 1 firewall.
# Uses the built-in SQLite DB (simplest for a single instance); data on ZFS.
services:
forgejo:
image: codeberg.org/forgejo/forgejo:10
container_name: forgejo
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__ROOT_URL: "https://${FORGEJO_DOMAIN}/"
FORGEJO__server__DISABLE_SSH: "true"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__packages__ENABLED: "true"
volumes:
- /tank/platform/forgejo:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN}`)"
- "traefik.http.routers.forgejo.entrypoints=websecure"
- "traefik.http.routers.forgejo.tls.certresolver=le"
- "traefik.http.routers.forgejo.middlewares=security-headers@file"
- "traefik.http.services.forgejo.loadbalancer.server.port=3000"
networks:
proxy:
external: true

View file

@ -0,0 +1,5 @@
# Copy to .env (git-ignored) and fill in.
# Root password for the shared MariaDB instance. Generate a strong value:
# openssl rand -base64 24
MARIADB_ROOT_PASSWORD=replace_with_a_strong_password

View file

@ -0,0 +1,17 @@
# heleos MariaDB tuning. Adjust innodb_buffer_pool_size to the host's RAM
# (rule of thumb: ~50-70% of RAM dedicated to the DB on a DB-heavy box; less
# here since the host also runs all the sites).
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 512M
innodb_file_per_table = 1
innodb_flush_log_at_trx_commit = 1
max_connections = 200
skip-name-resolve = 1
# Bind to all interfaces *inside the container*; the container is only attached
# to the internal `platform` network, so this is not host-exposed.
bind-address = 0.0.0.0

View file

@ -0,0 +1,26 @@
# Shared MariaDB. Per-site databases + least-privilege users are created later by
# the provisioning CLI (Phase 4). Not published to the host — reachable only by
# containers on the internal `platform` network.
services:
mariadb:
image: mariadb:11.4
container_name: mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
volumes:
- /tank/platform/mariadb:/var/lib/mysql
- ./conf/99-heleos.cnf:/etc/mysql/mariadb.conf.d/99-heleos.cnf:ro
networks:
- platform
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 20s
timeout: 5s
retries: 10
# Root is reachable only from within the container/platform net; no host port.
networks:
platform:
external: true

View file

@ -0,0 +1,13 @@
# Copy to .env (git-ignored) and fill in.
# Email used for Let's Encrypt registration + expiry notices.
ACME_EMAIL=admin@example.com
# Hostname for the Traefik dashboard (needs a DNS record pointing at the host).
TRAEFIK_DASHBOARD_HOST=traefik.example.com
# Basic-auth line(s) for the dashboard. Generate with:
# htpasswd -nbB admin 'your-password'
# NOTE: in a .env file, do NOT double the $ characters (that's only needed when
# putting the value directly in a compose label).
TRAEFIK_DASHBOARD_AUTH=admin:$2y$05$replace_with_real_bcrypt_hash

View file

@ -0,0 +1,88 @@
# Traefik edge router. Static config is passed as flags (so .env can drive
# ACME email + dashboard host); dynamic config (middlewares, TLS options) is
# loaded from ./dynamic. Docker access goes through a least-privilege socket-proxy.
services:
socketproxy:
image: tecnativa/docker-socket-proxy:0.3.0
container_name: socketproxy
restart: unless-stopped
environment:
# Grant only what Traefik needs to discover routes; everything else denied.
CONTAINERS: 1
NETWORKS: 1
SERVICES: 1
TASKS: 1
EVENTS: 1
PING: 1
VERSION: 1
# Explicitly deny the dangerous surfaces.
POST: 0
EXEC: 0
IMAGES: 0
VOLUMES: 0
INFO: 0
AUTH: 0
SECRETS: 0
SWARM: 0
SYSTEM: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- socketproxy
traefik:
image: traefik:v3.3
container_name: traefik
restart: unless-stopped
depends_on:
- socketproxy
command:
- "--global.checknewversion=false"
- "--global.sendanonymoususage=false"
- "--log.level=INFO"
- "--accesslog=true"
# Entry points + global HTTP->HTTPS redirect
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
# Providers: Docker (via socket-proxy) + file (dynamic dir)
- "--providers.docker=true"
- "--providers.docker.endpoint=tcp://socketproxy:2375"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=proxy"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
# Let's Encrypt (HTTP-01 challenge on the web entrypoint)
- "--certificatesresolvers.le.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.le.acme.storage=/acme/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge=true"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
# Dashboard/API (secured via labels below)
- "--api.dashboard=true"
# Prometheus metrics (scraped in Phase 6)
- "--metrics.prometheus=true"
ports:
- "80:80"
- "443:443"
volumes:
- /tank/platform/traefik/acme:/acme
- ./dynamic:/etc/traefik/dynamic:ro
networks:
- proxy
- socketproxy
labels:
- "traefik.enable=true"
# Dashboard router (HTTPS + basic auth)
- "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_DASHBOARD_HOST}`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=le"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dashboard-auth@docker,security-headers@file"
- "traefik.http.middlewares.dashboard-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
networks:
proxy:
external: true
socketproxy:
internal: true

View file

@ -0,0 +1,19 @@
# Traefik dynamic config: reusable middlewares + TLS options.
# Site routers reference these by name, e.g. `security-headers@file`.
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: strict-origin-when-cross-origin
frameDeny: true
tls:
options:
default:
minVersion: VersionTLS12
sniStrict: true