docs: document the multi-instance service pattern in CLAUDE.md

Establishes multi-instance as the default expectation for any service
that stores its own data and isn't inherently single-tenant, not an
opt-in special case -- matching the direction taken this session
(audiobookshelf, emby, mealie, traccar all just got it; mattermost and
wordpress already had it).

Documents the reusable pattern with a code skeleton (first instance
stays plain-named, adding a second introduces suffixed naming with no
further branching downstream), plus the three sharp edges found while
actually building it into four more services rather than just
theorizing about it:
- dedicated-per-instance databases over shared, and why (Kopia's
  generic backup stops a container to snapshot it, so a shared
  instance backs up and restores as one unit covering every instance
  at once -- this is the same reasoning already applied to
  wordpress.sh, now generalized)
- large port ranges shift by an offset instead of being scanned
  port-by-port, including the find -mindepth 1 gotcha discovered
  while building this into traccar.sh
- sidecar tooling that watches Docker labels host-wide (autoheal)
  needs the label itself scoped per instance, not just container names

Also states plainly: verify this kind of port/count logic by actually
running it, not by reading it -- both real bugs it references were
things code review alone missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
This commit is contained in:
Claude
2026-08-09 21:58:25 +00:00
parent d5d979ac31
commit 3fc20238af
+111
View File
@@ -485,6 +485,117 @@ a reason (a stray Enter on a service you're just checking on shouldn't
trigger anything). `fresh` runs the exact same flow a first-time install
would, prompts included.
## Multi-instance services
Any service where running two genuinely separate copies is a real use case
(two Mattermost teams, two WordPress sites, two Traccar fleets, a
music-only Emby alongside a movies one) should support it — this isn't
opt-in per service, it's the default shape for anything that stores its
own data and isn't inherently single-tenant (skip it for things like
`caddy` or `crowdsec`, where a second instance wouldn't mean anything).
**The pattern** (see `services/mattermost.sh` for the original, and
`services/audiobookshelf.sh`/`services/emby.sh`/`services/mealie.sh`/
`services/traccar.sh`/`services/wordpress.sh` for more examples): the first
instance keeps the plain name/directory/container/ports exactly as they'd
be without any of this — zero behavior change for anyone with a single
instance already installed. Only *choosing* to add a second introduces
suffixed naming. `services/wordpress.sh` is the one exception that requires
a name from every instance including the first — reasonable for a
brand-new service with no existing single-instance installs to stay
compatible with, but not the default choice for an established service.
```bash
local DIR="$DOCKER_DIR/myservice"
local INSTANCE_SUFFIX="" CONTAINER="myservice"
local WEB_PORT="9000"
if [ -d "$DIR" ]; then
echo ""
echo " MyService is already installed at $DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate MyService instance alongside it (its own"
echo " server and data — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
[ -z "$_suffix" ] && { log_warning "Name can't be empty."; continue; }
[ -d "$DOCKER_DIR/myservice-$_suffix" ] && { log_warning "myservice-$_suffix already exists — pick another name."; continue; }
break
done
INSTANCE_SUFFIX="$_suffix"
DIR="$DOCKER_DIR/myservice-$_suffix"
CONTAINER="myservice-$_suffix"
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
WEB_PORT=$((WEB_PORT + 1))
done
log_info "New instance: $DIR (port $WEB_PORT)"
fi
fi
```
Everything downstream — `container_name`, `hostname`, published ports, the
Caddy subdomain default passed to `configure_caddy_for_service`, log/prompt
text, the generated README's title — reads from `$CONTAINER`/`$WEB_PORT`/
`$INSTANCE_SUFFIX` instead of the literal service name, so it's already
correct for either the first instance or a named one with no further
branching.
**Databases: dedicated per instance, not shared.** `services/wordpress.sh`
started as one shared MariaDB container with a separate database per site
(same resource-sharing idea as the shared `coturn` below), and was
deliberately changed away from that. The reason generalizes: Kopia's
generic backup (`services/backup.sh`) stops a service's *container* to get
a consistent snapshot, so a shared database instance backs up — and would
have to be restored — as one unit covering every instance's data at once,
not one instance independently. A dedicated database container per
instance costs more RAM (a full container each instead of one instance
split across several) in exchange for real backup/restore isolation. Data
is typically isolated either way (separate database + user regardless), so
the shared-vs-dedicated choice is about the container/process and its
backup blast radius, not about the data being mixed. Default to dedicated
per instance; only share if a service's own architecture makes that
awkward and the resource savings are worth the backup-coupling tradeoff.
**Ports beyond a single one need more than one `ss` scan, but never
port-by-port for a large range.** A service publishing two or three fixed
ports (`services/emby.sh`, `services/mattermost.sh`'s web+Calls-UDP ports)
just runs the same `ss` scan once per port. A service publishing a large
*range* (`services/traccar.sh`'s ~150-port device-protocol range) can't be
scanned port-by-port — instead shift the whole range by a fixed offset per
instance, sized off how many `$DOCKER_DIR/<name>*` directories already
exist (`find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name '<name>*' -type d
| wc -l` — the `-mindepth 1` matters, since without it `find` also matches
`$DOCKER_DIR` itself if its own basename happens to start with the service
name). Check whether the *first* instance's range carves out exclusions for
another service's fixed ports (traccar's does, for Asterisk's AMI/SIP
ports) — a large enough offset on additional instances usually clears those
same fixed ports automatically, so the exclusions typically don't need to
be repeated for instance 2+.
**Docker labels used by sidecar tooling need per-instance scoping too, not
just container names.** `services/traccar.sh`'s `autoheal` sidecar watches
containers by a Docker label that's visible host-wide, not scoped to a
compose project — two instances both using the literal `autoheal` label
would each try to restart the *other* instance's container too. Give the
label itself a per-instance value (`autoheal-<name>-<suffix>`) and point
that instance's `autoheal` container at the same value via
`AUTOHEAL_CONTAINER_LABEL`, the same way container names get suffixed.
**Verify port/count logic by actually running it, not just by reading it.**
Both real bugs caught while building this pattern into
`services/traccar.sh` — the `find` matching `$DOCKER_DIR` itself, and the
port-scan needing a genuinely free-vs-taken state to prove it increments —
were things code review alone would have missed. Install two instances in
sequence (a fake `docker`/`ss` shim standing in for a live daemon is fine)
and confirm the second one's directory, container names, and ports are
actually distinct before trusting the logic.
## Chaining into another service from within your own
A service can call another service's `install_<name>()` directly as a