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:
parent
d715244b76
commit
41a01c60ef
11 changed files with 342 additions and 0 deletions
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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 3–8 — 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.
|
||||||
58
platform-infra/stacks/README.md
Normal file
58
platform-infra/stacks/README.md
Normal 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.
|
||||||
12
platform-infra/stacks/bootstrap-networks.sh
Executable file
12
platform-infra/stacks/bootstrap-networks.sh
Executable 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$'
|
||||||
6
platform-infra/stacks/forgejo/.env.example
Normal file
6
platform-infra/stacks/forgejo/.env.example
Normal 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
|
||||||
33
platform-infra/stacks/forgejo/docker-compose.yml
Normal file
33
platform-infra/stacks/forgejo/docker-compose.yml
Normal 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
|
||||||
5
platform-infra/stacks/mariadb/.env.example
Normal file
5
platform-infra/stacks/mariadb/.env.example
Normal 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
|
||||||
17
platform-infra/stacks/mariadb/conf/99-heleos.cnf
Normal file
17
platform-infra/stacks/mariadb/conf/99-heleos.cnf
Normal 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
|
||||||
26
platform-infra/stacks/mariadb/docker-compose.yml
Normal file
26
platform-infra/stacks/mariadb/docker-compose.yml
Normal 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
|
||||||
13
platform-infra/stacks/traefik/.env.example
Normal file
13
platform-infra/stacks/traefik/.env.example
Normal 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
|
||||||
88
platform-infra/stacks/traefik/docker-compose.yml
Normal file
88
platform-infra/stacks/traefik/docker-compose.yml
Normal 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
|
||||||
19
platform-infra/stacks/traefik/dynamic/security.yml
Normal file
19
platform-infra/stacks/traefik/dynamic/security.yml
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue