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>
5.4 KiB
03 — Naming & Conventions
Consistent, predictable names are what make CLI-driven provisioning and scripted backup/restore reliable. The model has two levels: a customer (owner) may own many sites (deployable units). Isolation, containers, and databases live at the site level; grouping (SFTP login, billing, bulk backup/delete) lives at the customer level.
1. Identifiers
| Id | Scope | Charset | Example |
|---|---|---|---|
customer |
Owner / billing entity | [a-z0-9-], 2–32, starts with a letter |
acme |
site |
One deployable unit, unique within its customer | [a-z0-9-], 2–40, starts with a letter |
shop |
slug |
Globally-unique flattened id = <customer>-<site> |
derived | acme-shop |
customeris chosen once (short, stable — usually the company name).siteidentifies an app within that customer. Derive from the primary domain or set explicitly (acme.com→acme-com;shop.acme.com→shop).slug=<customer>-<site>, the join key for Docker and database names. Neithercustomernorsitechanges for the life of the resource (renaming = new id + migration).
Same app vs separate app (subdomains)
- A subdomain that serves the same application (
www.acme.com+acme.com, vanity domains) is an alias — extra entries in one site'sdomains:list, one web root, one cert. Not a new site. - A subdomain that is a separate application (
shop.acme.comwebshop vsacme.commarketing) is a separate site under the same customer — its own slug, web root, DB, containers, and network. Isolated from the customer's other sites; grouped under the customer for SFTP/backup/billing.
2. ZFS datasets — nested customer → site
tank/
├── customers/
│ └── <customer>/ # groups all of a customer's sites
│ └── <site>/
│ └── web # web root ONLY (bind-mounted into the site)
└── platform/
├── docker # Docker data-root (images/layers/volumes)
├── mariadb # shared MariaDB datadir
├── db-backups/<customer>/<site># automysqlbackup output per site
├── traefik # ACME store + dynamic config
├── forgejo # Git + registry data
└── monitoring # Prometheus/Loki data
Example for customer acme:
tank/customers/acme/acme-com/web # WordPress marketing site
tank/customers/acme/shop/web # custom-PHP webshop (separate app + DB)
tank/customers/acme/blog/web # another WordPress
Snapshots:
- Per site:
tank/customers/<customer>/<site>/web@auto-YYYYMMDD-HHMM. - Whole customer (recursive):
zfs snapshot -r tank/customers/<customer>@....
3. Docker resources (keyed by the flattened slug)
| Resource | Pattern | Example |
|---|---|---|
| Compose project | <slug> |
acme-shop |
| Container | <slug>_<role> |
acme-shop_nginx, acme-shop_fpm |
| Per-site network | <slug>_net |
acme-shop_net |
| Shared edge network | proxy (fixed) |
proxy |
| Platform network | platform (fixed) |
platform (MariaDB, monitoring) |
| Named volume (if used) | <slug>_<purpose> |
acme-shop_fpmtmp |
Traefik router/service labels also key off the slug:
traefik.http.routers.<slug>.rule=Host(...).
4. Database (shared MariaDB)
Hyphens in the slug become underscores for MySQL identifiers
(acme-shop → acme_shop).
| Resource | Pattern | Example |
|---|---|---|
| Database | db_<customer>_<site> |
db_acme_shop |
| DB user | u_<customer>_<site> |
u_acme_shop |
| Grants | ALL PRIVILEGES ON db_<customer>_<site>.* to that user only |
— |
Passwords are generated, stored encrypted (SOPS/age), never reused across sites.
5. Domains & TLS
- The site's primary domain drives the default
siteid; additional aliases live in the site'sdomains:list and are added to the Traefik Host rule. - Certificates are issued per domain automatically by Traefik/ACME.
6. SFTP accounts — per customer
One login per customer, chrooted to the customer directory so they see all their sites as sub-folders.
| Resource | Pattern | Example |
|---|---|---|
| SFTP user | sftp_<customer> |
sftp_acme |
| Chroot path | tank/customers/<customer>/ |
sees acme-com/web, shop/web, … |
(A future option: per-site SFTP users for customers who want to delegate access to a single site. Default is one per customer.)
7. Deployment config (in deployments/) — mirrors ZFS
deployments/
└── <customer>/
└── <site>/
├── site.yaml # declarative source of truth
├── docker-compose.yml # rendered from a site-templates profile
├── .env.example # non-secret defaults / references
└── secrets.enc.yaml # SOPS/age-encrypted secrets (committed)
site.yaml is the source of truth the CLI reads/writes; everything else is
rendered from it. It carries both ids:
customer: acme
site: shop
profile: custom-php
domains: [shop.acme.com]
8. Reserved words
customer and site ids may not be: proxy, platform, traefik, mariadb,
forgejo, monitoring, docker, customers, platform-infra,
site-templates, deployments, control-panel (avoids collisions with
platform names).