New section covering what it does, the public-vs-private-key security
model (only public keys are ever fetched, no outbound capability like
private-repo access is granted), and how to run it standalone via
sudo ./setup.sh ssh-key-import. Placed ahead of the existing SSH Host
aliases section since that section already references key import as
prior context ("after SSH key import, the wizard offers to add...").
Was only ever runnable once, buried inside base.sh's required-setup flow —
no way to re-run just this step for a box that already went through base
setup but needs another admin's key added later, or (the immediate case)
a home box for services/vpn-data-mount.sh that only needs this one step.
services/ssh-key-import.sh holds the real logic now (GitHub/Launchpad
import via ssh-import-id, optional password-auth lockdown); base.sh's
_base_setup_ssh chains into it the same way services/asterisk.sh chains
into security-dashboard/pstn-trunk, with a degraded (no import, just
ensures the SSH server itself is running) fallback for a pure standalone
`sudo bash base.sh` run with no sibling files sourced. Independently
runnable via `sudo ./setup.sh ssh-key-import` or `sudo bash
services/ssh-key-import.sh`, and shows up in the whiptail menu under
extras alongside ssh-config. Marked as never showing [installed] in
is_installed()/install_count(), same as ssh-config — it's a repeatable
management action, not a thing with an install state.
Offered right after NetBird setup during required/base setup, matching
the requested flow (base packages -> NetBird -> data mount). Repeatable
by design rather than a one-shot step, since different services can have
data on different home boxes — asks for a home box IP every time and can
be run again for additional boxes/shares.
Flow: test for existing passwordless SSH first (covers "both boxes already
share a key via GitHub import, or any other means" for free — if it
already works, nothing else runs). If not, generate an SSH keypair and
offer ssh-copy-id or a manual/GitHub-import fallback (ssh-import-id, the
same mechanism base.sh's own SSH setup already uses) — needed because a
home box that took base.sh's "disable password login" option won't accept
ssh-copy-id at all. Once passwordless SSH works, use it to remotely
install and configure Samba on the home box for a chosen path, then mount
it locally over CIFS with a tagged /etc/fstab entry.
SMB over NFS/SSHFS per this session's direction: not a "huge" speed gap
for normal use, and SSHFS's own encryption is redundant overhead once the
VPN tunnel already encrypts everything. Guest-accessible (no separate
Samba credentials) since the VPN is the real access control — only
NetBird-connected peers can reach the home box's NetBird IP at all.
Also:
- cifs-utils added to base.sh's always-installed packages, same reasoning
as Docker/Compose being unconditional there instead of installed lazily
on first mount.
- is_installed()/install_count() in setup.sh gained a vpn-data-mount case
(state lives in tagged /etc/fstab entries, not $DOCKER_DIR, since this
isn't a Docker service) — mirrors wordpress's "count real instances"
handling rather than a flat 0/1.
- Every SSH call in the new service explicitly runs as $ACTUAL_USER
(sudo -u), not root — the script itself runs as root throughout, but the
SSH key lives in $ACTUAL_HOME/.ssh, so a bare `ssh` call would silently
use root's own ~/.ssh instead and never find it. Caught by review before
this shipped, not after.
- UNATTENDED mode skips outright with a message instead of spinning
forever on prompt_text's always-blank default under --unattended, since
none of this flow's prompts (home box IP, remote path, ...) have a
sane non-interactive default.
Confirmed (again, by rendering into a captured pty and inspecting the
character grid) that whiptail always renders a blank line between the
instructional text and the checklist box itself, with no parameter to
remove it — so a header "directly above the purple box" isn't achievable
no matter how it's built. Per this session's direction: drop the header
line entirely and just tighten the gap between the count and the service
name (was up to 15 chars of mostly blank space from the wide count field
sized to match the now-removed header label; down to ~5).
Verified end-to-end in the same pty harness: rendered the real dialog,
sent actual keystrokes to toggle two items (one plain, one with a
double-digit count), captured the raw whiptail selection output, and
confirmed the existing "extract text after the last space" logic still
pulls the correct plain service names back out.
Previous commit's leading-space count for the header was an estimate and
visibly off in the follow-up screenshot. Rather than guess again, actually
rendered the dialog into a captured pty (whiptail installed locally,
output fed through pyte to reconstruct the real character grid) and
measured exact column offsets instead of eyeballing.
Root fix: "installed" (9 chars) and "# of installs" (13 chars) are wider
than the underlying data (an "x"-or-blank mark, a 1-2 digit count) — a
narrow data column can never align under a wide label and stay readable,
so it's the data fields that got widened to match the label widths, not
the other way around. Verified alignment holds across installed/
not-installed/double-digit-count rows and at the narrow 78-column width
floor (where the description truncates first now, not the install status —
correct priority, since status is the more critical of the two).
Requested: no checkbox on the header row at all, not just a harmless one.
The previous fake-row header still drew a real [ ] like every other row —
whiptail has no way to suppress that per-row, there's no such thing as a
non-selectable list item in a --checklist.
The instructional text above the list has no checkbox rendering at all
though, since it isn't a list item — moved the header there instead:
"installed" / "# of installs" / "service", spelled out per this session's
request instead of the terse "x"/"#". Spelled-out words can't line up
character-for-character under the 1-2-char data columns below and stay
readable, so the leading spaces are a best-effort approximation, not exact
alignment.
Adjusted the box-height overhead constant (+8 -> +9) since the
instruction text is now two lines instead of one, and dropped the
now-unnecessary sentinel-row filtering from the selection-handling code.
Header row: a first, non-functional checklist entry using the exact same
printf field widths as the real rows ("x # NAME" / "x 1 caddy" / ...),
so it visually reads as column headers for the x/# prefix even though
whiptail has no real header concept. Its sentinel tag ("NAME") is filtered
back out of the selection after the dialog closes, so it's harmless even
if someone checks it and hits <Ok>.
Width: was a flat 78 regardless of the actual terminal, so descriptions
got cut off mid-sentence on anything wider with no way to read the rest
(confirmed from a screenshot — "TURN via the shared coturn s..." trailing
off). Scale with tput cols instead, floored at the old 78 (safe on a plain
80-column terminal) and capped at 160 so a very wide terminal doesn't get
an absurdly wide dialog.
Requested: separate, non-interactive "installed" (x) and "#" (instance
count) columns ahead of the actual selectable checkbox, with the
description no longer carrying any install-status text at all.
whiptail's checklist only has one interactive element per row — the
checkbox — so there's no such thing as a real extra column, tabbable or
not; the tag and item fields are always just inert display text regardless
of what's in them. The closest real equivalent: bake a fixed-width "x"
(installed) + count prefix into the tag field itself. whiptail pads every
row's tag field to the same width, so it lines up visually like columns
even though it's one string underneath. Extract the plain name back out
before dispatch by taking the last whitespace-separated token, since
service names never contain spaces — robust regardless of the exact
prefix width.
Description field is back to plain SERVICE_DESC text now that install
status lives in the tag prefix instead.
"[installed]" was 11 characters of an already-tight 78-column checklist
row, most of the reason the marker had so little room to spare before
whiptail's width truncation silently dropped it (previous commit). "[N]"
says the same thing in 3 characters — and for services that support
CLAUDE.md's multi-instance pattern (a base install plus any number of
"<name>-<suffix>" siblings, e.g. two separate mattermost instances), it's
more informative than a flat "installed": N > 1 means several instances
exist, not just one.
Add install_count() alongside is_installed() in setup.sh: the default case
counts $DOCKER_DIR/<name> plus any $DOCKER_DIR/<name>-* siblings; the
specially-cased services (asterisk, wordpress, etc.) either already count
sites directly (wordpress) or aren't part of the multi-instance pattern, so
they just mirror is_installed() as 0 or 1. Wired into the whiptail
checklist, the non-whiptail plain-text fallback, and --status.
The [installed] text label (previous commit) confirmed working from a
screenshot, but the checkbox itself stays unchecked for installed items by
design — checking it means "install/reinstall this on <Ok>", so
pre-checking every already-installed service would risk a mass reinstall
from just hitting Ok without manually unchecking each one.
Add a second, more immediate cue right next to the checkbox instead:
prefix the item's own tag with "*" when installed (whiptail's checklist
tag is the first column, directly after the checkbox). The "*" is
display-only — stripped back off the selected values before they reach
run_service, so dispatch is unaffected.
The dynamic checklist-sizing code added in the previous commit used
`local` for its variables, but the category menu loop it lives in is
top-level script code, not inside a function — `local` only works inside
one. Confirmed live: this broke the whiptail menu outright on first
`sudo ./setup.sh` run after pulling ("only be used in a function", then an
unbound-variable error under set -u since the assignment before it never
ran). Drop `local`; these are the same kind of plain loop-scoped variables
every other var in this loop (CHOSEN_CAT, SVCS, CHOICE, SELECTED) already
is.
Confirmed from gtstef/filebrowser's own Dockerfile (_docker/Dockerfile):
the image runs as a fixed non-root user (adduser -u 1000 filebrowser;
USER filebrowser), not root and not remappable via PUID/PGID. The
installer's broad `chown -R $ACTUAL_USER:$ACTUAL_USER "$FB_DIR"` left the
bind-mounted ./data owned by $ACTUAL_USER (root, on a box where the
installer itself runs as root) — UID 1000 inside the container then had no
write access to it, so every start failed with "could not open database:
open /home/filebrowser/data/database.db: permission denied" and the
container crash-looped indefinitely (restart: unless-stopped kept retrying
every ~60s, matching the log timestamps this was diagnosed from).
Re-chown ./data to 1000:1000 specifically, after the broad chown so it
isn't clobbered back to $ACTUAL_USER.
Root cause of the "installed services not shown as installed" report,
confirmed from a screenshot: the [installed] marker was appended AFTER the
service description, and whiptail hard-truncates each checklist row to the
dialog's fixed width (78) with no ellipsis or other sign it happened.
fmd's description alone is 68 characters — adding " [installed]" pushes
it to 81, past the width, so the marker silently fell off the end. fmd was
actually installed the whole time (confirmed via setup.sh's own pre-wizard
summary and the new --status flag); the checklist just never showed it.
Move the marker to the front of the tag instead, where a long description
can still lose its own tail to truncation but the install status — the
part that actually matters — always survives. Mirrored the same fix into
the non-whiptail plain-text fallback path for consistency.
Also size the checklist's listheight/height to the category instead of a
flat 14 rows: utilities alone has 35+ services, so anything past row 14
was only reachable by scrolling with no on-screen hint more rows existed.
Now scales with the category size, capped to what the actual terminal can
show (tput lines) so it can't request a dialog taller than the screen.
Same bug as the previous filebrowser/fmd fix: vaultwarden, immich,
audiobookshelf, homebox, rustdesk, emby, meshcentral, traccar, lyrion,
actualbudget, mealie, joplin, jellyfin, unifi, and ntfy all showed "Manage
that install (update / full reinstall / cancel)" when re-run against an
existing install, but choosing "1) Manage" fell straight through into the
same unconditional fresh-install flow every time regardless of choice —
no way to actually cancel or update in place. Wired all 15 up to
prompt_reinstall_mode, matching the reference pattern in
services/mattermost.sh: update pulls + restarts the existing container
without touching config, cancel leaves the install untouched, fresh falls
through to the existing full-install flow unchanged.
Also add `setup.sh --status`: a plain-text listing of every service with
its install state, using the exact same is_installed() calls the whiptail
checklist's [installed] marker uses. Exists so "is X actually installed"
can be answered by reading terminal output directly, without depending on
a whiptail checklist screen where a narrow/resized terminal can truncate
the "[installed]" suffix off-screen with no visible sign that happened.
fmd.sh pointed at nulide/findmydevice, which no longer exists on Docker
Hub — the project has moved twice (nulide/findmydevice ->
gitlab.com/Nulide/findmydeviceserver -> gitlab.com/fmd-foss/fmd-server) and
was rewritten from Node.js to Go+React along the way, confirmed against the
current upstream repo and its GitLab container registry. This means the
service never actually started for anyone who installed it before this fix
("pull access denied for nulide/findmydevice, repository does not exist").
Switch to registry.gitlab.com/fmd-foss/fmd-server:0 (GitLab's own registry
has no "latest" tag; ":0" tracks the current major release the same way
this repo's other services use a floating tag). The old FMD_ADMIN_PASSWORD
model is gone from the app too — replaced with FMD_REGISTRATIONTOKEN
(self-registration gated by a token instead of one shared admin login), and
the database path moved from /fmd/data to /var/lib/fmd-server/db.
Also: filebrowser.sh and fmd.sh both showed "Manage that install (update /
full reinstall / cancel)" when re-run against an existing install, but
choosing "1) Manage" fell straight through into the same unconditional
fresh-install flow every time — no way to actually cancel or update in
place, contradicting both the banner text and the documented
prompt_reinstall_mode contract (CLAUDE.md's "Update vs. fresh reinstall on
rerun"). Wired both up to prompt_reinstall_mode, matching the reference
pattern in services/mattermost.sh. The same gap exists in 15 other
multi-instance services (vaultwarden, immich, audiobookshelf, homebox,
rustdesk, emby, meshcentral, traccar, lyrion, actualbudget, mealie, joplin,
jellyfin, unifi, ntfy) — not fixed here, flagged for a follow-up pass.
install_coturn (services/coturn.sh) cd's into $DOCKER_DIR/coturn and never
restores the caller's original working directory. A consumer that chain-
installs coturn mid-flow (e.g. asterisk.sh, already cd'd into its own
install directory) returned from ensure_coturn_user still sitting in
coturn's directory, then went on to write its own docker-compose.yml/.env
there instead of its own directory — clobbering coturn's compose file and
leaving the consumer's directory without one. The consumer's later
`docker compose up --build` then failed with "Dockerfile: no such file or
directory", since the Dockerfile was correctly in the consumer's directory
but the misplaced compose file (and the build) were not.
ensure_coturn_user now saves/restores the caller's cwd around the
install_coturn call, fixing this for every consumer (asterisk, mattermost).
Also fold cloud-init.sh's contents into a collapsible README section so
it's copy-pasteable straight from the repo instead of requiring a separate
file download.
The header still told readers to paste the raw GitHub URL, left over from
before we confirmed provider user-data fields run pasted/imported content
directly rather than fetching a URL.
IONOS's User Data field takes a Script Type choice (Cloud Config vs Shell
Script) and runs the pasted/imported content directly rather than fetching
a URL. Update the README to say so, add DEBIAN_FRONTEND=noninteractive for
genuine unattended cloud-init execution.
IONOS Cloud Server, DigitalOcean, and Hetzner all offer an "install
script"/user-data field that runs as root with no TTY while the image is
still provisioning, so bootstrap.sh's interactive tail can't run there.
cloud-init.sh clones the repo unattended and drops a one-shot
/etc/profile.d hook that launches the normal whiptail setup.sh wizard on
the first interactive login, then removes itself.
Storage cost matches what was already known from IONOS chat support
(~$0.49/100GB/month). Found the two unknowns from IONOS's own published
price list rather than pricing-comparison sites, which had conflicting
numbers for the API-cost line: API requests (PUT/COPY/POST/LIST/GET/
DELETE) are free with no per-request charge, and outbound transfer is
free up to 2TB/month (shared across the whole IONOS contract, not scoped
to Object Storage alone) before tiered per-GB rates kick in. Relevant to
services/immich.sh's S3 storage engine.
Settled stack: 3x Mattermost, 1x Traccar (down from 2x to buy back RAM),
2x each of ntfy/mealie/wordpress/actualbudget/audiobookshelf/emby
(music-only + everything)/filebrowser/fmd/homebox/joplin/rustdesk/
vaultwarden, 1x each of asterisk/security-dashboard/sms-inbound (all
three are singleton-by-design, no multi-instance support exists for
them). changedetection and magicmirror x6 dropped — the former never got
the full multi-instance retrofit, the latter's existing pattern caps at
3 instances.
Comes out to ~6.0GB of 8GB (~25% headroom) with RustDesk's relay for
screen sharing, or ~6.4GB (~20% headroom) with MeshCentral instead.
With 70+ services sharing a handful of common default ports (emby and
jellyfin both default to 8096, changedetection and frigate both default
to 5000, arm and nextcloud both default to 8080...), nothing previously
checked whether a service's default port was actually free on the host.
Whichever service installed second would silently write a compose file
claiming an already-held port, only failing at `docker compose up` time.
Adds two shared helpers to lib/common.sh:
- port_in_use PORT [PROTO] — true if something's already listening
- find_free_port VARNAME START [PROTO] — scans upward, writes back the
first free port
Every service that publishes a fixed host port now scans before writing
docker-compose.yml, on every install (not just when adding an explicit
additional instance). On a normal single-install host this is a silent
no-op; it only changes behavior when something else already holds the
port.
- The 19 services already given multi-instance support this session had
their port scan moved out of the "add instance" branch to run
unconditionally, since the same collision risk exists on a plain first
install.
- 20 more services with previously-hardcoded ports gained scanning for
the first time: archivebox, arm, calibre-web, changedetection,
drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf,
uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha
(paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so
generated peer configs keep the right Endpoint), homeassistant
(bridge-mode only — host mode can only warn), frigate and
frigate-audio (multi-port stacks, moved together).
- caddy.sh is the deliberate exception: 80/443 stay fixed and only warn
on collision, since silently moving Caddy itself would leave nothing
listening where any client actually looks.
- authelia.sh needs no change — it has no published host port at all.
- Every service's standalone bootstrap fallback (sudo bash services/x.sh
with no sibling files) got the same two helpers duplicated into its
stub block, matching how every other shared helper is already handled
there.
Documents the full pattern in CLAUDE.md's new "Port collision avoidance"
section, including the quoted-heredoc/backtick-escaping gotcha and the
network_mode:host limitation (can only scan ports the app takes as a
configurable env var).
Verified via bash -n on every changed file, plus functional runs seeding
occupied ports for each collision shape used here (single, paired,
multi-port stacks) and confirming the scan/shift and generated
compose/README output are correct — including the emby/jellyfin,
nextcloud/arm, and frigate/changedetection collision scenarios that
originally motivated this.
Retrofits the standard multi-instance pattern (documented in CLAUDE.md)
onto actualbudget, filebrowser, fmd, homebox, immich, jellyfin, joplin,
lyrion, meshcentral, ntfy, rustdesk, unifi, and vaultwarden. First
instance of each keeps its original name/paths/ports unchanged; adding a
second instance prompts for a short name and auto-scans for free ports.
Service-specific handling beyond the base pattern:
- joplin, immich, unifi: dedicated Postgres/Mongo container per instance
(not shared), matching the backup-isolation reasoning in CLAUDE.md.
- meshcentral, unifi: multiple fixed ports scanned/shifted together so
they stay paired per instance.
- rustdesk: 6-port block shifted by a fixed offset per instance, since
the image hardcodes its internal ports with no per-port env override.
- jellyfin: DLNA/discovery UDP ports only published for the first
instance to avoid a host-wide fixed-port conflict.
- lyrion: first instance keeps network_mode: host (required for
Chromecast/Squeezebox broadcast discovery); additional instances fall
back to bridge networking with auto-scanned ports, trading away
zero-config discovery since a second container can't also bind host
networking's fixed ports.
- magicmirror.sh already had its own working multi-instance pattern
(upfront instance count, numbered subdirs) and was left as-is.
Verified via bash -n on every changed file, plus scripted functional
runs (fake docker/ss) exercising first + second instance installs for
every port-scanning shape used here (single, dual-paired, quad-paired,
block-offset) and confirming dedicated per-instance DB naming and the
lyrion host->bridge compose output.
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
Same pattern already established by services/mattermost.sh and
services/wordpress.sh: first instance keeps the plain name/paths/
ports exactly as before (zero behavior change for anyone with a
single instance already installed), and only choosing to add a second
introduces suffixed naming with its own directory, containers, and
ports.
- audiobookshelf.sh, emby.sh, mealie.sh: straightforward -- suffixed
dir/container name, auto-scanned free host port(s) via `ss`, Caddy
subdomain default suffixed to avoid collision. emby.sh's existing
music-only mode is untouched, just correctly parameterized.
- traccar.sh: the harder one -- has its own dedicated Postgres
container, an autoheal container, and a 150-port device-protocol
range that can't be scanned port-by-port. Additional instances shift
the whole range by 1000 (6000-6150, 7000-7150, ...) based on how
many traccar/traccar-* directories already exist, which never lands
on Asterisk's fixed ports the way the first instance's range does,
so no exclusions are needed there. Also scoped the autoheal label
per-instance (autoheal-traccar-<suffix>) -- autoheal watches by
Docker label host-wide, not scoped to a compose project, so two
instances sharing the generic "autoheal" label would each try to
manage the other's container too.
Found and fixed two real bugs via testing before committing, not just
code review:
- The device-protocol range offset counted existing instances via
`find $DOCKER_DIR -maxdepth 1 -name 'traccar*'`, which also matches
$DOCKER_DIR itself if its own basename happens to start with
"traccar" (true in my test harness, structurally possible in real
use too) -- fixed with -mindepth 1.
- Verified port auto-scanning actually detects a simulated in-use
port and increments past it, using a stateful fake `ss` rather than
trusting the logic by inspection alone.
Verified end-to-end for all four: first instance unchanged from prior
behavior, second instance gets fully distinct dir/containers/ports,
and (traccar specifically) correct DB container, correctly-scoped
autoheal label, and correct shifted port range in the generated
compose file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
New opt-in prompt on fresh/new installs (skipped on "update" reruns,
where an existing instance is already in real use and importing over
it would be destructive): "Migrating from an existing Mattermost
instance (e.g. PikaPods)?" -- if yes, generates
migrate-from-pikapods.sh in the instance's own directory, same
generated-helper pattern as Immich's import-photos.sh.
Checked PikaPods' own docs before writing this rather than guessing
at their export mechanics: they expose per-pod SFTP (file access) and
a Database-access toggle that hands you an Adminer link for a full
SQL dump -- their own documented backup/migration flow is stop the
pod, SFTP the files, export the DB via Adminer. The generated script
assumes that shape (plain-text SQL dump + a files directory) and says
so in its header, including that PikaPods' exact SFTP layout wasn't
verified against a live pod so the files-argument path needs the
user's own confirmation.
What the script does: stops the mattermost container (leaves the DB
container running), drops and recreates the database owned by the
same existing role -- so .env's credentials are never touched or
regenerated, avoiding the "restored data, mismatched password" bug
class fixed elsewhere in this repo -- imports the dump via psql,
rsyncs the files directory into ./data, restarts. Requires typing
"YES" to proceed since it's destructive to whatever's currently in
the fresh instance's database.
Correctly parameterized per-instance: pulled from install_mattermost's
own MM_CONTAINER/DB_CONTAINER variables, so it's already correct for
either the first instance or an additional named one.
Verified end-to-end: prompt fires correctly at the right point in the
flow, generated script is syntactically valid, and the container
names/paths it's parameterized with match the actual instance being
installed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
Final decision: actualbudget dropped and WordPress site count settled
at 2 (not 4) specifically to restore real headroom after dedicated-
per-site MariaDB made the 4-site case tight. ~1.09GB headroom (~27%)
now, back in the ideal 25-30% range.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
Reconsidered after the shared-MariaDB design's real cost became clear:
Kopia's generic backup (services/backup.sh) stops a service's
container to snapshot it, so a shared MariaDB instance would back up
-- and would have to be restored -- as one unit covering every site at
once. Restoring just one site's database to an earlier point meant
restoring the whole shared snapshot to a temporary location first and
manually extracting that site's data back out, not a direct restore.
Each site now gets its own dedicated MariaDB container embedded in its
own docker-compose.yml (same pattern as services/nextcloud.sh) instead
of registering a database on a shared instance:
- Removed _wordpress_ensure_shared_db() and the wordpress-db/
wordpress_net shared resources entirely.
- Each site's compose file gets a `db` service (container
<site>-db) on an explicitly-named per-site default network
(<site>_net), so wp-cli's one-off container reliably joins the
right network without depending on Docker Compose's implicit
naming convention.
- DB creation goes through the mariadb image's own MYSQL_DATABASE/
MYSQL_USER/MYSQL_PASSWORD env vars on first boot (same as
nextcloud.sh) instead of an imperative `docker exec mysql -e
"CREATE DATABASE..."` against a shared container.
- Root and site DB passwords are both reused across reruns (read from
the existing .env), verified via a real update-mode rerun.
Tradeoff, stated in both the script's header comment and the generated
per-site README: more RAM per site (~100-150MB for a full MariaDB
container instead of a slice of one shared instance) in exchange for
independent backup/restore. Data was already fully isolated either way
(separate database + user, always required since WordPress's schema
uses generic table names) -- the shared-vs-dedicated choice was only
ever about the container/process, not the data.
Re-verified end-to-end against the fake docker shim: distinct ports,
distinct dedicated DB containers/networks per site, correct compose/
.env structure, credentials preserved across an update-mode rerun.
docs/vps-sizing-recommendations.md: updated to match -- WordPress
capacity recomputed for dedicated-per-site MariaDB (~580MB headroom at
4 sites, ~976MB at 2, vs. the shared design's ~700MB/~950MB).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
Emby traded off for WordPress capacity rather than run alongside it —
still fully built and ready in services/emby.sh, just not part of the
current baseline. Updates the final RAM budget table to swap Emby for
the shared MariaDB + WordPress sites, and notes wg-easy/homebox/
audiobookshelf aren't included in that specific table since they
weren't part of the baseline as most recently stated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
New service: self-hosted WordPress, sized for running several
independent sites the way a hosting company would, not just one blog.
- Multi-site from the start: every site requires a name (no unnamed
"first instance" special case like mattermost's — there's no
backward-compat reason to special-case one here) and gets its own
directory/container/port, but all sites share ONE MariaDB container
(chain-installed on first site, reused by every other one) instead of
a dedicated database container per site — same resource-sharing idea
as services/coturn.sh, just scoped to WordPress's own sites rather
than shared across different services. Each site gets its own
database + user within that shared instance.
- E-commerce is just WooCommerce, a normal WordPress plugin — no
separate infrastructure. PHP memory_limit/upload_max_filesize/
post_max_size are pre-tuned (256M/64M/64M) so a product-catalog
import doesn't hit default-image limits on the first try.
- wp-cli (official wordpress:cli image, run as a one-off container
sharing the site's html volume) does the initial WordPress core
install non-interactively — title, admin account — so there's no
browser setup wizard to remember per site. Falls back to printing
the exact manual command if the site wasn't ready in time.
- Auto-scans for a free host port per site (multiple sites can't all
bind 8090), matching the "auto-scanned free ports for extras" idea
already used by mattermost's multi-instance support.
- DB and admin passwords are reused across reruns (checked against the
DB-password-regeneration bug class already fixed elsewhere in this
repo, e.g. PR #265) — verified via a real update-mode rerun that the
credential doesn't change.
- setup.sh: is_installed() gets a wordpress case — every site is named
from the first one on, so there's never a plain $DOCKER_DIR/wordpress
directory the default case could match against.
- README.md: added to the utilities services table + copiable list per
CLAUDE.md's three-step rule for new services. Also fixed `coturn`
being in the homelab row's prose but missing from the copiable list
block below it — a pre-existing gap from when coturn.sh was merged.
Verified end-to-end via non-interactive dry runs against a fake docker
shim (no live daemon in this environment): 3 sites installed in
sequence get 3 distinct databases, 3 distinct auto-scanned ports, the
shared DB is only set up once, and an update-mode rerun preserves the
existing DB password rather than regenerating it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
The "Adding a new service that needs TURN" example in coturn.sh's
write_readme heredoc had one unescaped backtick pair (`sudo ./setup.sh
coturn`) while every other backtick in the same heredoc was correctly
escaped. Since write_readme's heredoc is unquoted (intentionally, so
$DIR-style interpolation works elsewhere in the file), bash treated it
as a command substitution: it actually tried to execute `sudo
./setup.sh coturn` at install time, printed "sudo: ./setup.sh: command
not found" to the terminal on every coturn install, and silently
dropped the intended text from the generated README.
Found while verifying the shared-coturn multi-consumer flow end-to-end
(coturn install -> asterisk + 2 mattermost instances all registering
concurrently) — confirmed working correctly otherwise: three distinct
credential files, no collisions, all three referencing the same host/
port, and reruns correctly reuse the cached credential instead of
regenerating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
Adds an opt-in prompt to store Immich-managed data (thumbnails, encoded
video, new uploads) in S3-compatible object storage instead of local
disk, using Immich's native IMMICH_STORAGE_ENGINE=s3 — deliberately NOT
a FUSE-mounted bucket. Checked this against real reported issues before
implementing: Immich uses symlinks internally that S3 doesn't support
under FUSE (ENOSYS errors), and its startup does thousands of stat()/
read() calls that FUSE-over-network handles badly enough to crash the
mount under latency spikes as small as 100ms. Native S3 mode talks to
the bucket over the S3 API directly, sidestepping both problems.
Independent of the existing external-library strategy — an external
library (existing photos indexed read-only, e.g. over a VPN mount) is
a separate mount either way and works the same regardless of where
Immich's own managed data lives, since S3 mode only replaces
UPLOAD_LOCATION.
- New prompts: bucket, region, endpoint (for non-AWS S3-compatible
providers — auto-sets S3_FORCE_PATH_STYLE when given), prefix, access
key ID, and secret key (read via `read -rs` so it doesn't echo; left
blank with a warning under UNATTENDED, since there's no sane default).
- Refactored the docker-compose.yml generation from two near-duplicate
heredocs (with/without external library) into one with composable
volume-line variables, to avoid quadrupling the duplication once S3
was added as a second axis.
- Skips creating local upload-location subdirectories entirely in S3
mode (thumbs/upload/backups/library/profile/encoded-video) — Immich
manages that structure inside the bucket itself.
- .env now gets chmod 600 (previously ungated) — more pointed now that
it can hold an S3 secret key, not just the DB password.
- Generated README documents the S3 setup and carries the FUSE-mount
warning forward so a future reader doesn't try that route instead.
Verified both the non-S3 baseline (unchanged output) and S3 mode
end-to-end via non-interactive dry runs — correct .env, correct
compose volumes, no local upload dirs created, 0600 permissions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
lyrion was ruled out for two protocol-level reasons Authelia can't work
around (single shared server password, and SlimProto has no auth of its
own for Authelia's HTTP-only forward_auth to gate) — emby covers music
instead, with real per-user library access. Also updates the swapfile
rule of thumb to reflect it now being a default for every install
rather than an Asterisk-droplet-specific behavior, and adds a final
RAM budget table/verdict for the full confirmed service list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
Extracts the swapfile logic out of services/asterisk.sh (previously
DigitalOcean-droplet-gated) into lib/common.sh's ensure_swapfile() —
provider detection was never really the point, the actual condition
that matters is "modest RAM, no swap yet," which applies just as much
to a non-DO VPS running several Docker services at once as it did to a
single-purpose droplet.
- lib/common.sh: new ensure_swapfile(), same fallocate/mkswap/fstab/
swappiness logic as before, threshold raised from 2048MB to 4096MB
(a 4GB box running a full service stack is exactly the case that
motivated this change — the old threshold would have skipped it).
- services/base.sh: calls it unconditionally so every install gets the
same check regardless of which other services get chosen.
- services/asterisk.sh: swapfile call is no longer gated behind
IS_DO — calls the shared helper directly. Kept a standalone-mode
stub (same pattern as this file's other stubbed helpers) so
`sudo bash asterisk.sh` with no base.sh in the picture still gets
it. Idempotent either way: a box that already has swap, or already
got it from base.sh earlier in the same run, no-ops immediately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug