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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user