From ad38b96cfe1575cdad52a6702a33ee317d7e0481 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 18:13:37 +0000 Subject: [PATCH 01/11] Make swapfile a default for every install, not just Asterisk droplets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- lib/common.sh | 41 ++++++++++++++++ services/asterisk.sh | 108 +++++++++++++++++++++++-------------------- services/base.sh | 4 ++ 3 files changed, 102 insertions(+), 51 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 3eb0ef3..35de9a6 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -268,6 +268,47 @@ ensure_ufw_enabled() { log_success "UFW enabled (SSH on port ${_ssh_port} allowed first, so this won't lock you out)." } +# Adds a swapfile on any box with modest RAM and no swap already active — +# no cloud-provider detection, just the actual condition that matters. Used +# to be DigitalOcean-droplet-gated logic living only in services/asterisk.sh; +# generalized here so every install gets the same safety net regardless of +# which services get chosen or which provider the box is on — a small VPS +# running several Docker services at once needs this just as much as a +# single-purpose Asterisk droplet did. Idempotent and safe to call from +# multiple places in the same run (services/base.sh calls it for every +# install; services/asterisk.sh also calls it directly so the standalone +# `sudo bash asterisk.sh` path — no base.sh involved — still gets it): a +# box that already has swap, or already got it from an earlier call in the +# same session, just returns immediately. +ensure_swapfile() { + local TOTAL_RAM_MB + TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" + [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 4096 ]] || return 0 + swapon --show | grep -q . && return 0 + [ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would add a swapfile (${TOTAL_RAM_MB}MB RAM, no swap detected)"; return 0; } + + local FREE_DISK_MB SWAP_MB=2048 + FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" + if [[ "$FREE_DISK_MB" -le $((SWAP_MB + 2048)) ]]; then + log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." + log_warning "Consider a bigger box, or free up disk before installing." + return 0 + fi + + local ADD_SWAP="" + prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM box — add a ${SWAP_MB}MB swapfile? (recommended) (y/n):" "y" ADD_SWAP + [[ "$ADD_SWAP" =~ ^[Yy]$ ]] || return 0 + + fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab + grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf + sysctl -w vm.swappiness=10 >/dev/null 2>&1 + log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." +} + # Scopes a UFW allow rule to just the caddy_net bridge subnet instead of # every interface. Needed for any port that only needs to be reachable from # a *locally* Caddy-fronted service (via host.docker.internal) — a plain diff --git a/services/asterisk.sh b/services/asterisk.sh index ac5cf06..e387bc6 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -4,12 +4,13 @@ # # One installer for both deployment shapes. It detects a DigitalOcean droplet # (via the link-local metadata service, with a y/n fallback if that's blocked) -# and, in droplet mode, swaps in the public-cloud specifics: a swapfile for -# low-RAM plans, a public-FQDN-only flow with no LAN/VLAN prompts, a Caddy -# site block pinned to that one FQDN, an optional remote Authelia, and a -# DigitalOcean Cloud Firewall via doctl. Everything else — vendor files, -# compose, messaging dialplan, presence alerts, UFW, log rotation — is -# identical either way. +# and, in droplet mode, swaps in the public-cloud specifics: a public-FQDN-only +# flow with no LAN/VLAN prompts, a Caddy site block pinned to that one FQDN, +# an optional remote Authelia, and a DigitalOcean Cloud Firewall via doctl. +# The swapfile (lib/common.sh's ensure_swapfile) is NOT droplet-gated — every +# box gets that same low-RAM safety net regardless of provider or deployment +# shape. Everything else — vendor files, compose, messaging dialplan, +# presence alerts, UFW, log rotation — is identical either way. # # This used to be two services (services/asterisk-digital-ocean.sh held a # near-duplicate copy of the whole file). An existing droplet install at @@ -92,6 +93,36 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then esac } + # Standalone-mode copy of lib/common.sh's ensure_swapfile() — kept in + # sync by hand, same as every other helper stubbed in this block. + ensure_swapfile() { + local TOTAL_RAM_MB + TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" + [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 4096 ]] || return 0 + swapon --show | grep -q . && return 0 + [ "${DRY_RUN:-false}" = true ] && { echo "[DRY-RUN] Would add a swapfile (${TOTAL_RAM_MB}MB RAM, no swap detected)"; return 0; } + + local FREE_DISK_MB SWAP_MB=2048 + FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" + if [[ "$FREE_DISK_MB" -le $((SWAP_MB + 2048)) ]]; then + log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." + return 0 + fi + + local ADD_SWAP="" + prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM box — add a ${SWAP_MB}MB swapfile? (recommended) (y/n):" "y" ADD_SWAP + [[ "$ADD_SWAP" =~ ^[Yy]$ ]] || return 0 + + fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab + grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf + sysctl -w vm.swappiness=10 >/dev/null 2>&1 + log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." + } + configure_caddy_for_service() { local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" @@ -272,8 +303,9 @@ _asterisk_resolve_layout() { # metadata service isn't always reachable (a container, a firewalled # 169.254.0.0/16, a non-DO cloud that still wants the same public-IP # treatment), so a miss falls back to asking rather than silently deciding -# for the user. Droplet mode is what gates the swapfile, the public-FQDN-only -# flow, and the Cloud Firewall step further down. +# for the user. Droplet mode is what gates the public-FQDN-only flow and the +# Cloud Firewall step further down (the swapfile is NOT droplet-gated — see +# ensure_swapfile in lib/common.sh, called unconditionally further down). _asterisk_detect_digitalocean() { local _meta="http://169.254.169.254/metadata/v1" DROPLET_ID="$(curl -fsS --max-time 2 "$_meta/id" 2>/dev/null || true)" @@ -284,8 +316,8 @@ _asterisk_detect_digitalocean() { if [[ -n "$DROPLET_ID" ]]; then [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" log_success "DigitalOcean droplet detected (id $DROPLET_ID, public IP ${PUBLIC_IP:-unknown})." - log_info "Droplet mode adds: swapfile for low-RAM plans, public-FQDN-only setup (no" - log_info "LAN/VLAN prompts), a Cloud Firewall via doctl, and a remote-Authelia option." + log_info "Droplet mode adds: public-FQDN-only setup (no LAN/VLAN prompts), a Cloud" + log_info "Firewall via doctl, and a remote-Authelia option." prompt_yn "Set this up as a public droplet? (n = treat it as a home/LAN box) (y/n):" "y" _answer else log_info "No DigitalOcean metadata service reachable — assuming a home/LAN box." @@ -883,39 +915,6 @@ EOF fi } -# ── Shared: swapfile for low-RAM public cloud boxes ──────────────────────── -# DigitalOcean doesn't provision swap by default. Docker + Asterisk + coturn -# fit in 512MB-1GB at idle with little headroom; a swapfile absorbs spikes -# (apt/image pulls, log bursts, a few concurrent calls) instead of the -# kernel OOM-killing a container or the box going unresponsive over SSH. -_asterisk_offer_swapfile() { - local TOTAL_RAM_MB - TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" - [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 2048 ]] || return 0 - swapon --show | grep -q . && return 0 - - local FREE_DISK_MB SWAP_MB=2048 - FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" - if [[ "$FREE_DISK_MB" -le $((SWAP_MB + 2048)) ]]; then - log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." - log_warning "Consider a bigger box, or free up disk before installing." - return 0 - fi - - local ADD_SWAP="" - prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM box — add a ${SWAP_MB}MB swapfile? (y/n):" "y" ADD_SWAP - [[ "$ADD_SWAP" =~ ^[Yy]$ ]] || return 0 - - fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none - chmod 600 /swapfile - mkswap /swapfile >/dev/null - swapon /swapfile - grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab - grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf - sysctl -w vm.swappiness=10 >/dev/null 2>&1 - log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." -} - # ── Droplet-mode Caddy: web admin on the SAME FQDN used for SIP ──────────── # Deliberately NOT using configure_caddy_for_service in this mode. Caddy only # holds a cert for domains it's actively serving, and Asterisk never does ACME @@ -1316,8 +1315,9 @@ MD ## DigitalOcean droplet notes This install is in public-cloud mode: the installer read the droplet's public -IP from the metadata service, set up a swapfile, offered a Cloud Firewall, and -reverse-proxied the web admin on the same FQDN used for SIP. +IP from the metadata service, offered a Cloud Firewall, and reverse-proxied +the web admin on the same FQDN used for SIP. (The swapfile below isn't +droplet-specific — every install on this box gets the same check.) ### Droplet sizing @@ -1336,10 +1336,13 @@ phones actually are is fine; SIP/RTP care about latency more than raw bandwidth. **Swap:** DigitalOcean doesn't provision swap by default, and Docker + -Asterisk + coturn leave little headroom at 512MB–1GB RAM. The installer -detects RAM ≤2GB with no existing swap and offers to add a 2GB swapfile -(persisted in \`/etc/fstab\`) — it's what makes the \$4/mo plan viable instead -of risking an OOM kill under load. +Asterisk + coturn leave little headroom at 512MB–1GB RAM. This isn't +Asterisk- or droplet-specific — \`base.sh\` (and this installer, for the +standalone-run case) checks RAM ≤4GB with no existing swap and offers a 2GB +swapfile (persisted in \`/etc/fstab\`) on every install, since a box running +several Docker services at once needs the same insurance a single-purpose +droplet does. It's what makes the \$4/mo plan viable instead of risking an +OOM kill under load — and it's why nothing above 4GB gets asked at all. **OS image:** Ubuntu 24.04 LTS (supported through April 2029) is the safe, battle-tested choice for Docker + coturn. Ubuntu 26.04 LTS is also available @@ -1417,9 +1420,9 @@ install_asterisk() { echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk, patching Asterisk to" echo "[DRY-RUN] log security events to logs/full (what CrowdSec + the Security Dashboard read)" echo "[DRY-RUN] Would rotate logs/full at 100MB via /etc/logrotate.d/asterisk" + echo "[DRY-RUN] Would add a swapfile if RAM <= 4096MB and none exists (any deployment shape)" echo "[DRY-RUN] Would detect a DigitalOcean droplet via its metadata service (asking either way)" echo "[DRY-RUN] and, in droplet mode, additionally:" - echo "[DRY-RUN] - add a swapfile if RAM <= 2048MB and none exists" echo "[DRY-RUN] - skip the LAN/VLAN prompts and set up one public FQDN for SIP + web admin" echo "[DRY-RUN] - reverse-proxy the web admin on that SAME FQDN (needed for SIP cert sync)" echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin" @@ -1525,7 +1528,10 @@ install_asterisk() { local IS_DO=false DROPLET_ID="" PUBLIC_IP="" _asterisk_detect_digitalocean - [[ "$IS_DO" == true ]] && _asterisk_offer_swapfile + # Not droplet-gated — every box gets the same low-RAM safety net now + # (see lib/common.sh's ensure_swapfile). Idempotent: a no-op if base.sh + # already added swap earlier in this run, or if swap already exists. + ensure_swapfile mkdir -p "$EA_DIR" mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \ diff --git a/services/base.sh b/services/base.sh index 887943d..e872547 100644 --- a/services/base.sh +++ b/services/base.sh @@ -13,6 +13,7 @@ install_base() { echo "[DRY-RUN] Would install Docker CE + Compose plugin" echo "[DRY-RUN] Would detect an NVIDIA GPU and offer to install the driver" echo " + NVIDIA Container Toolkit (for GPU-accelerated Docker services)" + echo "[DRY-RUN] Would add a swapfile if RAM <= 4096MB and none exists" echo "[DRY-RUN] Would install/configure openssh-server" echo "[DRY-RUN] Would offer SSH key import from GitHub/Launchpad" echo "[DRY-RUN] Would offer to disable SSH password auth" @@ -38,6 +39,9 @@ install_base() { # ── Docker ─────────────────────────────────────────────────────────────── require_docker || log_warning "Docker install failed — will retry after base setup" + # ── Swapfile — default for every install, not just Asterisk droplets ──── + ensure_swapfile + # ── NVIDIA GPU (driver + container toolkit) ───────────────────────────── _base_setup_nvidia_gpu From 033ffeee48c686a79792be7c442336846914cf29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 18:14:32 +0000 Subject: [PATCH 02/11] docs: drop lyrion from the VPS plan, add emby music-only, update swap notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- docs/vps-sizing-recommendations.md | 67 ++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/docs/vps-sizing-recommendations.md b/docs/vps-sizing-recommendations.md index 5ca30c0..7b7781e 100644 --- a/docs/vps-sizing-recommendations.md +++ b/docs/vps-sizing-recommendations.md @@ -26,9 +26,11 @@ Rough per-service RAM budget, idle: Rules of thumb: - Keep at least 25-30% of total RAM free at idle for burst load (image pulls, log bursts, concurrent call/session spikes). -- A swapfile is cheap insurance below ~2GB RAM. `services/asterisk.sh` - already automates this for droplet installs ≤2GB — the same logic applies - to any small box running more than one service. +- A swapfile is cheap insurance and is now a **default for every install**, + not just Asterisk droplets — `base.sh` calls `lib/common.sh`'s + `ensure_swapfile()` unconditionally, which offers a 2GB swapfile any time + RAM is ≤4096MB and none exists yet (`services/asterisk.sh` also calls it + directly for the standalone-run case, so it's covered either way). - Sharing one `coturn` instance (`services/coturn.sh`) instead of letting each WebRTC-capable service (Asterisk, Mattermost) embed its own saves a container per consumer and — more importantly — avoids relay-port @@ -43,8 +45,8 @@ meaningful fraction of the box. **Pick one purpose, not a stack:** - **Option A — Asterisk only.** Asterisk + the shared coturn service fits comfortably per this repo's own droplet-sizing notes (`services/asterisk.sh` - README section) — a swapfile is added automatically on droplets ≤2GB, and - this plan is "fine for a couple of extensions and light personal use." + README section) — a swapfile is added automatically (RAM ≤4GB, see above), + and this plan is "fine for a couple of extensions and light personal use." - **Option B — a lightweight utility box.** Caddy + CrowdSec + NetBird (all near-zero RAM) plus at most one or two of the smallest apps (`ntfy`, `vaultwarden`, `wg-easy`) — total comfortably under 500MB. @@ -113,15 +115,29 @@ means mounting a network share from that tunnel at the mount point instead of a local directory. Avoids the disk/CPU tradeoffs of a local media library; real bandwidth depends on home upload speed, which wasn't checked. -**Floated, not yet decided:** `lyrion` (music) doing the same -home-library-over-VPN thing — same pattern as `audiobookshelf` above, -architecturally sound, just not explicitly confirmed yet. +**Music: `emby`, music-only — not `lyrion`.** `lyrion` (LMS/Squeezebox) was +floated first since it's a purpose-built, well-regarded music server, but +ruled out for two protocol-level reasons neither Caddy nor Authelia can +paper over: its own web-UI auth is one shared server-wide password (no +per-user accounts), and its player protocol (SlimProto, port 3483) is raw +TCP with no authentication of its own, so Authelia's HTTP-only +`forward_auth` can't gate it at all. `emby` (already registered in this +repo, `media` category) solves both — real per-user accounts with +per-library access restriction, and every client protocol it uses is HTTP, +so Caddy fronts all of it cleanly. `services/emby.sh` now has a music-only +mode (prompts for this, defaults the folder to `~/music`, and the generated +README walks through adding only a Music library plus the +Dashboard → Users → Access per-user restriction steps in Emby's own setup +wizard). Tradeoff accepted knowingly: Emby is a generalist media server, not +a purpose-built one — it lacks LMS's music-specific depth (its lyrics +fetching, its many audio-focused plugins). Since there's no hardware +Squeezebox tie-in to preserve, that tradeoff was fine to make. **Explicitly out of scope for this box** (wrong fit, not "can't run"): -- Local media servers storing media on the VPS (`emby`, `jellyfin`, - `immich`, and `lyrion`/`audiobookshelf` *without* the home-library-over-VPN - approach above) — disk-hungry, and transcoding CPU load risks contending - with active calls. +- Local media servers storing media on the VPS (`jellyfin`, `immich`, + `lyrion`, and `emby`/`audiobookshelf` *without* the home-library-over-VPN + approach used above) — disk-hungry, and transcoding CPU load risks + contending with active calls. - AI stacks (`ai-stack`, `ai-gpu`, `iopaint`, `paintplus`) — need real VRAM/RAM most VPS plans don't have. - Gaming (`minecraft`, `wolf`, `wolf-pair`, `sunshine`, `kyber-*`) — CPU/RAM @@ -135,3 +151,30 @@ architecturally sound, just not explicitly confirmed yet. - SSH `ProxyJump`/bastion-hop chaining for reaching genuinely isolated (CGNAT, no local peer) boxes — a good idea in principle, parked for later since NetBird already covers the current need. + +## Final RAM budget for the IONOS box (everything above, idle) + +| Service | ~RAM | +|---|---| +| OS + Docker baseline | ~400MB | +| Caddy | ~30MB | +| CrowdSec | ~150MB | +| coturn (shared) | ~40MB | +| Asterisk | ~100MB | +| Mattermost × 2 (app+Postgres each) | ~1200MB | +| Traccar (JVM) | ~425MB | +| NetBird client | ~35MB | +| ntfy, wg-easy, homebox, actualbudget, mealie | ~490MB combined | +| audiobookshelf | ~200MB | +| Emby (music-only) | ~300MB | +| **Total** | **~3.37GB** | + +Leaves roughly **~600-700MB headroom (~16-18%)** out of 4GB — tighter than +the 25-30% rule of thumb above, but with the swapfile now automatic +(`ensure_swapfile`, see above) there's real insurance against burst load +rather than relying on manual setup. Deploy incrementally and check +`free -h` / `docker stats` against this table rather than trusting it +blindly — each line carries real estimate uncertainty, and they're stacked +close enough to the ceiling that it's worth confirming. If real usage runs +higher than estimated, the two Mattermost instances (~1.2GB combined) are +the single biggest lever to reconsider. From d2848cecc3a8aea02023be71b1a563618cf21bf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:06:50 +0000 Subject: [PATCH 03/11] immich: add native S3 storage engine support for thumbnails/uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- services/immich.sh | 199 ++++++++++++++++++++++++++++----------------- 1 file changed, 124 insertions(+), 75 deletions(-) diff --git a/services/immich.sh b/services/immich.sh index 2f2e1b6..63441d8 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -198,6 +198,9 @@ install_immich() { echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres" echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper" echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate" + echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible" + echo " object storage instead of local disk (native IMMICH_STORAGE_ENGINE=s3 —" + echo " NOT a FUSE mount, those are unreliable for Immich's access pattern)" echo " - Expose port 2283" echo " - Offer a Caddy reverse proxy and to start the stack" return 0 @@ -270,18 +273,68 @@ install_immich() { echo "" + # ── S3-compatible object storage (thumbnails/encoded-video/new uploads) ── + # Native IMMICH_STORAGE_ENGINE=s3 support — talks to the S3 API directly, + # NOT a FUSE-mounted bucket pretending to be a filesystem. That distinction + # matters: Immich uses symlinks internally (S3 doesn't support them — + # ENOSYS errors under FUSE) and does thousands of stat()/read() calls on + # startup, which FUSE-over-network handles badly (reported to crash the + # mount under latency spikes as small as 100ms). Native S3 mode sidesteps + # both problems by never pretending the bucket is a filesystem. + # + # Independent of the library strategy above — an external library (if + # configured) is a separate read-only mount either way and is unaffected + # by where Immich's own managed data (thumbs/encoded-video/upload/backups/ + # profile) lives. + echo "" + local USE_S3="" + prompt_yn "Store Immich-managed data (thumbnails, encoded video, new uploads) in S3-compatible object storage instead of local disk? (y/n):" "n" USE_S3 + + local S3_BUCKET="" S3_REGION="" S3_ENDPOINT="" S3_PREFIX="" + local S3_ACCESS_KEY_ID="" S3_SECRET_ACCESS_KEY="" S3_FORCE_PATH_STYLE="" + if [ "$USE_S3" = "y" ] || [ "$USE_S3" = "Y" ]; then + USE_S3=true + echo "" + echo " S3-COMPATIBLE OBJECT STORAGE" + echo "" + prompt_text " Bucket name:" "" S3_BUCKET + prompt_text " Region (blank if your provider doesn't use one):" "us-east-1" S3_REGION + prompt_text " Endpoint URL (blank = AWS S3; set for IONOS/MinIO/other S3-compatible providers):" "" S3_ENDPOINT + prompt_text " Prefix/folder within the bucket (blank = bucket root):" "" S3_PREFIX + prompt_text " Access key ID:" "" S3_ACCESS_KEY_ID + if [ "$UNATTENDED" = true ]; then + S3_SECRET_ACCESS_KEY="" + log_warning "Unattended mode — secret access key left blank. Set S3_SECRET_ACCESS_KEY in .env before starting." + else + read -r -sp " Secret access key: " S3_SECRET_ACCESS_KEY; echo "" + fi + [ -n "$S3_ENDPOINT" ] && S3_FORCE_PATH_STYLE=true + echo "" + echo " S3 mode: thumbnails, encoded video, and new uploads go to $S3_BUCKET." + [ -n "$EXTERNAL_LIBRARY" ] && echo " Existing photos ($EXTERNAL_LIBRARY) stay where they are, read-only, unaffected." + else + USE_S3=false + fi + + echo "" + # ── Create directories ────────────────────────────────────────────────── mkdir -p "$IMMICH_DIR" ensure_docker_dir_ownership "$IMMICH_DIR" - mkdir -p "$UPLOAD_LOCATION" [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" - # Immich checks for these subdirs + .immich marker files on startup - local subdir - for subdir in thumbs upload backups library profile encoded-video; do - mkdir -p "$UPLOAD_LOCATION/$subdir" - touch "$UPLOAD_LOCATION/$subdir/.immich" - done + if [ "$USE_S3" = true ]; then + log_info "S3 mode — skipping local upload-location directories; Immich manages" + log_info "thumbs/upload/backups/library/profile/encoded-video inside the bucket." + else + mkdir -p "$UPLOAD_LOCATION" + # Immich checks for these subdirs + .immich marker files on startup + local subdir + for subdir in thumbs upload backups library profile encoded-video; do + mkdir -p "$UPLOAD_LOCATION/$subdir" + touch "$UPLOAD_LOCATION/$subdir/.immich" + done + fi cd "$IMMICH_DIR" || return 1 @@ -319,8 +372,18 @@ networks: " fi - if [ -n "$EXTERNAL_LIBRARY" ]; then - cat > docker-compose.yml << IMMICH_COMPOSE + # Composable volume lines instead of duplicating the whole compose file + # per combination — S3 mode drops the upload-location bind mount entirely + # (Immich talks to the bucket over the S3 API, nothing to mount), the + # external-library mount is independent and applies either way. + local _UPLOAD_VOLUME_LINE=" - \${UPLOAD_LOCATION}:/usr/src/app/upload +" + [ "$USE_S3" = true ] && _UPLOAD_VOLUME_LINE="" + local _EXTERNAL_VOLUME_LINE="" + [ -n "$EXTERNAL_LIBRARY" ] && _EXTERNAL_VOLUME_LINE=" - \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro +" + + cat > docker-compose.yml << IMMICH_COMPOSE name: immich services: @@ -328,9 +391,7 @@ services: container_name: immich_server image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release} volumes: - - \${UPLOAD_LOCATION}:/usr/src/app/upload - - \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro - - /etc/localtime:/etc/localtime:ro +${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro env_file: - .env ports: @@ -376,65 +437,31 @@ volumes: model-cache: ${_CADDY_NET_SECTION} IMMICH_COMPOSE - else - cat > docker-compose.yml << IMMICH_COMPOSE -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release} - volumes: - - \${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false -${_CADDY_NET_BLOCK} - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: \${DB_PASSWORD} - POSTGRES_USER: \${DB_USERNAME} - POSTGRES_DB: \${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - \${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -${_CADDY_NET_SECTION} -IMMICH_COMPOSE - fi # ── Write .env ────────────────────────────────────────────────────────── + local _UPLOAD_LOCATION_LINE="UPLOAD_LOCATION=$UPLOAD_LOCATION" + local _S3_BLOCK="" + if [ "$USE_S3" = true ]; then + # Do NOT set UPLOAD_LOCATION when using the S3 storage engine — Immich + # derives s3:/// itself and the local bind mount is + # unused; leaving UPLOAD_LOCATION set alongside S3 vars is the + # documented footgun to avoid here. + _UPLOAD_LOCATION_LINE="# UPLOAD_LOCATION intentionally unset — S3 mode manages storage in the bucket" + _S3_BLOCK=" +# S3-compatible object storage (thumbnails, encoded video, new uploads) — +# NOT a FUSE mount, this is Immich's native S3 storage engine talking to +# the bucket directly over the S3 API. +IMMICH_STORAGE_ENGINE=s3 +S3_BUCKET=$S3_BUCKET +S3_REGION=$S3_REGION +S3_ENDPOINT=$S3_ENDPOINT +S3_PREFIX=$S3_PREFIX +S3_FORCE_PATH_STYLE=$S3_FORCE_PATH_STYLE +S3_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID +S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY +" + fi + if [ "$IMMICH_STRATEGY" = "2" ]; then cat > .env << IMMICH_ENV # IMMICH CONFIGURATION — External Library Mode @@ -449,11 +476,11 @@ IMMICH_COMPOSE # Click "Scan" to index your existing photos. # New uploads from phone/web -UPLOAD_LOCATION=$UPLOAD_LOCATION +$_UPLOAD_LOCATION_LINE # Existing photos (read-only, indexed by Immich) EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY - +${_S3_BLOCK} DB_DATA_LOCATION=./postgres IMMICH_VERSION=release DB_PASSWORD=$DB_PASS @@ -471,7 +498,8 @@ IMMICH_ENV # # To import existing photos run: $IMMICH_DIR/import-photos.sh -UPLOAD_LOCATION=$UPLOAD_LOCATION +$_UPLOAD_LOCATION_LINE +${_S3_BLOCK} DB_DATA_LOCATION=./postgres IMMICH_VERSION=release DB_PASSWORD=$DB_PASS @@ -481,9 +509,10 @@ TZ=$TZ_VAL CADDY_NET=$SITE_CADDY_NET IMMICH_ENV fi + chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" + [ "$USE_S3" != true ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true # ── import-photos.sh (strategy 1 + existing photos only) ─────────────── @@ -773,9 +802,29 @@ Self-hosted photo and video backup — like Google Photos but private. Mobile apps (iOS/Android) auto-upload in the background. - Web UI: http://localhost:2283 -- Photo storage: \`$UPLOAD_LOCATION\` +- Photo storage: $( [ "$USE_S3" = true ] && echo "S3 bucket \`$S3_BUCKET\` (thumbnails, encoded video, new uploads)" || echo "\`$UPLOAD_LOCATION\`" ) - App data (postgres, model cache): inside this folder -- Edit paths in \`.env\`, then \`docker compose up -d\` to apply. +- Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply. +$( [ "$USE_S3" = true ] && cat << S3MD + +## S3 object storage +Thumbnails, encoded video, and new uploads live in \`$S3_BUCKET\` +(this is Immich's native \`IMMICH_STORAGE_ENGINE=s3\`, talking to the S3 API +directly — **not** a FUSE-mounted bucket). Don't try to switch this to a +\`rclone mount\`/s3fs-style setup instead: Immich uses symlinks internally +that S3 doesn't support under FUSE (ENOSYS errors), and its startup alone +does thousands of stat()/read() calls that FUSE-over-network handles badly — +this has been reported to crash the mount under latency spikes as small as +100ms. The native S3 engine avoids both problems entirely. +$( [ -n "$EXTERNAL_LIBRARY" ] && echo "An external library is unaffected by this — it's a separate read-only mount (\`$EXTERNAL_LIBRARY\`) regardless of where Immich's own managed data lives." ) + +Credentials and bucket config are in \`.env\` (\`S3_*\` vars, \`chmod 600\`). +Changing them requires recreating the container: +\`\`\`bash +docker compose up -d --force-recreate immich-server +\`\`\` +S3MD +) ## Manage \`\`\`bash From 22a63662583b5e887148b259693e25d19ec315e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:49:22 +0000 Subject: [PATCH 04/11] coturn: fix unescaped backticks corrupting generated README + stray error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- services/coturn.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/coturn.sh b/services/coturn.sh index 7b7ec33..19b3fdc 100644 --- a/services/coturn.sh +++ b/services/coturn.sh @@ -362,7 +362,7 @@ if [ -n "\$COTURN_HOST" ]; then # configure_caddy_for_service's CADDY_SERVICE_* out-params) else # coturn unavailable — degrade gracefully (no TURN, or prompt to run - # `sudo ./setup.sh coturn` first) + # \`sudo ./setup.sh coturn\` first) fi \`\`\` MD From a26d1831eec0e12fd9f1f2106896b5f8089116bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:57:18 +0000 Subject: [PATCH 05/11] =?UTF-8?q?Add=20services/wordpress.sh=20=E2=80=94?= =?UTF-8?q?=20multi-site=20WordPress=20with=20shared=20MariaDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- README.md | 4 +- services/wordpress.sh | 572 ++++++++++++++++++++++++++++++++++++++++++ setup.sh | 4 + 3 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 services/wordpress.sh diff --git a/README.md b/README.md index 9fbf20e..7a3da93 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, shared MariaDB — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | @@ -89,6 +89,7 @@ homelab caddy crowdsec authelia + coturn homeassistant asterisk pstn-trunk @@ -131,6 +132,7 @@ utilities watchyourlan watchtower wg-easy + wordpress media arm diff --git a/services/wordpress.sh b/services/wordpress.sh new file mode 100644 index 0000000..4cf86b2 --- /dev/null +++ b/services/wordpress.sh @@ -0,0 +1,572 @@ +#!/bin/bash +# services/wordpress.sh — Self-hosted WordPress sites, multi-site, shared MariaDB. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash wordpress.sh +# (Docker must already be installed when run standalone) +# +# Every WordPress site gets its own directory/containers, but all sites on +# this box share ONE MariaDB container (chain-installed on first need, same +# "one shared thing instead of N heavy duplicates" idea as services/coturn.sh +# — just scoped to WordPress's own sites rather than shared across different +# services). Each site gets its own database + credentials within that +# shared instance instead of a dedicated MariaDB container per site. +# +# E-commerce (WooCommerce) is just a normal WordPress plugin — no separate +# infrastructure needed. PHP upload/memory limits are tuned upfront so it +# works well the first time instead of hitting default-image limits on the +# first product-image import. + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi + echo " Existing install detected. Choose:" + echo " r) Reinstall in place — refresh image/compose, keep database and settings" + echo " f) Full install — re-run every prompt from scratch" + echo " c) Cancel — leave everything as-is [default]" + read -r -p " Choice [r/f/c, Enter=cancel]: " _r + case "${_r,,}" in + r) eval "$_var='update'" ;; + f) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block_upstream="$_upstream" + [[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true + } + fi + + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service wordpress utilities "Self-hosted WordPress sites (multi-site, shared MariaDB) — blogs, business sites, e-commerce via WooCommerce" 8090 + +# ── Shared MariaDB, chain-installed the first time any site needs it ──────── +# Out-param (not `local`): WP_DB_ROOT_PASS — read after this returns. +_wordpress_ensure_shared_db() { + local DB_DIR="$DOCKER_DIR/wordpress-db" + WP_DB_ROOT_PASS="" + + if [ -f "$DB_DIR/.env" ]; then + WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' "$DB_DIR/.env" | cut -d= -f2-)" + [ -n "$WP_DB_ROOT_PASS" ] && return 0 + fi + + log_info "No shared WordPress database yet — setting one up (used by every WordPress site on this box)..." + mkdir -p "$DB_DIR/data" + ensure_docker_dir_ownership "$DB_DIR" + + WP_DB_ROOT_PASS="$(generate_password 32)" + + # Dedicated network so WordPress site containers can reach the shared DB + # without joining caddy_net (that one's for Caddy<->service HTTP traffic). + docker network inspect wordpress_net &>/dev/null || docker network create wordpress_net &>/dev/null + + cat > "$DB_DIR/docker-compose.yml" << 'WPDBCOMPOSE' +name: wordpress-db + +services: + wordpress-db: + image: mariadb:11 + container_name: wordpress-db + hostname: wordpress-db + restart: unless-stopped + env_file: .env + volumes: + - ./data:/var/lib/mysql + networks: + - wordpress_net + +networks: + wordpress_net: + external: true +WPDBCOMPOSE + + cat > "$DB_DIR/.env" << WPDBENV +# Shared MariaDB for every WordPress site on this box — each site gets its +# own database + user within this one instance instead of a dedicated +# MariaDB container per site (same resource-sharing idea as coturn, just +# scoped to WordPress's own sites). Changing this breaks every site's DB +# connection until each site's .env is updated to match. +MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS +MARIADB_AUTO_UPGRADE=1 +WPDBENV + chmod 600 "$DB_DIR/.env" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DB_DIR" + + ( cd "$DB_DIR" && docker compose up -d ) \ + && log_success "Shared WordPress database started" \ + || { log_warning "Failed to start the shared WordPress database — check: cd $DB_DIR && docker compose logs"; return 1; } + + local _tries=0 + until docker exec wordpress-db mysqladmin ping -uroot -p"$WP_DB_ROOT_PASS" --silent &>/dev/null || [ "$_tries" -ge 30 ]; do + sleep 1; _tries=$((_tries + 1)) + done + + write_readme "$DB_DIR" << WPDBREADME +# wordpress-db — shared MariaDB for every WordPress site + +One MariaDB instance shared by every WordPress site on this box +(\`services/wordpress.sh\`) — each site gets its own database and user +within this instance instead of a dedicated MariaDB container per site. + +Root credentials: \`.env\` (\`MYSQL_ROOT_PASSWORD\`, chmod 600). + +## Manage +\`\`\`bash +docker compose up -d +docker compose down +docker compose logs -f +docker exec -it wordpress-db mysql -uroot -p +\`\`\` + +Deleting this stops every WordPress site on the box — check +\`~/docker/wordpress-*\` for what depends on it before removing. +WPDBREADME +} + +install_wordpress() { + require_docker || return 1 + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ WORDPRESS │" + echo "│ Self-hosted WordPress site — blog, business site, or store │" + echo "│ (WooCommerce is just a plugin — install it from the WP admin │" + echo "│ after setup, no extra infrastructure needed for e-commerce) │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would prompt for a site name (directory/container naming)" + echo "[DRY-RUN] Would ensure the shared wordpress-db MariaDB container exists" + echo "[DRY-RUN] (chain-installed on first WordPress site, reused by every other site)" + echo "[DRY-RUN] Would create this site's database + credentials in that shared instance" + echo "[DRY-RUN] Would create \$DOCKER_DIR/wordpress- with docker-compose.yml + .env" + echo "[DRY-RUN] Would tune PHP memory_limit/upload_max_filesize for WooCommerce-readiness" + echo "[DRY-RUN] Would auto-scan for a free host port for this site" + echo "[DRY-RUN] Would run wp-cli to install WordPress core non-interactively (site title," + echo "[DRY-RUN] admin account) instead of leaving a setup wizard for a browser to finish" + echo "[DRY-RUN] Would offer a Caddy reverse proxy and to start the site" + return 0 + fi + + # ── Site name (used for directory/container naming) ───────────────────── + local SITE_NAME="" + while [ -z "$SITE_NAME" ]; do + prompt_text "Site name (letters/numbers/hyphens, e.g. 'myblog' or 'client-store'):" "" SITE_NAME + SITE_NAME="$(echo "$SITE_NAME" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')" + SITE_NAME="${SITE_NAME#-}"; SITE_NAME="${SITE_NAME%-}" + if [ -z "$SITE_NAME" ]; then + if [ "$UNATTENDED" = true ]; then + SITE_NAME="site1" + else + log_warning "Site name required." + fi + fi + done + + local DIR="$DOCKER_DIR/wordpress-$SITE_NAME" + local CONTAINER="wordpress-$SITE_NAME" + + # ── Existing install (this exact site)? Offer update-in-place ─────────── + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing '$SITE_NAME''s image/compose only — database, domain, and" + log_info "credentials are left exactly as they are." + ( cd "$DIR" && docker compose pull && docker compose up -d ) + log_success "'$SITE_NAME' refreshed" + return 0 + ;; + cancel) + log_info "Leaving '$SITE_NAME' as-is." + return 0 + ;; + fresh) ;; + esac + fi + + # ── Shared MariaDB ──────────────────────────────────────────────────────── + _wordpress_ensure_shared_db || return 1 + + # ── This site's database + credentials within the shared instance ─────── + local WP_DB_NAME="wp_${SITE_NAME//-/_}" + local WP_DB_USER="wp_${SITE_NAME//-/_}" + local WP_DB_PASS="" + [ -f "$DIR/.env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" + [ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)" + + if docker exec wordpress-db mysql -uroot -p"$WP_DB_ROOT_PASS" -e \ + "CREATE DATABASE IF NOT EXISTS \`$WP_DB_NAME\`; \ + CREATE USER IF NOT EXISTS '$WP_DB_USER'@'%' IDENTIFIED BY '$WP_DB_PASS'; \ + GRANT ALL PRIVILEGES ON \`$WP_DB_NAME\`.* TO '$WP_DB_USER'@'%'; \ + FLUSH PRIVILEGES;" &>/dev/null; then + log_success "Database '$WP_DB_NAME' ready on the shared MariaDB instance" + else + log_warning "Could not create the database — is wordpress-db running? Check: docker logs wordpress-db" + return 1 + fi + + echo "" + local WP_SITE_TITLE="" WP_ADMIN_USER="" WP_ADMIN_EMAIL="" + prompt_text "Site title:" "$SITE_NAME" WP_SITE_TITLE + prompt_text "Admin username:" "admin" WP_ADMIN_USER + prompt_text "Admin email:" "" WP_ADMIN_EMAIL + local WP_ADMIN_PASS="" + [ -f "$DIR/.env" ] && WP_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" + [ -n "$WP_ADMIN_PASS" ] || WP_ADMIN_PASS="$(generate_password 16)" + + # ── Free host port (multiple sites can't all bind the same one) ───────── + local WEB_PORT=8090 + while docker ps -a --format '{{.Ports}}' 2>/dev/null | grep -q ":${WEB_PORT}->"; do + WEB_PORT=$((WEB_PORT + 1)) + done + + mkdir -p "$DIR/html" "$DIR/uploads-ini.d" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local TZ_VAL="${SITE_TZ:-UTC}" + + # PHP tuning for WooCommerce/media-heavy sites out of the box — default + # image limits (2M uploads, 128M memory) are a common first-run surprise + # otherwise, especially importing a product catalog. + cat > uploads-ini.d/uploads.ini << 'PHPINI' +file_uploads = On +memory_limit = 256M +upload_max_filesize = 64M +post_max_size = 64M +max_execution_time = 300 +PHPINI + + # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): + # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, + # then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net. + local _CADDY_MODE="${CADDY_MODE:-none}" + [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" + [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" + + local _CADDY_NET_LINE="" _CADDY_NET_SECTION="" + if [ "$_CADDY_MODE" = "local" ]; then + _CADDY_NET_LINE=" - caddy_net +" + _CADDY_NET_SECTION=" + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +" + fi + + cat > docker-compose.yml << WPCOMPOSE +name: $CONTAINER + +services: + wordpress: + image: wordpress:php8.3-apache + container_name: $CONTAINER + hostname: $CONTAINER + restart: unless-stopped + env_file: .env + volumes: + - ./html:/var/www/html + - ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro + ports: + - "${WEB_PORT}:80" + networks: + - wordpress_net +${_CADDY_NET_LINE} +networks: + wordpress_net: + external: true +${_CADDY_NET_SECTION} +WPCOMPOSE + + cat > .env << WPENV +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +WORDPRESS_DB_HOST=wordpress-db +WORDPRESS_DB_NAME=$WP_DB_NAME +WORDPRESS_DB_USER=$WP_DB_USER +WORDPRESS_DB_PASSWORD=$WP_DB_PASS + +# Only consulted by wp-cli during initial setup below, not read by the +# wordpress:apache image itself (unlike Nextcloud's image, WordPress's +# official image has no built-in "create admin from env vars" feature). +WP_SITE_TITLE=$WP_SITE_TITLE +WP_ADMIN_USER=$WP_ADMIN_USER +WP_ADMIN_PASSWORD=$WP_ADMIN_PASS +WP_ADMIN_EMAIL=$WP_ADMIN_EMAIL +WPENV + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + log_success "'$SITE_NAME' configured at $DIR (port $WEB_PORT)" + + configure_caddy_for_service "WordPress - $SITE_NAME" "${CONTAINER}:80" "$SITE_NAME" + + write_readme "$DIR" << MD +# WordPress — $SITE_NAME + +Self-hosted WordPress site. Database lives on the shared \`wordpress-db\` +MariaDB instance (\`~/docker/wordpress-db\`) used by every WordPress site on +this box — not a dedicated database container for this site alone. + +- Web UI: http://localhost:${WEB_PORT} +- Admin user: \`$WP_ADMIN_USER\` +- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\` +- Site files: \`html/\` +- PHP limits: \`uploads-ini.d/uploads.ini\` (256M memory, 64M uploads — + raise further here if a specific import still hits a limit) + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## wp-cli +Run any wp-cli command against this site without installing wp-cli on the +host: +\`\`\`bash +docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ + --env-file $DIR/.env wordpress:cli wp +\`\`\` + +## E-commerce (WooCommerce) +No separate infrastructure needed — WooCommerce is a normal WordPress +plugin. Install it from Plugins → Add New in the WP admin, or via wp-cli: +\`\`\`bash +docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ + --env-file $DIR/.env wordpress:cli wp plugin install woocommerce --activate +\`\`\` +The PHP limits above (256M memory, 64M uploads) were already sized with +WooCommerce's own recommendations in mind, so product/image imports work +without hitting default-image limits on the first try. + +## Backup +Back up \`html/\` (site files/plugins/themes/media) and this site's +database on \`wordpress-db\` (\`docker exec wordpress-db mysqldump -uroot -p +$WP_DB_NAME > backup.sql\`) — \`.env\` holds the root password. +MD + + local START_WP="" + prompt_yn "Start '$SITE_NAME' now? (y/n):" "y" START_WP + if [[ "$START_WP" =~ ^[Yy]$ ]]; then + if docker compose up -d; then + log_success "'$SITE_NAME' started" + + log_info "Waiting for WordPress to come up, then running wp-cli core install..." + local _tries=0 + until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do + sleep 1; _tries=$((_tries + 1)) + done + + if docker run --rm --network wordpress_net \ + -v "$DIR/html:/var/www/html" \ + -e WORDPRESS_DB_HOST=wordpress-db \ + -e WORDPRESS_DB_NAME="$WP_DB_NAME" \ + -e WORDPRESS_DB_USER="$WP_DB_USER" \ + -e WORDPRESS_DB_PASSWORD="$WP_DB_PASS" \ + wordpress:cli \ + core install \ + --url="http://localhost:${WEB_PORT}" \ + --title="$WP_SITE_TITLE" \ + --admin_user="$WP_ADMIN_USER" \ + --admin_password="$WP_ADMIN_PASS" \ + --admin_email="$WP_ADMIN_EMAIL" \ + --skip-email &>/dev/null; then + log_success "WordPress installed — no browser setup wizard needed" + else + log_warning "wp-cli install didn't complete (WordPress may not have been ready yet, or was" + log_warning "already installed). Finish setup in the browser, or retry manually:" + log_warning " docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\" + log_warning " -e WORDPRESS_DB_HOST=wordpress-db -e WORDPRESS_DB_NAME=$WP_DB_NAME \\" + log_warning " -e WORDPRESS_DB_USER=$WP_DB_USER -e WORDPRESS_DB_PASSWORD=$WP_DB_PASS \\" + log_warning " wordpress:cli core install --url=http://localhost:${WEB_PORT} \\" + log_warning " --title=\"$WP_SITE_TITLE\" --admin_user=$WP_ADMIN_USER \\" + log_warning " --admin_password= --admin_email=$WP_ADMIN_EMAIL" + fi + else + log_warning "Failed to start — check: docker compose logs" + fi + fi + + echo "" + echo " Access at: http://localhost:${WEB_PORT}" + echo " Admin user: $WP_ADMIN_USER" + echo " Admin pass: $WP_ADMIN_PASS" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wordpress diff --git a/setup.sh b/setup.sh index d1371ea..58d9fba 100755 --- a/setup.sh +++ b/setup.sh @@ -99,6 +99,10 @@ is_installed() { pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;; sms-inbound) [ -f /opt/sms-inbound/settings.env ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] + # Every WordPress site is named from the first one on (no plain + # $DOCKER_DIR/wordpress dir the default case below could match) — + # [installed] means "at least one site exists", not any specific one. + wordpress) compgen -G "$DOCKER_DIR/wordpress-*" >/dev/null 2>&1 ;; *) [ -e "$DOCKER_DIR/$1" ] ;; esac } From e96a257d8f1ecf39c31ab1745bd9f6856e04e139 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:16:00 +0000 Subject: [PATCH 06/11] docs: record WordPress decision (2-4 sites, ecommerce-capable, Emby dropped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- docs/vps-sizing-recommendations.md | 42 +++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/vps-sizing-recommendations.md b/docs/vps-sizing-recommendations.md index 7b7781e..823200f 100644 --- a/docs/vps-sizing-recommendations.md +++ b/docs/vps-sizing-recommendations.md @@ -133,6 +133,23 @@ a purpose-built one — it lacks LMS's music-specific depth (its lyrics fetching, its many audio-focused plugins). Since there's no hardware Squeezebox tie-in to preserve, that tradeoff was fine to make. +**Emby subsequently dropped from the near-term plan** — traded off for +WordPress capacity (below) rather than run alongside it. `services/emby.sh`'s +music-only mode is still there and ready whenever there's headroom for it +again; it just isn't part of the current baseline. + +**WordPress — confirmed, 2-4 sites, light traffic, ecommerce-capable.** +`services/wordpress.sh` (new): multi-site from the start, every site named, +one shared MariaDB container instead of a dedicated database container per +site (same resource-sharing idea as `coturn`, scoped to WordPress's own +sites). Each site gets its own database + user within that shared +instance — required, not just tidy: WordPress's schema uses generic table +names (`wp_posts`, `wp_options`, etc.), so two installs sharing one database +with the same table prefix would collide. wp-cli automates the initial +install (title, admin account) so there's no per-site browser setup wizard, +and PHP limits are pre-tuned (256M memory, 64M uploads) for WooCommerce +specifically since "possible ecommerce" was part of the ask. + **Explicitly out of scope for this box** (wrong fit, not "can't run"): - Local media servers storing media on the VPS (`jellyfin`, `immich`, `lyrion`, and `emby`/`audiobookshelf` *without* the home-library-over-VPN @@ -152,7 +169,7 @@ Squeezebox tie-in to preserve, that tradeoff was fine to make. (CGNAT, no local peer) boxes — a good idea in principle, parked for later since NetBird already covers the current need. -## Final RAM budget for the IONOS box (everything above, idle) +## Final RAM budget for the IONOS box (current baseline, no Emby, idle) | Service | ~RAM | |---|---| @@ -164,17 +181,22 @@ Squeezebox tie-in to preserve, that tradeoff was fine to make. | Mattermost × 2 (app+Postgres each) | ~1200MB | | Traccar (JVM) | ~425MB | | NetBird client | ~35MB | -| ntfy, wg-easy, homebox, actualbudget, mealie | ~490MB combined | -| audiobookshelf | ~200MB | -| Emby (music-only) | ~300MB | -| **Total** | **~3.37GB** | +| ntfy, actualbudget, mealie | ~340MB combined | +| Shared MariaDB (WordPress, one-time) | ~180MB | +| WordPress × 4 sites (~120MB each — sized for possible WooCommerce, not a light blog) | ~480MB | +| **Total** | **~3.38GB** | -Leaves roughly **~600-700MB headroom (~16-18%)** out of 4GB — tighter than -the 25-30% rule of thumb above, but with the swapfile now automatic +Leaves roughly **~700MB headroom (~17%)** out of 4GB — tighter than the +25-30% rule of thumb above, but with the swapfile now automatic (`ensure_swapfile`, see above) there's real insurance against burst load -rather than relying on manual setup. Deploy incrementally and check -`free -h` / `docker stats` against this table rather than trusting it -blindly — each line carries real estimate uncertainty, and they're stacked +rather than relying on manual setup. At the low end of the site range (2 +instead of 4), headroom improves to roughly ~950MB (~23%). `wg-easy`, +`homebox`, and `audiobookshelf` from earlier in this doc aren't included in +this specific table — add them back in at ~25MB, ~125MB, and ~200MB +respectively if/when they're actually deployed alongside this baseline. +Deploy incrementally and check `free -h` / `docker stats` against this table +rather than trusting it blindly — each line carries real estimate +uncertainty, and they're stacked close enough to the ceiling that it's worth confirming. If real usage runs higher than estimated, the two Mattermost instances (~1.2GB combined) are the single biggest lever to reconsider. From a5d57050b33854bf6c7beeca7051aabcd39021f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:23:24 +0000 Subject: [PATCH 07/11] wordpress: switch to dedicated MariaDB per site (was shared) 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 -db) on an explicitly-named per-site default network (_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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- README.md | 2 +- docs/vps-sizing-recommendations.md | 44 +++--- services/wordpress.sh | 210 +++++++++++------------------ 3 files changed, 109 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 7a3da93..1993f87 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, shared MariaDB — blogs, business sites, e-commerce via WooCommerce) | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/docs/vps-sizing-recommendations.md b/docs/vps-sizing-recommendations.md index 823200f..8530f99 100644 --- a/docs/vps-sizing-recommendations.md +++ b/docs/vps-sizing-recommendations.md @@ -140,14 +140,23 @@ again; it just isn't part of the current baseline. **WordPress — confirmed, 2-4 sites, light traffic, ecommerce-capable.** `services/wordpress.sh` (new): multi-site from the start, every site named, -one shared MariaDB container instead of a dedicated database container per -site (same resource-sharing idea as `coturn`, scoped to WordPress's own -sites). Each site gets its own database + user within that shared -instance — required, not just tidy: WordPress's schema uses generic table -names (`wp_posts`, `wp_options`, etc.), so two installs sharing one database -with the same table prefix would collide. wp-cli automates the initial -install (title, admin account) so there's no per-site browser setup wizard, -and PHP limits are pre-tuned (256M memory, 64M uploads) for WooCommerce +each with its own **dedicated** MariaDB container (same pattern as +`services/nextcloud.sh`) — not a shared instance. Started as a shared-MariaDB +design (same resource-sharing idea as `coturn`) but switched to dedicated +per-site after weighing it against backup/restore: Kopia's generic backup +(`services/backup.sh`) stops a service's container to snapshot it, so a +shared instance would back up — and would have to be restored — as one unit +covering every site at once, not one site independently. Dedicated per-site +costs more RAM (a full MariaDB container each, ~100-150MB, instead of one +instance amortized across sites) in exchange for real isolation: each +site's database backs up and restores completely independently. Separate +databases were always required regardless of which model — WordPress's +schema uses generic table names (`wp_posts`, `wp_options`, etc.), so two +installs sharing one database with the same table prefix would collide — +the shared-vs-dedicated choice was only ever about the container/process, +never about the data being mixed. wp-cli automates the initial install +(title, admin account) so there's no per-site browser setup wizard, and PHP +limits are pre-tuned (256M memory, 64M uploads) for WooCommerce specifically since "possible ecommerce" was part of the ask. **Explicitly out of scope for this box** (wrong fit, not "can't run"): @@ -182,15 +191,18 @@ specifically since "possible ecommerce" was part of the ask. | Traccar (JVM) | ~425MB | | NetBird client | ~35MB | | ntfy, actualbudget, mealie | ~340MB combined | -| Shared MariaDB (WordPress, one-time) | ~180MB | -| WordPress × 4 sites (~120MB each — sized for possible WooCommerce, not a light blog) | ~480MB | -| **Total** | **~3.38GB** | +| WordPress × 4 sites (app ~80MB + dedicated MariaDB ~120MB each) | ~800MB | +| **Total** | **~3.52GB** | -Leaves roughly **~700MB headroom (~17%)** out of 4GB — tighter than the -25-30% rule of thumb above, but with the swapfile now automatic -(`ensure_swapfile`, see above) there's real insurance against burst load -rather than relying on manual setup. At the low end of the site range (2 -instead of 4), headroom improves to roughly ~950MB (~23%). `wg-easy`, +Leaves roughly **~580MB headroom (~14%)** out of 4GB at 4 sites — tighter +than the shared-MariaDB design would have been (~700MB), the real cost of +per-site backup/restore isolation, and tighter than the 25-30% rule of +thumb above. With the swapfile now automatic (`ensure_swapfile`, see above) +there's still real insurance against burst load. At the low end of the site +range (2 instead of 4), it's ~3.12GB used, ~976MB headroom (~24%) — the gap +between shared and dedicated MariaDB narrows a lot at low site counts, +since the shared model's one fixed instance cost is amortized across fewer +sites. `wg-easy`, `homebox`, and `audiobookshelf` from earlier in this doc aren't included in this specific table — add them back in at ~25MB, ~125MB, and ~200MB respectively if/when they're actually deployed alongside this baseline. diff --git a/services/wordpress.sh b/services/wordpress.sh index 4cf86b2..b638075 100644 --- a/services/wordpress.sh +++ b/services/wordpress.sh @@ -1,17 +1,23 @@ #!/bin/bash -# services/wordpress.sh — Self-hosted WordPress sites, multi-site, shared MariaDB. +# services/wordpress.sh — Self-hosted WordPress sites, multi-site, dedicated MariaDB per site. # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash wordpress.sh # (Docker must already be installed when run standalone) # -# Every WordPress site gets its own directory/containers, but all sites on -# this box share ONE MariaDB container (chain-installed on first need, same -# "one shared thing instead of N heavy duplicates" idea as services/coturn.sh -# — just scoped to WordPress's own sites rather than shared across different -# services). Each site gets its own database + credentials within that -# shared instance instead of a dedicated MariaDB container per site. +# Every WordPress site gets its own directory, its own WordPress container, +# and its own dedicated MariaDB container (same pattern as services/ +# nextcloud.sh) — deliberately NOT a shared MariaDB instance across sites. +# A shared instance would mean one site's database backup/restore is +# entangled with every other site's: Kopia's generic backup (services/ +# backup.sh) stops a service's container to get a consistent snapshot, so a +# shared instance backs up (and would have to be restored) as one unit +# covering every site at once, not one site independently. Costs more RAM +# per site (a full MariaDB container each, ~100-150MB, instead of one +# instance split across sites) in exchange for real backup/restore +# isolation — each site's database can be restored to any point in time +# without touching any other site's current data. # # E-commerce (WooCommerce) is just a normal WordPress plugin — no separate # infrastructure needed. PHP upload/memory limits are tuned upfront so it @@ -202,91 +208,7 @@ CBLOCK fi # ───────────────────────────────────────────────────────────────────────────── -register_service wordpress utilities "Self-hosted WordPress sites (multi-site, shared MariaDB) — blogs, business sites, e-commerce via WooCommerce" 8090 - -# ── Shared MariaDB, chain-installed the first time any site needs it ──────── -# Out-param (not `local`): WP_DB_ROOT_PASS — read after this returns. -_wordpress_ensure_shared_db() { - local DB_DIR="$DOCKER_DIR/wordpress-db" - WP_DB_ROOT_PASS="" - - if [ -f "$DB_DIR/.env" ]; then - WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' "$DB_DIR/.env" | cut -d= -f2-)" - [ -n "$WP_DB_ROOT_PASS" ] && return 0 - fi - - log_info "No shared WordPress database yet — setting one up (used by every WordPress site on this box)..." - mkdir -p "$DB_DIR/data" - ensure_docker_dir_ownership "$DB_DIR" - - WP_DB_ROOT_PASS="$(generate_password 32)" - - # Dedicated network so WordPress site containers can reach the shared DB - # without joining caddy_net (that one's for Caddy<->service HTTP traffic). - docker network inspect wordpress_net &>/dev/null || docker network create wordpress_net &>/dev/null - - cat > "$DB_DIR/docker-compose.yml" << 'WPDBCOMPOSE' -name: wordpress-db - -services: - wordpress-db: - image: mariadb:11 - container_name: wordpress-db - hostname: wordpress-db - restart: unless-stopped - env_file: .env - volumes: - - ./data:/var/lib/mysql - networks: - - wordpress_net - -networks: - wordpress_net: - external: true -WPDBCOMPOSE - - cat > "$DB_DIR/.env" << WPDBENV -# Shared MariaDB for every WordPress site on this box — each site gets its -# own database + user within this one instance instead of a dedicated -# MariaDB container per site (same resource-sharing idea as coturn, just -# scoped to WordPress's own sites). Changing this breaks every site's DB -# connection until each site's .env is updated to match. -MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS -MARIADB_AUTO_UPGRADE=1 -WPDBENV - chmod 600 "$DB_DIR/.env" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DB_DIR" - - ( cd "$DB_DIR" && docker compose up -d ) \ - && log_success "Shared WordPress database started" \ - || { log_warning "Failed to start the shared WordPress database — check: cd $DB_DIR && docker compose logs"; return 1; } - - local _tries=0 - until docker exec wordpress-db mysqladmin ping -uroot -p"$WP_DB_ROOT_PASS" --silent &>/dev/null || [ "$_tries" -ge 30 ]; do - sleep 1; _tries=$((_tries + 1)) - done - - write_readme "$DB_DIR" << WPDBREADME -# wordpress-db — shared MariaDB for every WordPress site - -One MariaDB instance shared by every WordPress site on this box -(\`services/wordpress.sh\`) — each site gets its own database and user -within this instance instead of a dedicated MariaDB container per site. - -Root credentials: \`.env\` (\`MYSQL_ROOT_PASSWORD\`, chmod 600). - -## Manage -\`\`\`bash -docker compose up -d -docker compose down -docker compose logs -f -docker exec -it wordpress-db mysql -uroot -p -\`\`\` - -Deleting this stops every WordPress site on the box — check -\`~/docker/wordpress-*\` for what depends on it before removing. -WPDBREADME -} +register_service wordpress utilities "Self-hosted WordPress sites (multi-site, dedicated MariaDB per site) — blogs, business sites, e-commerce via WooCommerce" 8090 install_wordpress() { require_docker || return 1 @@ -302,10 +224,9 @@ install_wordpress() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would prompt for a site name (directory/container naming)" - echo "[DRY-RUN] Would ensure the shared wordpress-db MariaDB container exists" - echo "[DRY-RUN] (chain-installed on first WordPress site, reused by every other site)" - echo "[DRY-RUN] Would create this site's database + credentials in that shared instance" echo "[DRY-RUN] Would create \$DOCKER_DIR/wordpress- with docker-compose.yml + .env" + echo "[DRY-RUN] including a dedicated MariaDB container for this site alone (not shared" + echo "[DRY-RUN] with other sites — independent backup/restore per site)" echo "[DRY-RUN] Would tune PHP memory_limit/upload_max_filesize for WooCommerce-readiness" echo "[DRY-RUN] Would auto-scan for a free host port for this site" echo "[DRY-RUN] Would run wp-cli to install WordPress core non-interactively (site title," @@ -352,26 +273,23 @@ install_wordpress() { esac fi - # ── Shared MariaDB ──────────────────────────────────────────────────────── - _wordpress_ensure_shared_db || return 1 - - # ── This site's database + credentials within the shared instance ─────── + # ── This site's dedicated database credentials ─────────────────────────── + # The mariadb image creates MYSQL_DATABASE/MYSQL_USER itself from these + # env vars on first boot — no imperative CREATE DATABASE step needed, + # same as services/nextcloud.sh. Reused across reruns (read from the + # existing .env if present) so a rerun never locks the site out of its + # own already-initialized database. + local DB_CONTAINER="${CONTAINER}-db" + local WP_NET="${CONTAINER}_net" local WP_DB_NAME="wp_${SITE_NAME//-/_}" local WP_DB_USER="wp_${SITE_NAME//-/_}" - local WP_DB_PASS="" - [ -f "$DIR/.env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" - [ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)" - - if docker exec wordpress-db mysql -uroot -p"$WP_DB_ROOT_PASS" -e \ - "CREATE DATABASE IF NOT EXISTS \`$WP_DB_NAME\`; \ - CREATE USER IF NOT EXISTS '$WP_DB_USER'@'%' IDENTIFIED BY '$WP_DB_PASS'; \ - GRANT ALL PRIVILEGES ON \`$WP_DB_NAME\`.* TO '$WP_DB_USER'@'%'; \ - FLUSH PRIVILEGES;" &>/dev/null; then - log_success "Database '$WP_DB_NAME' ready on the shared MariaDB instance" - else - log_warning "Could not create the database — is wordpress-db running? Check: docker logs wordpress-db" - return 1 + local WP_DB_PASS="" WP_DB_ROOT_PASS="" + if [ -f "$DIR/.env" ]; then + WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" + WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" fi + [ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)" + [ -n "$WP_DB_ROOT_PASS" ] || WP_DB_ROOT_PASS="$(generate_password 32)" echo "" local WP_SITE_TITLE="" WP_ADMIN_USER="" WP_ADMIN_EMAIL="" @@ -388,7 +306,7 @@ install_wordpress() { WEB_PORT=$((WEB_PORT + 1)) done - mkdir -p "$DIR/html" "$DIR/uploads-ini.d" + mkdir -p "$DIR/html" "$DIR/db" "$DIR/uploads-ini.d" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 @@ -433,17 +351,30 @@ services: hostname: $CONTAINER restart: unless-stopped env_file: .env + depends_on: + - db volumes: - ./html:/var/www/html - ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro ports: - "${WEB_PORT}:80" networks: - - wordpress_net + - default ${_CADDY_NET_LINE} + db: + image: mariadb:11 + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER + restart: unless-stopped + env_file: .env + volumes: + - ./db:/var/lib/mysql + networks: + - default + networks: - wordpress_net: - external: true + default: + name: $WP_NET ${_CADDY_NET_SECTION} WPCOMPOSE @@ -451,7 +382,15 @@ WPCOMPOSE TZ=$TZ_VAL CADDY_NET=$SITE_CADDY_NET -WORDPRESS_DB_HOST=wordpress-db +# Dedicated MariaDB for this site alone (not shared with other WordPress +# sites) — independent backup/restore, at the cost of a full MariaDB +# container per site instead of one instance split across several. +MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS +MYSQL_DATABASE=$WP_DB_NAME +MYSQL_USER=$WP_DB_USER +MYSQL_PASSWORD=$WP_DB_PASS + +WORDPRESS_DB_HOST=$DB_CONTAINER WORDPRESS_DB_NAME=$WP_DB_NAME WORDPRESS_DB_USER=$WP_DB_USER WORDPRESS_DB_PASSWORD=$WP_DB_PASS @@ -474,21 +413,27 @@ WPENV write_readme "$DIR" << MD # WordPress — $SITE_NAME -Self-hosted WordPress site. Database lives on the shared \`wordpress-db\` -MariaDB instance (\`~/docker/wordpress-db\`) used by every WordPress site on -this box — not a dedicated database container for this site alone. +Self-hosted WordPress site with its own **dedicated** MariaDB container +(\`$DB_CONTAINER\`, in \`db/\` below) — not shared with any other WordPress +site on this box. Costs more RAM per site than a shared database would, in +exchange for independent backup/restore: this site's database can be +restored to any point in time without touching any other site's data, since +each one is backed up (and would be restored) as its own separate Kopia +snapshot rather than being entangled with other sites in one shared +snapshot. - Web UI: http://localhost:${WEB_PORT} - Admin user: \`$WP_ADMIN_USER\` - Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\` - Site files: \`html/\` +- Database files: \`db/\` - PHP limits: \`uploads-ini.d/uploads.ini\` (256M memory, 64M uploads — raise further here if a specific import still hits a limit) ## Manage \`\`\`bash cd $DIR -docker compose up -d # start +docker compose up -d # start (both wordpress and its db) docker compose down # stop docker compose logs -f # logs docker compose pull && docker compose up -d # update @@ -498,7 +443,7 @@ docker compose pull && docker compose up -d # update Run any wp-cli command against this site without installing wp-cli on the host: \`\`\`bash -docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ +docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\ --env-file $DIR/.env wordpress:cli wp \`\`\` @@ -506,7 +451,7 @@ docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ No separate infrastructure needed — WooCommerce is a normal WordPress plugin. Install it from Plugins → Add New in the WP admin, or via wp-cli: \`\`\`bash -docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ +docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\ --env-file $DIR/.env wordpress:cli wp plugin install woocommerce --activate \`\`\` The PHP limits above (256M memory, 64M uploads) were already sized with @@ -514,9 +459,14 @@ WooCommerce's own recommendations in mind, so product/image imports work without hitting default-image limits on the first try. ## Backup -Back up \`html/\` (site files/plugins/themes/media) and this site's -database on \`wordpress-db\` (\`docker exec wordpress-db mysqldump -uroot -p -$WP_DB_NAME > backup.sql\`) — \`.env\` holds the root password. +\`services/backup.sh\` (Kopia) already covers this directory automatically — +it stops \`docker compose down\`, snapshots \`$DIR\`, and restarts, generically +for every \`~/docker/*\` directory with a \`docker-compose.yml\`, so both +\`html/\` and \`db/\` are captured together on every run with no per-site setup +needed. For an ad hoc logical dump instead: +\`\`\`bash +docker exec $DB_CONTAINER mysqldump -uroot -p"\$(grep MYSQL_ROOT_PASSWORD .env | cut -d= -f2-)" $WP_DB_NAME > backup.sql +\`\`\` MD local START_WP="" @@ -531,9 +481,9 @@ MD sleep 1; _tries=$((_tries + 1)) done - if docker run --rm --network wordpress_net \ + if docker run --rm --network "$WP_NET" \ -v "$DIR/html:/var/www/html" \ - -e WORDPRESS_DB_HOST=wordpress-db \ + -e WORDPRESS_DB_HOST="$DB_CONTAINER" \ -e WORDPRESS_DB_NAME="$WP_DB_NAME" \ -e WORDPRESS_DB_USER="$WP_DB_USER" \ -e WORDPRESS_DB_PASSWORD="$WP_DB_PASS" \ @@ -549,8 +499,8 @@ MD else log_warning "wp-cli install didn't complete (WordPress may not have been ready yet, or was" log_warning "already installed). Finish setup in the browser, or retry manually:" - log_warning " docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\" - log_warning " -e WORDPRESS_DB_HOST=wordpress-db -e WORDPRESS_DB_NAME=$WP_DB_NAME \\" + log_warning " docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\" + log_warning " -e WORDPRESS_DB_HOST=$DB_CONTAINER -e WORDPRESS_DB_NAME=$WP_DB_NAME \\" log_warning " -e WORDPRESS_DB_USER=$WP_DB_USER -e WORDPRESS_DB_PASSWORD=$WP_DB_PASS \\" log_warning " wordpress:cli core install --url=http://localhost:${WEB_PORT} \\" log_warning " --title=\"$WP_SITE_TITLE\" --admin_user=$WP_ADMIN_USER \\" From 344bdf4a0f58e8b2ddd0607596bae6d88ed18075 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:37:39 +0000 Subject: [PATCH 08/11] docs: settle on 2 WordPress sites, drop actualbudget 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- docs/vps-sizing-recommendations.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/docs/vps-sizing-recommendations.md b/docs/vps-sizing-recommendations.md index 8530f99..cb2a1fe 100644 --- a/docs/vps-sizing-recommendations.md +++ b/docs/vps-sizing-recommendations.md @@ -82,9 +82,10 @@ no transcoding/conferencing/heavy-video load in this profile, so CPU has large margin and RAM sits around 2.0-2.7GB idle with the core stack alone. **Utility adds, agreed:** -- `ntfy`, `wg-easy`, `homebox`, `actualbudget`, `mealie` +- `ntfy`, `wg-easy`, `homebox`, `mealie` -**Explicitly declined:** `vaultwarden`, `portainer`, `syncthing` +**Explicitly declined:** `vaultwarden`, `portainer`, `syncthing`, `actualbudget` +(dropped to make room for WordPress — see below) **Remote / cross-VLAN access:** NetBird — hosted control plane (not self-hosted), client-only, with its embedded SSH server enabled @@ -138,7 +139,7 @@ WordPress capacity (below) rather than run alongside it. `services/emby.sh`'s music-only mode is still there and ready whenever there's headroom for it again; it just isn't part of the current baseline. -**WordPress — confirmed, 2-4 sites, light traffic, ecommerce-capable.** +**WordPress — confirmed, 2 sites (settled), light traffic, ecommerce-capable.** `services/wordpress.sh` (new): multi-site from the start, every site named, each with its own **dedicated** MariaDB container (same pattern as `services/nextcloud.sh`) — not a shared instance. Started as a shared-MariaDB @@ -178,7 +179,7 @@ specifically since "possible ecommerce" was part of the ask. (CGNAT, no local peer) boxes — a good idea in principle, parked for later since NetBird already covers the current need. -## Final RAM budget for the IONOS box (current baseline, no Emby, idle) +## Final RAM budget for the IONOS box (settled baseline, no Emby, no actualbudget, idle) | Service | ~RAM | |---|---| @@ -190,19 +191,16 @@ specifically since "possible ecommerce" was part of the ask. | Mattermost × 2 (app+Postgres each) | ~1200MB | | Traccar (JVM) | ~425MB | | NetBird client | ~35MB | -| ntfy, actualbudget, mealie | ~340MB combined | -| WordPress × 4 sites (app ~80MB + dedicated MariaDB ~120MB each) | ~800MB | -| **Total** | **~3.52GB** | +| ntfy, mealie | ~225MB combined | +| WordPress × 2 sites (app ~80MB + dedicated MariaDB ~120MB each) | ~400MB | +| **Total** | **~3.00GB** | -Leaves roughly **~580MB headroom (~14%)** out of 4GB at 4 sites — tighter -than the shared-MariaDB design would have been (~700MB), the real cost of -per-site backup/restore isolation, and tighter than the 25-30% rule of -thumb above. With the swapfile now automatic (`ensure_swapfile`, see above) -there's still real insurance against burst load. At the low end of the site -range (2 instead of 4), it's ~3.12GB used, ~976MB headroom (~24%) — the gap -between shared and dedicated MariaDB narrows a lot at low site counts, -since the shared model's one fixed instance cost is amortized across fewer -sites. `wg-easy`, +Leaves roughly **~1.09GB headroom (~27%)** out of 4GB — back into the ideal +25-30% range, between dropping `actualbudget` (~115MB) and settling on 2 +sites instead of 4 (dedicated-per-site MariaDB's cost scales with site +count, so this was the single biggest lever). With the swapfile now +automatic (`ensure_swapfile`, see above) there's real insurance on top of +that margin, not instead of it. `wg-easy`, `homebox`, and `audiobookshelf` from earlier in this doc aren't included in this specific table — add them back in at ~25MB, ~125MB, and ~200MB respectively if/when they're actually deployed alongside this baseline. From 5f36b14f939e1ab626af9944ceb0a92abbcedf2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:45:26 +0000 Subject: [PATCH 09/11] mattermost: add PikaPods migration helper (DB dump + files import) 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- services/mattermost.sh | 120 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/services/mattermost.sh b/services/mattermost.sh index 390d496..486fe97 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -592,6 +592,126 @@ MD log_warning "WebRTC (voice/video calls) requires HTTPS. Configure Caddy and update SITE_URL." fi + # ── Migration helper (new/fresh installs only — not "update" reruns, + # where an existing instance is already in real use and importing over + # it would be destructive) ──────────────────────────────────────────── + if [ "$MODE" != "update" ]; then + echo "" + local MIGRATING="" + prompt_yn "Migrating from an existing Mattermost instance (e.g. PikaPods)? (y/n):" "n" MIGRATING + if [[ "$MIGRATING" =~ ^[Yy]$ ]]; then + cat > "$DIR/migrate-from-pikapods.sh" << 'MIGRATE_HEAD' +#!/bin/bash +################################################################################ +# migrate-from-pikapods.sh — generated by ubuntu-post-install +# +# Imports a Mattermost database dump + file storage exported from another +# instance (e.g. PikaPods) into THIS freshly-created instance, replacing its +# empty database and populating its file storage. +# +# PikaPods export procedure (Pod Settings): enable SFTP + Database access, +# STOP the pod first (flushes anything still in memory to disk), SFTP the +# pod's files down, then use the Adminer link PikaPods gives you to export +# the database as a plain SQL dump. See docs.pikapods.com/manage/backup. +# +# This script assumes a PLAIN-TEXT SQL dump (what Adminer produces by +# default). If you have a custom-format pg_dump instead, use `pg_restore` +# in place of the `psql < dump` step below. +# +# IMPORTANT: point the files argument at the SUBDIRECTORY that holds +# Mattermost's own file storage inside whatever you downloaded via SFTP +# (commonly named `data`), not the whole SFTP root — PikaPods' exact +# layout wasn't verified against a live pod, so confirm this yourself +# before running. +# +# Usage: +# ./migrate-from-pikapods.sh +################################################################################ + +MIGRATE_HEAD + + cat >> "$DIR/migrate-from-pikapods.sh" << MIGRATE_VARS +PROJECT_DIR="$DIR" +MM_CONTAINER="$MM_CONTAINER" +DB_CONTAINER="$DB_CONTAINER" +DB_NAME="mattermost" +DB_USER="mattermost" +MIGRATE_VARS + + cat >> "$DIR/migrate-from-pikapods.sh" << 'MIGRATE_BODY' +set -uo pipefail +cd "$PROJECT_DIR" || exit 1 + +SQL_DUMP="${1:-}" +FILES_DIR="${2:-}" + +if [ -z "$SQL_DUMP" ] || [ -z "$FILES_DIR" ]; then + echo "Usage: $0 " + exit 1 +fi +[ -f "$SQL_DUMP" ] || { echo "SQL dump not found: $SQL_DUMP"; exit 1; } +[ -d "$FILES_DIR" ] || { echo "Files directory not found: $FILES_DIR"; exit 1; } +[ -f "docker-compose.yml" ] || { echo "Run this from $PROJECT_DIR (docker-compose.yml not found here)."; exit 1; } + +echo "" +echo "┌─────────────────────────────────────────────────────────────────┐" +echo "│ MATTERMOST MIGRATION — THIS REPLACES THE CURRENT DATABASE │" +echo "└─────────────────────────────────────────────────────────────────┘" +echo "" +echo " Target instance: $PROJECT_DIR" +echo " SQL dump: $SQL_DUMP" +echo " Files: $FILES_DIR (copied into ./data)" +echo "" +read -r -p " Type YES to proceed: " CONFIRM +[ "$CONFIRM" = "YES" ] || { echo "Aborted — no changes made."; exit 0; } + +echo "" +echo "Stopping $MM_CONTAINER (keeping $DB_CONTAINER running)..." +docker compose stop mattermost + +echo "Waiting for $DB_CONTAINER to accept connections..." +tries=0 +until docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" >/dev/null 2>&1 || [ "$tries" -ge 30 ]; do + sleep 1; tries=$((tries + 1)) +done + +echo "Dropping and recreating '$DB_NAME' (owned by the existing '$DB_USER' role — .env credentials are untouched)..." +if ! docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" \ + || ! docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"; then + echo "Failed to reset the database — check: docker compose logs db" + exit 1 +fi + +echo "Importing $SQL_DUMP..." +if ! docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$SQL_DUMP" > /tmp/mm-migrate-import.log 2>&1; then + echo "Import reported errors — check /tmp/mm-migrate-import.log before continuing." + echo "(Some warnings, e.g. about extensions already existing, are expected and harmless." + echo " Look for actual failures — missing tables, permission errors — before deciding.)" +fi + +echo "Copying files into ./data..." +mkdir -p ./data +rsync -a "$FILES_DIR"/ ./data/ 2>/dev/null || cp -a "$FILES_DIR"/. ./data/ + +echo "Starting Mattermost..." +docker compose up -d + +echo "" +echo "Done. Verify before treating this as live:" +echo " - Open the site and confirm you can log in as an existing (migrated) user" +echo " - Spot-check a channel with history and a message that has an attached file" +echo " - Check System Console → users/teams counts look right" +echo "" +echo "Import log: /tmp/mm-migrate-import.log" +MIGRATE_BODY + + chmod +x "$DIR/migrate-from-pikapods.sh" + chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/migrate-from-pikapods.sh" + log_success "Migration helper written: $DIR/migrate-from-pikapods.sh" + log_info "Run it once you have both a SQL dump and the files directory from PikaPods." + fi + fi + local START="" prompt_yn "Start Mattermost now? (y/n):" "y" START if [ "$START" = "y" ] || [ "$START" = "Y" ]; then From d5d979ac3146a5e0893e8e1982efd2cdb746cdb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:57:23 +0000 Subject: [PATCH 10/11] Add multi-instance support to audiobookshelf, emby, mealie, traccar 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-) -- 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- services/audiobookshelf.sh | 71 ++++++++++++--- services/emby.sh | 78 +++++++++++++---- services/mealie.sh | 74 ++++++++++++---- services/traccar.sh | 171 +++++++++++++++++++++++++++---------- 4 files changed, 308 insertions(+), 86 deletions(-) diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index 4aa96b8..e7be929 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -186,18 +186,60 @@ register_service audiobookshelf media "Audiobook & podcast server (Audiobookshel install_audiobookshelf() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "audiobookshelf" name/paths/port exactly + # as before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local ABS_DIR="$DOCKER_DIR/audiobookshelf" + local INSTANCE_SUFFIX="" CONTAINER="audiobookshelf" + local WEB_PORT="13378" local DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Audiobookshelf would:" - echo " - Create $ABS_DIR with docker-compose.yml + .env (config/ metadata/ podcasts/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/audiobookshelf(-) with docker-compose.yml + .env" echo " - Mount an audiobooks folder (default $DEFAULT_AUDIOBOOKS) at /audiobooks" - echo " - Expose port 13378" + echo " - Auto-scan for a free host port if this is an additional instance" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$ABS_DIR" ]; then + echo "" + echo " Audiobookshelf is already installed at $ABS_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Audiobookshelf instance alongside it (its own" + echo " server, library, and port — 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, e.g. 'kids'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/audiobookshelf-$_suffix" ]; then + log_warning "audiobookshelf-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + ABS_DIR="$DOCKER_DIR/audiobookshelf-$_suffix" + CONTAINER="audiobookshelf-$_suffix" + DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $ABS_DIR (port $WEB_PORT)" + fi + fi + local AUDIOBOOKS_PATH="" prompt_text "Path to audiobooks folder [$DEFAULT_AUDIOBOOKS]:" "$DEFAULT_AUDIOBOOKS" AUDIOBOOKS_PATH AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH/#\~/$ACTUAL_HOME}"; AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH%/}" @@ -232,13 +274,13 @@ networks: fi cat > docker-compose.yml << ABS_COMPOSE -name: audiobookshelf +name: $CONTAINER services: audiobookshelf: image: ghcr.io/advplyr/audiobookshelf:latest - container_name: audiobookshelf - hostname: audiobookshelf + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - TZ=$TZ_VAL @@ -248,7 +290,7 @@ services: - \${AUDIOBOOKS_PATH}:/audiobooks - \${PODCASTS_PATH:-./podcasts}:/podcasts ports: - - "13378:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} ABS_COMPOSE @@ -260,16 +302,19 @@ ABS_ENV mkdir -p config metadata podcasts chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" - log_success "Audiobookshelf configured at $ABS_DIR" + log_success "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ABS_DIR (port $WEB_PORT)" - configure_caddy_for_service "AudioBookshelf" "audiobookshelf:80" "audiobooks" + configure_caddy_for_service "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "audiobooks${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$ABS_DIR" << MD -# Audiobookshelf +# Audiobookshelf${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted audiobook and podcast server with progress sync across devices. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own library, own +port) — not a shared library with another Audiobookshelf instance.") -- Web UI: http://localhost:13378 +- Web UI: http://localhost:${WEB_PORT} - Audiobooks: \`$AUDIOBOOKS_PATH\` → mounted at /audiobooks - Podcasts: \`podcasts/\` in this folder → /podcasts (change \`PODCASTS_PATH\` in .env) - App data: \`config/\` and \`metadata/\` @@ -288,13 +333,13 @@ pointing at /audiobooks and /podcasts. MD local START_ABS="" - prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS + prompt_yn "Start Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_ABS if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then - docker compose up -d && log_success "Audiobookshelf started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:13378" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/emby.sh b/services/emby.sh index d95c35e..8e50f79 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -197,21 +197,66 @@ register_service emby media "Media server — movies, TV, music (Emby); supports install_emby() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "emby" name/paths/ports exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local EMBY_DIR="$DOCKER_DIR/emby" + local INSTANCE_SUFFIX="" CONTAINER="emby" + local WEB_PORT="8096" HTTPS_PORT="8920" local DEFAULT_MEDIA="$ACTUAL_HOME/media" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Emby would:" + echo " - Offer to add a new, separate instance if one already exists" echo " - Ask whether this is a music-only setup (changes the default folder/guidance only —" echo " which library types you add still happens in Emby's own web setup wizard)" - echo " - Create $EMBY_DIR with docker-compose.yml + .env (config/)" + echo " - Create \$DOCKER_DIR/emby(-) with docker-compose.yml + .env (config/)" echo " - Mount a media folder (default $DEFAULT_MEDIA) at /media" echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER")" - echo " - Expose ports 8096 (web) and 8920 (https)" + echo " - Auto-scan for free host ports if this is an additional instance" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$EMBY_DIR" ]; then + echo "" + echo " Emby is already installed at $EMBY_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Emby instance alongside it (its own server," + echo " library, and ports — full isolation, not another Emby library)" + 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, e.g. 'music'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/emby-$_suffix" ]; then + log_warning "emby-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + EMBY_DIR="$DOCKER_DIR/emby-$_suffix" + CONTAINER="emby-$_suffix" + DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + while ss -tlnH "sport = :${HTTPS_PORT}" 2>/dev/null | grep -q .; do + HTTPS_PORT=$((HTTPS_PORT + 1)) + done + log_info "New instance: $EMBY_DIR (web port $WEB_PORT, https port $HTTPS_PORT)" + fi + fi + local MUSIC_ONLY="" prompt_yn "Set this up as a music-only server (skip movies/TV)? (y/n):" "n" MUSIC_ONLY @@ -257,13 +302,13 @@ networks: fi cat > docker-compose.yml << EMBY_COMPOSE -name: emby +name: $CONTAINER services: emby: image: emby/embyserver:latest - container_name: emby - hostname: emby + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - UID=$UID_VAL @@ -273,8 +318,8 @@ services: - ./config:/config - \${MEDIA_PATH}:/media ports: - - "8096:8096" - - "8920:8920" + - "${WEB_PORT}:8096" + - "${HTTPS_PORT}:8920" # Uncomment for hardware transcoding (Intel/AMD): # devices: # - /dev/dri:/dev/dri @@ -288,16 +333,19 @@ EMBY_ENV mkdir -p config chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" - log_success "Emby configured at $EMBY_DIR" + log_success "Emby${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $EMBY_DIR (port $WEB_PORT)" - configure_caddy_for_service "Emby" "emby:8096" "emby" + configure_caddy_for_service "Emby${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8096" "emby${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$EMBY_DIR" << MD -# Emby +# Emby${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Media server for movies, TV, and music. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own library, own +ports) — not another library within another Emby instance.") -- Web UI: http://localhost:8096 (HTTPS on 8920) +- Web UI: http://localhost:${WEB_PORT} (HTTPS on ${HTTPS_PORT}) - Media folder: \`$MEDIA_PATH\` → mounted at /media - App data: \`config/\` in this folder - Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. @@ -322,7 +370,7 @@ you actually add still happens in Emby's own first-run setup wizard, not this script (Emby has no compose/env flag for "music-only"; it's a web-UI step): -1. Open http://localhost:8096 and complete the setup wizard. +1. Open http://localhost:${WEB_PORT} and complete the setup wizard. 2. When adding a library, choose type **Music**, point it at \`/media\`, and don't add any Movies/TV/other library types. 3. **Per-user library access** (the reason to pick Emby over a Squeezebox @@ -343,13 +391,13 @@ MUSICMD MD local START_EMBY="" - prompt_yn "Start Emby now? (y/n):" "y" START_EMBY + prompt_yn "Start Emby${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_EMBY if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then - docker compose up -d && log_success "Emby started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Emby${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:8096" + echo " Access at: http://localhost:${WEB_PORT}" echo "" } diff --git a/services/mealie.sh b/services/mealie.sh index 0b0c762..a4a6911 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -186,17 +186,58 @@ register_service mealie utilities "Recipe manager & meal planner (Mealie)" 9925 install_mealie() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "mealie" name/paths/port exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local MEALIE_DIR="$DOCKER_DIR/mealie" + local INSTANCE_SUFFIX="" CONTAINER="mealie" + local WEB_PORT="9925" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Mealie would:" - echo " - Create $MEALIE_DIR with docker-compose.yml (data/)" - echo " - Expose port 9925" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/mealie(-) with docker-compose.yml (data/)" + echo " - Auto-scan for a free host port if this is an additional instance" echo " - Default login: changeme@email.com / MyPassword (change immediately)" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$MEALIE_DIR" ]; then + echo "" + echo " Mealie is already installed at $MEALIE_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Mealie instance alongside it (its own" + echo " server, recipes, and port — 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, e.g. 'family'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/mealie-$_suffix" ]; then + log_warning "mealie-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + MEALIE_DIR="$DOCKER_DIR/mealie-$_suffix" + CONTAINER="mealie-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $MEALIE_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$MEALIE_DIR" ensure_docker_dir_ownership "$MEALIE_DIR" cd "$MEALIE_DIR" || return 1 @@ -207,9 +248,9 @@ install_mealie() { # BASE_URL must match the public URL Mealie is served on (used for email links, # OAuth redirects, and the web app manifest). Default to SITE_DOMAIN if set. - local MEALIE_BASE_URL="http://localhost:9925" + local MEALIE_BASE_URL="http://localhost:${WEB_PORT}" if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then - MEALIE_BASE_URL="https://recipes.${SITE_DOMAIN}" + MEALIE_BASE_URL="https://recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}" fi # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): @@ -236,13 +277,13 @@ networks: fi cat > docker-compose.yml << MEALIE_COMPOSE -name: mealie +name: $CONTAINER services: mealie: image: ghcr.io/mealie-recipes/mealie:latest - container_name: mealie - hostname: mealie + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped env_file: .env environment: @@ -255,7 +296,7 @@ services: volumes: - ./data:/app/data ports: - - "9925:9000" + - "${WEB_PORT}:9000" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} MEALIE_COMPOSE @@ -268,17 +309,20 @@ MEALIE_ENV mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" - log_success "Mealie configured at $MEALIE_DIR" + log_success "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $MEALIE_DIR (port $WEB_PORT)" - configure_caddy_for_service "Mealie" "mealie:9000" "recipes" + configure_caddy_for_service "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:9000" "recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$MEALIE_DIR" << MD -# Mealie +# Mealie${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Recipe manager and meal planner — import recipes from any URL, plan meals, and generate shopping lists. Optional AI-powered recipe parsing. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own recipes, own +port) — not shared recipes with another Mealie instance.") -- Web UI: http://localhost:9925 +- Web UI: http://localhost:${WEB_PORT} - Default login: changeme@email.com / MyPassword (change immediately!) - App data: \`data/\` @@ -296,13 +340,13 @@ docker compose pull && docker compose up -d # update MD local START_MEALIE="" - prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE + prompt_yn "Start Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_MEALIE if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then - docker compose up -d && log_success "Mealie started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:9925" + echo " Access at: http://localhost:${WEB_PORT}" echo " Default: changeme@email.com / MyPassword (change immediately!)" echo "" } diff --git a/services/traccar.sh b/services/traccar.sh index 1c08182..337ce65 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -188,21 +188,87 @@ register_service traccar utilities "GPS tracking server — phones, vehicles, as install_traccar() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "traccar" name/paths/ports exactly as + # before (zero behavior change for anyone with a single instance). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. + # + # The device-protocol port range is the one thing that can't just be + # auto-scanned port-by-port (150+ ports, and the first instance already + # carves Asterisk's exact ports out of it) — instead each additional + # instance's whole range shifts by 1000 (6000-6150 for the first extra + # instance, 7000-7150 for the next, ...), determined by how many + # traccar/traccar-* directories already exist. Those shifted ranges never + # land on Asterisk's fixed ports (5038/5060/5061), so no exclusions are + # needed there the way the first instance needs them. local TRACCAR_DIR="$DOCKER_DIR/traccar" + local INSTANCE_SUFFIX="" CONTAINER="traccar" DB_CONTAINER="traccar-db" + local AUTOHEAL_CONTAINER="traccar-autoheal" AUTOHEAL_LABEL="autoheal" + local WEB_PORT="8082" + local PROTO_MIN=5000 PROTO_MAX=5150 if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Traccar would:" - echo " - Create $TRACCAR_DIR with docker-compose.yml + .env" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/traccar(-) with docker-compose.yml + .env" echo " - Deploy a PostgreSQL database container (Traccar no longer ships H2)" echo " - Point Traccar at it via env vars (CONFIG_USE_ENVIRONMENT_VARIABLES) — no secrets in a config file" echo " - Deploy an autoheal container that restarts Traccar if its healthcheck fails" - echo " - Expose port 8082 (web) and 5000-5150 (device protocols; 5038/5060/5061 skipped — Asterisk keeps priority on those)" + echo " - Expose port 8082 (web) and 5000-5150 (device protocols; 5038/5060/5061 skipped — Asterisk keeps" + echo " priority on those); an additional instance's device-protocol range shifts by 1000 instead" echo " - No default login — register the first account at the web UI, it becomes admin" echo " - Offer optional ntfy push notifications (self-hosted anywhere, or ntfy.sh)" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$TRACCAR_DIR" ]; then + echo "" + echo " Traccar is already installed at $TRACCAR_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Traccar instance alongside it (its own" + echo " server, database, and device-protocol port range — 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, e.g. 'fleet-b'):" "" _suffix + _suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$_suffix" ]; then + log_warning "Name can't be empty."; continue + fi + if [ -d "$DOCKER_DIR/traccar-$_suffix" ]; then + log_warning "traccar-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + TRACCAR_DIR="$DOCKER_DIR/traccar-$_suffix" + CONTAINER="traccar-$_suffix" + DB_CONTAINER="traccar-$_suffix-db" + AUTOHEAL_CONTAINER="traccar-$_suffix-autoheal" + AUTOHEAL_LABEL="autoheal-traccar-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + + # Count existing traccar/traccar-* dirs (this new one isn't + # created yet, so the first extra instance counts exactly 1 + # existing dir -> offset 1 -> 6000-6150). + local _existing_count + _existing_count="$(find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'traccar*' -type d 2>/dev/null | wc -l)" + local _offset=$((_existing_count * 1000)) + PROTO_MIN=$((5000 + _offset)) + PROTO_MAX=$((5150 + _offset)) + + log_info "New instance: $TRACCAR_DIR (web port $WEB_PORT, device protocols $PROTO_MIN-$PROTO_MAX)" + fi + fi + mkdir -p "$TRACCAR_DIR" # Non-recursive on purpose — a rerun already has a `db/` full of Postgres's # own data files, owned by whatever uid the postgres container runs as @@ -299,14 +365,48 @@ networks: " fi + # First instance keeps the exact existing Asterisk-exclusion port block + # (5000-5150 with 5038/5060/5061 carved out). An additional instance's + # range is shifted by 1000 per instance (computed above), which never + # lands on Asterisk's fixed ports, so it just publishes the plain range + # with no exclusions needed. + local _PROTO_PORT_BLOCK + if [ -z "$INSTANCE_SUFFIX" ]; then + _PROTO_PORT_BLOCK=" # 5038 (AMI), 5060 (SIP, tcp+udp), and 5061 (SIP TLS, tcp) are skipped: + # they're Asterisk's ports (services/asterisk.sh runs Asterisk with + # network_mode: host, so it binds them directly on the host, not + # through Docker networking). Publishing the full 5000-5150 range here + # would fight Asterisk for those exact host ports on any box running + # both services from this repo. Confirmed live: this is what made + # \"docker network connect caddy_net traccar\" and then a plain + # \`docker compose up -d\` both fail with \"failed to bind host port + # 0.0.0.0:5038/tcp\" and then \"...5060/tcp: address already in use\" on + # a box with Asterisk's PSTN trunk already installed. Checked every + # other network_mode: host service in this repo (caddy, homeassistant, + # kyber-server, lyrion, mattermost, watchyourlan, wolf-pair, wolf) — + # none of them land in 5000-5150, so Asterisk is the only conflict. + - \"5000-5037:5000-5037\" + - \"5039-5059:5039-5059\" + - \"5062-5150:5062-5150\" + - \"5000-5059:5000-5059/udp\" + - \"5061-5150:5061-5150/udp\"" + else + _PROTO_PORT_BLOCK=" # Shifted by 1000 from the default 5000-5150 range so this instance + # doesn't collide with the first (or any other) Traccar instance on + # this box — never lands on Asterisk's fixed ports either, so no + # exclusions are needed here the way the first instance needs them. + - \"${PROTO_MIN}-${PROTO_MAX}:${PROTO_MIN}-${PROTO_MAX}\" + - \"${PROTO_MIN}-${PROTO_MAX}:${PROTO_MIN}-${PROTO_MAX}/udp\"" + fi + cat > docker-compose.yml << TRACCAR_COMPOSE -name: traccar +name: $CONTAINER services: db: image: postgres:15-alpine - container_name: traccar-db - hostname: traccar-db + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER restart: unless-stopped env_file: .env volumes: @@ -319,19 +419,19 @@ ${_CADDY_NET_BLOCK} healthcheck: traccar: image: traccar/traccar:latest - container_name: traccar - hostname: traccar + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped env_file: .env depends_on: db: condition: service_healthy labels: - - "autoheal=true" + - "${AUTOHEAL_LABEL}=true" environment: CONFIG_USE_ENVIRONMENT_VARIABLES: "true" DATABASE_DRIVER: org.postgresql.Driver - DATABASE_URL: jdbc:postgresql://traccar-db:5432/\${POSTGRES_DB}?sslmode=disable + DATABASE_URL: jdbc:postgresql://${DB_CONTAINER}:5432/\${POSTGRES_DB}?sslmode=disable DATABASE_USER: \${POSTGRES_USER} DATABASE_PASSWORD: \${POSTGRES_PASSWORD} healthcheck: @@ -344,32 +444,15 @@ ${_CADDY_NET_BLOCK} healthcheck: - ./logs:/opt/traccar/logs:rw - ./data:/opt/traccar/data:rw ports: - - "8082:8082" - # 5038 (AMI), 5060 (SIP, tcp+udp), and 5061 (SIP TLS, tcp) are skipped: - # they're Asterisk's ports (services/asterisk.sh runs Asterisk with - # network_mode: host, so it binds them directly on the host, not - # through Docker networking). Publishing the full 5000-5150 range here - # would fight Asterisk for those exact host ports on any box running - # both services from this repo. Confirmed live: this is what made - # "docker network connect caddy_net traccar" and then a plain - # `docker compose up -d` both fail with "failed to bind host port - # 0.0.0.0:5038/tcp" and then "...5060/tcp: address already in use" on - # a box with Asterisk's PSTN trunk already installed. Checked every - # other network_mode: host service in this repo (caddy, homeassistant, - # kyber-server, lyrion, mattermost, watchyourlan, wolf-pair, wolf) — - # none of them land in 5000-5150, so Asterisk is the only conflict. - - "5000-5037:5000-5037" - - "5039-5059:5039-5059" - - "5062-5150:5062-5150" - - "5000-5059:5000-5059/udp" - - "5061-5150:5061-5150/udp" + - "${WEB_PORT}:8082" +${_PROTO_PORT_BLOCK} ${_CADDY_NET_BLOCK} autoheal: image: willfarrell/autoheal:latest - container_name: traccar-autoheal + container_name: $AUTOHEAL_CONTAINER restart: unless-stopped environment: - AUTOHEAL_CONTAINER_LABEL: autoheal + AUTOHEAL_CONTAINER_LABEL: ${AUTOHEAL_LABEL} AUTOHEAL_INTERVAL: 60 AUTOHEAL_START_PERIOD: 3600 volumes: @@ -422,9 +505,9 @@ TRACCAR_ENV # db/ is deliberately excluded — see the comment on the earlier chown. chown "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" docker-compose.yml .env chown -R "$ACTUAL_USER:$ACTUAL_USER" logs data - log_success "Traccar configured at $TRACCAR_DIR" + log_success "Traccar${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $TRACCAR_DIR (port $WEB_PORT)" - configure_caddy_for_service "Traccar" "traccar:8082" "traccar" + configure_caddy_for_service "Traccar${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8082" "traccar${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" local _NTFY_README_BLOCK="" if [ -n "$SMS_HTTP_URL" ]; then @@ -445,27 +528,29 @@ gateway. Configured in \`.env\`: \`SMS_HTTP_URL\`, \`SMS_HTTP_TEMPLATE\` fi write_readme "$TRACCAR_DIR" << MD -# Traccar +# Traccar${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} GPS tracking server. Track phones, vehicles, and assets via the Traccar Android/iOS app, OwnTracks, or any of 200+ supported device protocols. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own database, own +device-protocol port range) — not shared tracking data with another +Traccar instance.") -- Web UI: http://localhost:8082 +- Web UI: http://localhost:${WEB_PORT} - No default login — Traccar ships with no built-in account. Open the web UI and register the first user; it's automatically made admin. Self-registration stays open to anyone who reaches this server until you turn it off, so do this right away, then go to Settings → Server → Permissions and uncheck Registration. -- Device protocols: ports 5000-5150 (TCP + UDP; 5038/tcp, 5060/tcp+udp, and - 5061/tcp are skipped — reserved for Asterisk's AMI and SIP if this box also - runs Asterisk from this repo, which gets priority on those ports) +- Device protocols: ports ${PROTO_MIN}-${PROTO_MAX} (TCP + UDP$( [ -z "$INSTANCE_SUFFIX" ] && echo "; 5038/tcp, 5060/tcp+udp, and 5061/tcp are skipped — reserved for Asterisk's AMI and SIP if this box also runs Asterisk from this repo, which gets priority on those ports")) - App data: \`data/\` and \`logs/\` -- Database: PostgreSQL (\`traccar-db\` container, data in \`db/\`) +- Database: PostgreSQL (\`$DB_CONTAINER\` container, data in \`db/\`) - All database settings (name, user, password) live in \`.env\` — Traccar reads them directly via env vars, nothing is duplicated in a config file. Change the password there (then recreate both containers) if you need to rotate it. -- Autoheal: \`traccar-autoheal\` restarts the \`traccar\` container if its healthcheck fails +- Autoheal: \`$AUTOHEAL_CONTAINER\` restarts the \`$CONTAINER\` container if its healthcheck fails (scoped to this instance only via the \`$AUTOHEAL_LABEL\` label — it won't touch any other Traccar instance's container) ${_NTFY_README_BLOCK} ## Manage \`\`\`bash @@ -477,18 +562,18 @@ docker compose pull && docker compose up -d # update \`\`\` ## Mobile apps -- Traccar Client (Android/iOS): set server to \`http://YOUR-IP:8082\` +- Traccar Client (Android/iOS): set server to \`http://YOUR-IP:${WEB_PORT}\` - OwnTracks (Android/iOS): configure HTTP endpoint to Traccar MD local START_TRACCAR="" - prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR + prompt_yn "Start Traccar${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_TRACCAR if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then - docker compose up -d && log_success "Traccar started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Traccar${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:8082" + echo " Access at: http://localhost:${WEB_PORT}" echo " No default login — register the first account now; it becomes admin." echo " Then disable further registration: Settings → Server → Permissions." if [ -n "$SMS_HTTP_URL" ]; then From 3fc20238af7ba3b2334f2df819e4fd741fd67379 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:58:25 +0000 Subject: [PATCH 11/11] 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 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- CLAUDE.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9a6309a..e5f1a27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/*` directories already +exist (`find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -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--`) 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_()` directly as a