Provisioning failed on a stock host because `useradd -g <fpm_gid>` (82, the
alpine www-data gid) requires a group at that gid, which no host baseline
creates. Add ensure_fpm_group(): create a `heleos-web` group at fpm_gid if
absent, before the SFTP user is created. Idempotent; resolves the open item
noted in site-templates/README.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Provide the admin's ed25519 public key in admin_authorized_keys so the
ssh_hardening role installs it, enabling key-based login (and allowing
ssh_disable_password_auth to be flipped on later).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Traefik v3.3's Docker provider fails to negotiate the API version against
Docker Engine 29 (API min 1.44) and falls back to 1.24, which the daemon
rejects ("client version 1.24 is too old"), leaving no container routes.
v3.7.x negotiates correctly through the socket-proxy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add zfs_pool_mode: file|single|mirror.
- file (default): loopback disk image at zfs_pool_file_path — real ZFS with
no spare disk, ideal for a cost-optimized test VM; wipes nothing.
- single/mirror: whole spare disk(s); mirror for production redundancy.
Guard/probe now loops over zfs_pool_disks. Update group_vars, README (modes,
prerequisites, safety), and CLAUDE.md current-focus note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some sanoid packages (e.g. on Ubuntu 26.04) don't ship /etc/sanoid, so the
config template failed with 'Destination directory does not exist'. Create
the directory explicitly, and copy the packaged sanoid.defaults.conf into
it (sanoid requires it beside sanoid.conf) so the first timer run succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
'No package matching docker-ce' happens when Docker has no repo for the
VM's release codename (common on non-LTS Ubuntu). Add docker_apt_codename
(defaults to the detected release; override to e.g. noble on non-LTS) and
refresh the apt cache right after adding the repo so index errors surface
immediately instead of as a missing package.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The six.moves ModuleNotFoundError comes from version-skewed duplicate
Ansible installs; recommend a single pipx install in prerequisites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The playbook runs everything via become(root); note that -K
(--ask-become-pass) is required unless the target user has passwordless
sudo. Replace the misleading --check first-run hint with --syntax-check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Running the playbook from /mnt/c in WSL makes Ansible ignore ansible.cfg
(world-writable dir), losing the inventory. Document the fix (copy to WSL
home, or export ANSIBLE_CONFIG) plus the inventory/syntax-check reminders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the two decoupled backup streams from docs/06 as an idempotent
Ansible role wired into the host playbook:
- Files: sanoid takes/prunes ZFS snapshots per policy (sanoid_datasets) on
its packaged timer; syncoid replicates offsite (heleos-zfs-offsite),
enabled only when zfs_offsite_target is set.
- DB: heleos-db-backup (nightly systemd timer) walks the deployments dir and
dumps each DB-backed site via `docker exec mariadb-dump` into
db-backups/<customer>/<site>/{daily,weekly,monthly} with rotation
(automysqlbackup-style, adapted for the containerized DB; MYSQL_PWD keeps
the password out of the process list). heleos-db-offsite rsyncs offsite
when db_offsite_target is set.
Streams and schedules are configured in group_vars/all.yml; offsite is
opt-in via the two target vars. Updates doc 06 (implementation note), the
ansible README, and CLAUDE.md status. YAML + templates validated by render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Python control-panel package driving the full provisioning flow from a
site's site.yaml (docs/05):
- provision: ZFS web dataset + ownership, per-site DB + least-priv user,
generated .env encrypted to secrets.enc.yaml (SOPS/age), render the
profile templates + persist site.yaml, per-customer chrooted SFTP
account, docker compose up.
- deprovision (gated: data destroyed only with --purge, after a final
backup), backup (ZFS snapshot + mariadb-dump), restore (rollback +
import), render (preview), list.
Design: one command/file runner with a real --dry-run (prints every
action, redacts secrets); idempotent steps; Config + Site validation
mirroring docs/03; passwords never logged.
Modules: cli, config, naming, context, render, runner, zfs, database,
secrets, sftp, compose, provision, backup. Plus pyproject (heleosctl
entry point), config.example.yaml, an example site, and a README.
Tests: 22 pure-logic unit tests (naming, config validation, template
render across all profiles + db on/off) — all passing. Full provision and
deprovision verified end-to-end in --dry-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the building blocks the provisioning CLI renders per site.
Base images (site-templates/images):
- php-fpm: non-root (www-data) php:<ver>-fpm-alpine with pdo_mysql, mysqli,
gd, intl, zip, opcache, exif; tuned php.ini + pool; built per PHP version.
- nginx: hardened nginx:1.27-alpine with shared security + fastcgi snippets;
per-site server block mounted at runtime.
- build.sh: build + Trivy-scan (+ optional push) for both images.
Profile templates (site-templates/profiles), Jinja2 rendered:
- static, redirect (tiny nginx 301/302), custom-php (nginx + our php-fpm,
optional DB), wordpress (nginx + official wordpress-fpm, DB required,
upload-exec denied). Only nginx carries Traefik labels and joins proxy;
php-fpm uses the private <slug>_net and joins platform only when a DB is
needed. Secrets stay in a git-ignored .env, not the compose.
CI: .forgejo/workflows/images.yml builds/scans images (gitleaks + Trivy).
README documents the render context and the Phase 4 web-root ownership item.
Templates validated: all profiles render to valid compose YAML across the
database on/off branches and single/multi-domain host rules.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Idempotent host configuration targeting Ubuntu 24.04 with ZFS on a
dedicated second disk. Role-based platform-infra/ansible:
- base: apt packages, timezone, unattended security upgrades.
- zfs: install ZFS, create pool on a dedicated disk (guarded against
wiping a non-empty disk), create platform datasets + customers parent
per docs/03; docker dataset mounted at /var/lib/docker.
- docker: Docker Engine + Compose plugin, daemon.json written before first
start so the native zfs storage driver initializes on the ZFS data-root;
per-site network address pool preconfigured.
- firewall: nftables inbound default-deny in a dedicated table that never
flushes Docker's rules; container outbound SMTP blocked via a DOCKER-USER
jump applied by a systemd oneshot.
- ssh_hardening: key-first SSH with an anti-lockout assertion, config
validation gate, and the sftponly group for Phase 4 SFTP accounts.
Includes ansible.cfg, requirements.yml, inventory example, group_vars with
safety notes, and a run guide. Real inventory (hosts.yml) is git-ignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Establish the design foundation for the heleosv2 multi-tenant hosting
platform before any implementation code:
- Monorepo skeleton: docs/, platform-infra/, site-templates/,
deployments/, control-panel/ with orientation READMEs.
- docs/: roadmap index, architecture + threat model, naming conventions,
site profiles, provisioning workflow, backup & DR runbook, repo/GitOps
layout, and the approved architecture plan.
- docs/adr/: 9 ADRs recording the rationale for single-host Compose,
Traefik edge, nginx+fpm split, shared MariaDB, ZFS-per-customer,
decoupled backup streams, Forgejo, CLI-first, and SFTP-only.
- Secrets hygiene: .gitignore (only *.enc.* committed) and .gitattributes
(LF for scripts/Dockerfiles/YAML run on the Linux host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>