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