From b860a8b17440f4eeb09c094cbfadb3fad9ba4288 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:53:40 +0000 Subject: [PATCH] Add multi-instance support to 13 more services Retrofits the standard multi-instance pattern (documented in CLAUDE.md) onto actualbudget, filebrowser, fmd, homebox, immich, jellyfin, joplin, lyrion, meshcentral, ntfy, rustdesk, unifi, and vaultwarden. First instance of each keeps its original name/paths/ports unchanged; adding a second instance prompts for a short name and auto-scans for free ports. Service-specific handling beyond the base pattern: - joplin, immich, unifi: dedicated Postgres/Mongo container per instance (not shared), matching the backup-isolation reasoning in CLAUDE.md. - meshcentral, unifi: multiple fixed ports scanned/shifted together so they stay paired per instance. - rustdesk: 6-port block shifted by a fixed offset per instance, since the image hardcodes its internal ports with no per-port env override. - jellyfin: DLNA/discovery UDP ports only published for the first instance to avoid a host-wide fixed-port conflict. - lyrion: first instance keeps network_mode: host (required for Chromecast/Squeezebox broadcast discovery); additional instances fall back to bridge networking with auto-scanned ports, trading away zero-config discovery since a second container can't also bind host networking's fixed ports. - magicmirror.sh already had its own working multi-instance pattern (upfront instance count, numbered subdirs) and was left as-is. Verified via bash -n on every changed file, plus scripted functional runs (fake docker/ss) exercising first + second instance installs for every port-scanning shape used here (single, dual-paired, quad-paired, block-offset) and confirming dedicated per-instance DB naming and the lyrion host->bridge compose output. --- services/actualbudget.sh | 68 +++++++++++++++++---- services/filebrowser.sh | 69 +++++++++++++++++---- services/fmd.sh | 72 +++++++++++++++++----- services/homebox.sh | 80 ++++++++++++++++++------ services/immich.sh | 93 ++++++++++++++++++++++------ services/jellyfin.sh | 95 +++++++++++++++++++++++----- services/joplin.sh | 82 ++++++++++++++++++++----- services/lyrion.sh | 129 +++++++++++++++++++++++++++++++++------ services/meshcentral.sh | 81 ++++++++++++++++++------ services/ntfy.sh | 81 ++++++++++++++++++------ services/rustdesk.sh | 110 +++++++++++++++++++++++++-------- services/unifi.sh | 117 +++++++++++++++++++++++++++-------- services/vaultwarden.sh | 70 +++++++++++++++++---- 13 files changed, 923 insertions(+), 224 deletions(-) diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 606ed25..694ecf9 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -186,16 +186,57 @@ register_service actualbudget utilities "Open-source personal finance & budgetin install_actualbudget() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "actualbudget" 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 AB_DIR="$DOCKER_DIR/actualbudget" + local INSTANCE_SUFFIX="" CONTAINER="actualbudget" + local WEB_PORT="5006" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Actual Budget would:" - echo " - Create $AB_DIR with docker-compose.yml (data/)" - echo " - Expose port 5006" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/actualbudget(-) with docker-compose.yml (data/)" + 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 "$AB_DIR" ]; then + echo "" + echo " Actual Budget is already installed at $AB_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Actual Budget instance alongside it (its own" + echo " server, budget file, 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/actualbudget-$_suffix" ]; then + log_warning "actualbudget-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + AB_DIR="$DOCKER_DIR/actualbudget-$_suffix" + CONTAINER="actualbudget-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $AB_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$AB_DIR/data" ensure_docker_dir_ownership "$AB_DIR" cd "$AB_DIR" || return 1 @@ -226,15 +267,15 @@ networks: fi cat > docker-compose.yml << AB_COMPOSE -name: actualbudget +name: $CONTAINER services: actualbudget: image: actualbudget/actual-server:latest - container_name: actualbudget + container_name: $CONTAINER restart: unless-stopped ports: - - "5006:5006" + - "${WEB_PORT}:5006" volumes: - ./data:/data env_file: @@ -248,17 +289,20 @@ CADDY_NET=$SITE_CADDY_NET AB_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" - log_success "Actual Budget configured at $AB_DIR" + log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AB_DIR (port $WEB_PORT)" - configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget" + configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$AB_DIR" << MD -# Actual Budget +# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Open-source personal finance and budgeting tool. Supports bank sync via SimpleFIN (requires a SimpleFIN account at simplefin.org). +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own budget file, +own port) — not shared data with another Actual Budget instance.") -- Web UI: http://localhost:5006 +- Web UI: http://localhost:${WEB_PORT} - App data: \`data/\` ## Manage @@ -276,13 +320,13 @@ docker compose pull && docker compose up -d # update MD local START_AB="" - prompt_yn "Start Actual Budget now? (y/n):" "y" START_AB + prompt_yn "Start Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_AB if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then - docker compose up -d && log_success "Actual Budget started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:5006" + echo " Access at: http://localhost:${WEB_PORT}" echo " Bank sync: simplefin.org (optional, paid)" echo "" } diff --git a/services/filebrowser.sh b/services/filebrowser.sh index 747a54a..ec9c97a 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -184,14 +184,56 @@ install_filebrowser() { require_docker || return 1 log_info "Installing FileBrowser Quantum..." + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "filebrowser" 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 FB_DIR="$DOCKER_DIR/filebrowser" + local INSTANCE_SUFFIX="" CONTAINER="filebrowser" + local WEB_PORT="8085" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FB_DIR" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $FB_DIR(-)" echo "[DRY-RUN] Would write docker-compose.yml and data/config.yaml" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$FB_DIR" ]; then + echo "" + echo " FileBrowser is already installed at $FB_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate FileBrowser instance alongside it (its own" + echo " server, config, 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. 'shared'):" "" _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/filebrowser-$_suffix" ]; then + log_warning "filebrowser-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + FB_DIR="$DOCKER_DIR/filebrowser-$_suffix" + CONTAINER="filebrowser-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $FB_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$FB_DIR/data" ensure_docker_dir_ownership "$FB_DIR" cd "$FB_DIR" || return 1 @@ -223,13 +265,13 @@ networks: fi cat > docker-compose.yml << FB_COMPOSE -name: filebrowser +name: $CONTAINER services: filebrowser: image: gtstef/filebrowser:stable - container_name: filebrowser - hostname: filebrowser + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - TZ=${SITE_TZ:-UTC} @@ -237,7 +279,7 @@ services: - ./data:/home/filebrowser/data - ${FB_PATH}:/files ports: - - "8085:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} FB_COMPOSE @@ -282,18 +324,21 @@ FB_CONFIG fi echo "" - log_success "FileBrowser Quantum configured at $FB_DIR" + log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FB_DIR (port $WEB_PORT)" - configure_caddy_for_service "FileBrowser" "filebrowser:80" "files" + configure_caddy_for_service "FileBrowser${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "files${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$FB_DIR" << MD -# FileBrowser Quantum +# FileBrowser Quantum${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Web-based file manager with multi-source support, office preview, and per-user access control. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own config, own +port) — not shared sources with another FileBrowser instance.") ## Access -- URL: http://localhost:8085 +- URL: http://localhost:${WEB_PORT} - Default login: admin / admin (change immediately!) ## Adding sources (extra directories) @@ -320,14 +365,14 @@ docker compose pull && docker compose down && docker compose up -d # update MD local START_FB="" - prompt_yn "Start FileBrowser Quantum now? (y/n):" "y" START_FB + prompt_yn "Start FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FB if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then docker compose up -d 2>/dev/null \ - && log_success "FileBrowser Quantum started" \ + && log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:8085" + echo " Access at: http://localhost:${WEB_PORT}" echo " Default login: admin / admin (change immediately!)" echo "" } diff --git a/services/fmd.sh b/services/fmd.sh index bc80529..8f9180f 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -196,17 +196,58 @@ register_service fmd utilities "Android device tracking — alternative to Googl install_fmd() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "fmd" 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 FMD_DIR="$DOCKER_DIR/fmd" + local INSTANCE_SUFFIX="" CONTAINER="fmd" + local WEB_PORT="8084" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] FindMyDevice would:" - echo " - Create $FMD_DIR with docker-compose.yml + .env (data/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/fmd(-) with docker-compose.yml + .env (data/)" echo " - Generate a random admin password" - echo " - Expose port 8084" + 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 "$FMD_DIR" ]; then + echo "" + echo " FindMyDevice is already installed at $FMD_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate FindMyDevice instance alongside it (its own" + echo " server, password, 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/fmd-$_suffix" ]; then + log_warning "fmd-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + FMD_DIR="$DOCKER_DIR/fmd-$_suffix" + CONTAINER="fmd-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $FMD_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$FMD_DIR" ensure_docker_dir_ownership "$FMD_DIR" cd "$FMD_DIR" || return 1 @@ -238,20 +279,20 @@ networks: fi cat > docker-compose.yml << FMD_COMPOSE -name: fmd +name: $CONTAINER services: fmd: image: nulide/findmydevice - container_name: fmd - hostname: fmd + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD} volumes: - ./data:/fmd/data ports: - - "8084:8080" + - "${WEB_PORT}:8080" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} FMD_COMPOSE @@ -262,17 +303,20 @@ FMD_ENV mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" - log_success "FindMyDevice configured at $FMD_DIR" + log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FMD_DIR (port $WEB_PORT)" - configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd" + configure_caddy_for_service "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "fmd${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$FMD_DIR" << MD -# FindMyDevice (FMD) +# FindMyDevice (FMD)${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted Android device tracking — locate, lock, or wipe your device from the web UI. Alternative to Google's Find My Device. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own password, own +port) — not shared devices with another FindMyDevice instance.") -- Web UI: http://localhost:8084 +- Web UI: http://localhost:${WEB_PORT} - Admin password: stored in \`.env\` (\`FMD_ADMIN_PASSWORD\`) - App data: \`data/\` @@ -287,19 +331,19 @@ docker compose pull && docker compose up -d # update ## Mobile app Install **FindMyDevice** from **F-Droid** (not the Play Store version): -1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:8084\` +1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:${WEB_PORT}\` 2. Enter your admin password from \`.env\` 3. Grant location and accessibility permissions MD local START_FMD="" - prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD + prompt_yn "Start FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FMD if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then - docker compose up -d && log_success "FindMyDevice started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:8084" + echo " Access at: http://localhost:${WEB_PORT}" echo " Password: $FMD_PASS (saved in .env)" echo " Mobile app: FindMyDevice on F-Droid" echo "" diff --git a/services/homebox.sh b/services/homebox.sh index 9795a43..5c94ba2 100644 --- a/services/homebox.sh +++ b/services/homebox.sh @@ -185,14 +185,56 @@ install_homebox() { require_docker || return 1 log_info "Installing Homebox..." + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "homebox" 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 HB_DIR="$DOCKER_DIR/homebox" + local INSTANCE_SUFFIX="" CONTAINER="homebox" + local WEB_PORT="7745" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $HB_DIR" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $HB_DIR(-)" echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$HB_DIR" ]; then + echo "" + echo " Homebox is already installed at $HB_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Homebox instance alongside it (its own" + echo " server, inventory, 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. 'garage'):" "" _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/homebox-$_suffix" ]; then + log_warning "homebox-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + HB_DIR="$DOCKER_DIR/homebox-$_suffix" + CONTAINER="homebox-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $HB_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$HB_DIR/data" ensure_docker_dir_ownership "$HB_DIR" cd "$HB_DIR" || return 1 @@ -221,13 +263,13 @@ networks: fi cat > docker-compose.yml << HB_COMPOSE -name: homebox +name: $CONTAINER services: homebox: image: ghcr.io/sysadminsmedia/homebox:latest - container_name: homebox - hostname: homebox + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - HBOX_LOG_LEVEL=info @@ -235,7 +277,7 @@ services: volumes: - ./data:/data ports: - - "7745:7745" + - "${WEB_PORT}:7745" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} HB_COMPOSE @@ -246,18 +288,21 @@ HB_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR" echo "" - log_success "Homebox configured at $HB_DIR" + log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $HB_DIR (port $WEB_PORT)" - configure_caddy_for_service "Homebox" "homebox:7745" "homebox" + configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" - write_readme "$HB_DIR" << 'MD' -# Homebox + write_readme "$HB_DIR" << MD +# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Home inventory and asset management. Track items, locations, labels, warranties, and attachments across your household. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own inventory, own +port) — not shared items with another Homebox instance.") ## Access -- URL: http://localhost:7745 +- URL: http://localhost:${WEB_PORT} - Register your account on first visit — the first user becomes the admin. ## Data @@ -265,27 +310,28 @@ warranties, and attachments across your household. ## Configuration Key environment variables (edit docker-compose.yml to change): -- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error) -- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10) +- \`HBOX_LOG_LEVEL\` — log verbosity (info, debug, warn, error) +- \`HBOX_WEB_MAX_UPLOAD_SIZE\` — max attachment upload size in MB (default: 10) ## Manage -```bash +\`\`\`bash +cd $HB_DIR docker compose up -d docker compose down docker compose logs -f docker compose pull && docker compose down && docker compose up -d -``` +\`\`\` MD local START_HB="" - prompt_yn "Start Homebox now? (y/n):" "y" START_HB + prompt_yn "Start Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_HB if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then docker compose up -d 2>/dev/null \ - && log_success "Homebox started" \ + && log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:7745" + echo " Access at: http://localhost:${WEB_PORT}" echo " Register your account on first visit." echo "" } diff --git a/services/immich.sh b/services/immich.sh index 63441d8..7eb1bca 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -189,23 +189,75 @@ register_service immich media "Self-hosted photo & video backup — like Google install_immich() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "immich" name/paths/port and + # immich_server/immich_machine_learning/immich_redis/immich_postgres + # container names 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. + # Each instance gets its own dedicated Postgres (already the case — one + # per compose project) and its own model-cache volume (scoped by the + # per-instance Compose project name), so instances are fully isolated for + # backup/restore too, per CLAUDE.md's "Multi-instance services" section. local IMMICH_DIR="$DOCKER_DIR/immich" + local INSTANCE_SUFFIX="" PROJECT="immich" + local C_SERVER="immich_server" C_ML="immich_machine_learning" C_REDIS="immich_redis" C_DB="immich_postgres" + local WEB_PORT="2283" local DEFAULT_PHOTOS="$ACTUAL_HOME/photos" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Immich would:" - echo " - Create $IMMICH_DIR with docker-compose.yml + .env" - echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/immich(-) with docker-compose.yml + .env" + echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres (dedicated per instance)" 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 " - Expose port 2283, auto-scanned for additional instances" echo " - Offer a Caddy reverse proxy and to start the stack" return 0 fi + if [ -d "$IMMICH_DIR" ]; then + echo "" + echo " Immich is already installed at $IMMICH_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Immich instance alongside it (its own" + echo " server, database, 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/immich-$_suffix" ]; then + log_warning "immich-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + IMMICH_DIR="$DOCKER_DIR/immich-$_suffix" + PROJECT="immich-$_suffix" + C_SERVER="immich_server_$_suffix" + C_ML="immich_ml_$_suffix" + C_REDIS="immich_redis_$_suffix" + C_DB="immich_postgres_$_suffix" + DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $IMMICH_DIR (port $WEB_PORT)" + fi + fi + # ── Photo library setup ───────────────────────────────────────────────── echo "" echo " PHOTO LIBRARY SETUP" @@ -384,18 +436,18 @@ networks: " cat > docker-compose.yml << IMMICH_COMPOSE -name: immich +name: $PROJECT services: immich-server: - container_name: immich_server + container_name: $C_SERVER image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release} volumes: ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro env_file: - .env ports: - - 2283:2283 + - ${WEB_PORT}:2283 depends_on: - redis - database @@ -404,7 +456,7 @@ ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localt disable: false ${_CADDY_NET_BLOCK} immich-machine-learning: - container_name: immich_machine_learning + container_name: $C_ML image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release} volumes: - model-cache:/cache @@ -415,14 +467,14 @@ ${_CADDY_NET_BLOCK} disable: false redis: - container_name: immich_redis + container_name: $C_REDIS image: docker.io/valkey/valkey:9-bookworm healthcheck: test: valkey-cli ping || exit 1 restart: always database: - container_name: immich_postgres + container_name: $C_DB image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 environment: POSTGRES_PASSWORD: \${DB_PASSWORD} @@ -541,7 +593,7 @@ IMMICH_ENV IMPORT_HEAD cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS -IMMICH_URL="http://localhost:2283" +IMMICH_URL="http://localhost:${WEB_PORT}" SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" IMMICH_DIR="$IMMICH_DIR" IMPORT_VARS @@ -791,17 +843,20 @@ IMPORT_BODY log_success "Import helper written: $IMMICH_DIR/import-photos.sh" fi - log_success "Immich configured at $IMMICH_DIR" + log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $IMMICH_DIR (port $WEB_PORT)" - configure_caddy_for_service "Immich" "immich-server:2283" "immich" + configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$IMMICH_DIR" << MD -# Immich +# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted photo and video backup — like Google Photos but private. Mobile apps (iOS/Android) auto-upload in the background. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own dedicated +database, own port) — not shared photos with another Immich instance.") -- Web UI: http://localhost:2283 +- Web UI: http://localhost:${WEB_PORT} - 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/credentials in \`.env\`, then \`docker compose up -d\` to apply. @@ -836,8 +891,8 @@ docker compose pull && docker compose up -d # update \`\`\` ## First launch -1. Open http://localhost:2283 and create your admin account. -2. Install the Immich mobile app and point it at \`http://:2283\`. +1. Open http://localhost:${WEB_PORT} and create your admin account. +2. Install the Immich mobile app and point it at \`http://:${WEB_PORT}\`. 3. (External library mode) Go to Admin → External Libraries → Create Library, set import path to \`/usr/src/app/external\`, and click Scan. @@ -855,13 +910,13 @@ docker compose pull && docker compose up -d # update MD local START_IMMICH="" - prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH + prompt_yn "Start Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_IMMICH if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then - docker compose up -d && log_success "Immich started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:2283" + echo " Access at: http://localhost:${WEB_PORT}" echo " First launch: create your admin account in the web UI." echo "" } diff --git a/services/jellyfin.sh b/services/jellyfin.sh index ad7086d..b1f176f 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -188,19 +188,67 @@ register_service jellyfin media "Free media server — movies, TV, music (Jellyf install_jellyfin() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "jellyfin" 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 (and services/emby.sh, + # the same idea for a sibling media server). local JELLYFIN_DIR="$DOCKER_DIR/jellyfin" + local INSTANCE_SUFFIX="" CONTAINER="jellyfin" + local WEB_PORT="8096" local DEFAULT_MEDIA="$ACTUAL_HOME/media" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Jellyfin would:" - echo " - Create $JELLYFIN_DIR with docker-compose.yml + .env (config/ cache/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/jellyfin(-) with docker-compose.yml + .env (config/ cache/)" echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media" echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists" - echo " - Expose port 8096 (+ DLNA 1900/udp, discovery 7359/udp)" + echo " - Expose port 8096, auto-scanned for additional instances" + echo " - First instance only: DLNA 1900/udp + discovery 7359/udp (host-wide;" + echo " additional instances skip these to avoid a fixed-port conflict)" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$JELLYFIN_DIR" ]; then + echo "" + echo " Jellyfin is already installed at $JELLYFIN_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Jellyfin 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/jellyfin-$_suffix" ]; then + log_warning "jellyfin-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix" + CONTAINER="jellyfin-$_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 + log_info "New instance: $JELLYFIN_DIR (port $WEB_PORT)" + log_warning "DLNA/discovery (1900/udp, 7359/udp) are fixed, host-wide ports already" + log_warning "claimed by the first instance — this instance skips them (web UI and" + log_warning "app-based streaming are unaffected; DLNA auto-discovery is not)." + fi + fi + local MEDIA_PATH="" prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" @@ -248,14 +296,25 @@ networks: " fi + # DLNA (1900/udp) and discovery (7359/udp) are fixed, host-wide UDP ports — + # only the first instance publishes them to avoid a bind conflict with an + # existing instance. Additional instances still work for the web UI and + # every app-based client; only DLNA/network auto-discovery is instance-1-only. + local _DISCOVERY_PORTS="" + if [ -z "$INSTANCE_SUFFIX" ]; then + _DISCOVERY_PORTS=' - "1900:1900/udp" + - "7359:7359/udp" +' + fi + cat > docker-compose.yml << JELLYFIN_COMPOSE -name: jellyfin +name: $CONTAINER services: jellyfin: image: jellyfin/jellyfin:latest - container_name: jellyfin - hostname: jellyfin + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - TZ=$TZ_VAL @@ -265,10 +324,8 @@ $HWACCEL_BLOCK - ./cache:/cache - \${MEDIA_PATH}:/media:ro ports: - - "8096:8096" - - "1900:1900/udp" - - "7359:7359/udp" -${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} + - "${WEB_PORT}:8096" +${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} JELLYFIN_COMPOSE cat > .env << JELLYFIN_ENV @@ -278,16 +335,22 @@ JELLYFIN_ENV mkdir -p config cache chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" - log_success "Jellyfin configured at $JELLYFIN_DIR" + log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JELLYFIN_DIR (port $WEB_PORT)" - configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin" + configure_caddy_for_service "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8096" "jellyfin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$JELLYFIN_DIR" << MD -# Jellyfin +# Jellyfin${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Free media server (movies, TV, music) — a no-paywall alternative to Emby. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own library, own +port) — not a shared library with another Jellyfin instance. DLNA and +network auto-discovery are only published for the first instance (fixed, +host-wide ports); this instance still works for the web UI and every +app-based client.") -- Web UI: http://localhost:8096 +- Web UI: http://localhost:${WEB_PORT} - Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media - App data: \`config/\` and \`cache/\` in this folder - Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. @@ -309,13 +372,13 @@ docker compose pull && docker compose up -d # update MD local START_JF="" - prompt_yn "Start Jellyfin now? (y/n):" "y" START_JF + prompt_yn "Start Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JF if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then - docker compose up -d && log_success "Jellyfin started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Jellyfin${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/joplin.sh b/services/joplin.sh index b161743..7d0a91c 100644 --- a/services/joplin.sh +++ b/services/joplin.sh @@ -185,14 +185,61 @@ install_joplin() { require_docker || return 1 log_info "Installing Joplin Server..." + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "joplin"/"joplin-db" names/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. + # Each instance gets its own dedicated Postgres container (not shared) — + # see CLAUDE.md's "Multi-instance services" section for why: Kopia's + # generic backup stops the container to snapshot it, so a shared DB would + # back up/restore every instance's notes as one unit instead of per-instance. local JOPLIN_DIR="$DOCKER_DIR/joplin" + local INSTANCE_SUFFIX="" CONTAINER="joplin" DB_CONTAINER="joplin-db" + local WEB_PORT="22300" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $JOPLIN_DIR" - echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create \$DOCKER_DIR/joplin(-)" + echo "[DRY-RUN] Would write docker-compose.yml and .env (dedicated Postgres per instance)" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$JOPLIN_DIR" ]; then + echo "" + echo " Joplin Server is already installed at $JOPLIN_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Joplin Server instance alongside it (its own" + echo " server, database, 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. 'work'):" "" _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/joplin-$_suffix" ]; then + log_warning "joplin-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix" + CONTAINER="joplin-$_suffix" + DB_CONTAINER="joplin-db-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $JOPLIN_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$JOPLIN_DIR" ensure_docker_dir_ownership "$JOPLIN_DIR" cd "$JOPLIN_DIR" || return 1 @@ -203,7 +250,7 @@ install_joplin() { local DB_PASS="" [ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)" [ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)" - local BASE_URL="https://joplin.${SITE_DOMAIN}" + local BASE_URL="https://joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}" # 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, @@ -229,24 +276,24 @@ networks: fi cat > docker-compose.yml << JOPLIN_COMPOSE -name: joplin +name: $CONTAINER services: joplin: image: joplin/server:latest - container_name: joplin - hostname: joplin + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped depends_on: - joplin-db env_file: .env ports: - - "22300:22300" + - "${WEB_PORT}:22300" ${_CADDY_NET_BLOCK} joplin-db: image: postgres:15-alpine - container_name: joplin-db - hostname: joplin-db + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER restart: unless-stopped env_file: .env volumes: @@ -278,19 +325,22 @@ JOPLIN_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR" echo "" - log_success "Joplin Server configured at $JOPLIN_DIR" + log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JOPLIN_DIR (port $WEB_PORT)" log_info "APP_BASE_URL set to: $BASE_URL" log_warning "APP_BASE_URL in .env must match the public URL used by Joplin clients." - configure_caddy_for_service "Joplin" "joplin:22300" "joplin" + configure_caddy_for_service "Joplin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:22300" "joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$JOPLIN_DIR" << MD -# Joplin Server +# Joplin Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted sync server for the Joplin note-taking app. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own dedicated +database, own port) — not shared notes with another Joplin instance.") ## Access -- URL: http://localhost:22300 +- URL: http://localhost:${WEB_PORT} - Default admin: admin@localhost / admin (change immediately after first login!) ## Important @@ -319,14 +369,14 @@ docker compose pull && docker compose down && docker compose up -d # update MD local START_JOPLIN="" - prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN + prompt_yn "Start Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JOPLIN if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then docker compose up -d 2>/dev/null \ - && log_success "Joplin Server started" \ + && log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi - echo " Access at: http://localhost:22300" + echo " Access at: http://localhost:${WEB_PORT}" echo " Default login: admin@localhost / admin (change immediately!)" echo "" } diff --git a/services/lyrion.sh b/services/lyrion.sh index 81b349a..33dcb86 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -196,19 +196,85 @@ register_service lyrion media "Music streaming server — Squeezebox, Chromecast install_lyrion() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "lyrion" name/paths/ports and + # network_mode: host exactly as before (zero behavior change for anyone + # with a single instance) — host networking is what makes Chromecast and + # Squeezebox UDP broadcast/multicast discovery work without manual setup. + # + # A second instance CANNOT also use network_mode: host — both would bind + # the same fixed host ports (9000/9090/3483) and collide outright, and + # Docker only allows one container on host networking to own a given port. + # So additional instances switch to bridge networking with auto-scanned, + # per-instance ports instead. The tradeoff: bridge mode means this + # instance loses the zero-config broadcast/multicast auto-discovery that + # host networking provides — Chromecasts and Squeezebox hardware won't + # find it automatically. Players still work, just not auto-discovered: + # point the Squeezer app / Squeezebox firmware at this server's address + # and port manually instead of relying on discovery. local LYRION_DIR="$DOCKER_DIR/lyrion" + local INSTANCE_SUFFIX="" CONTAINER="lyrion" + local USE_HOST_NETWORK=true + local WEB_PORT="9000" CLI_PORT="9090" PLAYER_PORT="3483" local DEFAULT_MUSIC="$ACTUAL_HOME/music" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Lyrion Music Server would:" - echo " - Create $LYRION_DIR with docker-compose.yml + .env (config/ playlists/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/lyrion(-) with docker-compose.yml + .env (config/ playlists/)" echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music" - echo " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)" - echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)" + echo " - First instance: network_mode: host (Chromecast/Squeezebox UDP discovery)" + echo " ports 9000 (web), 9090 (CLI), 3483 (players)" + echo " - Additional instances: bridge networking with auto-scanned ports —" + echo " loses zero-config discovery; players need the server address entered manually" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$LYRION_DIR" ]; then + echo "" + echo " Lyrion is already installed at $LYRION_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Lyrion instance alongside it (its own" + echo " server, library, and ports — 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/lyrion-$_suffix" ]; then + log_warning "lyrion-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + LYRION_DIR="$DOCKER_DIR/lyrion-$_suffix" + CONTAINER="lyrion-$_suffix" + DEFAULT_MUSIC="$ACTUAL_HOME/music-$_suffix" + USE_HOST_NETWORK=false + + log_warning "Additional Lyrion instances use bridge networking (not host) so they" + log_warning "don't collide with the first instance's fixed ports. This instance" + log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address" + log_warning "manually in the Squeezer app / Squeezebox firmware instead." + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${CLI_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${PLAYER_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + CLI_PORT=$((CLI_PORT + 1)) + PLAYER_PORT=$((PLAYER_PORT + 1)) + done + log_info "New instance: $LYRION_DIR (web $WEB_PORT, CLI $CLI_PORT, player $PLAYER_PORT)" + fi + fi + local MUSIC_PATH="" prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}" @@ -221,17 +287,28 @@ install_lyrion() { TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + local _NETWORK_BLOCK=" network_mode: host +" + local _PORTS_BLOCK="" + if [ "$USE_HOST_NETWORK" != true ]; then + _NETWORK_BLOCK="" + _PORTS_BLOCK=" ports: + - \"${WEB_PORT}:9000\" + - \"${CLI_PORT}:9090\" + - \"${PLAYER_PORT}:3483\" +" + fi + cat > docker-compose.yml << LYRION_COMPOSE -name: lyrion +name: $CONTAINER services: lyrion: image: lmscommunity/lyrionmusicserver:stable - container_name: lyrion - hostname: lyrion + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped - network_mode: host - environment: +${_NETWORK_BLOCK}${_PORTS_BLOCK} environment: - HTTP_PORT=9000 - PUID=$UID_VAL - PGID=$GID_VAL @@ -251,19 +328,26 @@ LYRION_ENV mkdir -p config playlists chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR" - log_success "Lyrion Music Server configured at $LYRION_DIR" + log_success "Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $LYRION_DIR (port $WEB_PORT)" - configure_caddy_for_service "Lyrion" "9000" "lyrion" + configure_caddy_for_service "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${WEB_PORT}" "lyrion${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$LYRION_DIR" << MD -# Lyrion Music Server +# Lyrion Music Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast. Formerly known as Logitech Media Server (LMS). +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own library, own +ports) — not a shared library with another Lyrion instance. It runs on +**bridge networking**, not host networking, so it does NOT get zero-config +Chromecast/Squeezebox discovery — enter this server's address and port +manually in the Squeezer app or Squeezebox firmware instead of relying on +auto-discovery.") -- Web UI: http://localhost:9000 -- Player port: 3483 (Squeezeboxes / apps) -- CLI port: 9090 +- Web UI: http://localhost:${WEB_PORT} +- Player port: ${PLAYER_PORT} (Squeezeboxes / apps) +- CLI port: ${CLI_PORT} - Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music - App data: \`config/\` and \`playlists/\` @@ -277,21 +361,26 @@ docker compose pull && docker compose up -d # update \`\`\` ## Notes -- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices - works without manual port mapping. +$( [ "$USE_HOST_NETWORK" = true ] && echo "- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices + works without manual port mapping." || echo "- Uses bridge networking (auto-scanned ports) since the first instance already + owns the fixed host-networking ports. Discovery is manual for this instance." ) - Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`. - Add music libraries in the web UI under Settings → Music Library. MD local START_LMS="" - prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS + prompt_yn "Start Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_LMS if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then - docker compose up -d && log_success "Lyrion started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: http://localhost:9000" - echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" + echo " Access at: http://localhost:${WEB_PORT}" + if [ "$USE_HOST_NETWORK" = true ]; then + echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" + else + echo " Note: uses bridge networking — no auto-discovery, configure players manually" + fi echo "" } diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 0eb8a1c..4c5691d 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -196,17 +196,61 @@ register_service meshcentral utilities "Self-hosted remote device management ser install_meshcentral() { require_docker || return 1 + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "meshcentral" 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. Two ports (web + agent) + # move together so they stay easy to reason about instance-to-instance. local MC_DIR="$DOCKER_DIR/meshcentral" + local INSTANCE_SUFFIX="" CONTAINER="meshcentral" + local WEB_PORT="4430" AGENT_PORT="4433" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] MeshCentral would:" - echo " - Create $MC_DIR with docker-compose.yml + .env (data/ files/ backups/)" + echo " - Offer to add a new, separate instance if one already exists" + echo " - Create \$DOCKER_DIR/meshcentral(-) with docker-compose.yml + .env (data/ files/ backups/)" echo " - Prompt for hostname (domain or IP for agent connections)" - echo " - Expose port 4430 (HTTPS web) and 4433 (agent)" + echo " - Expose port 4430 (HTTPS web) and 4433 (agent), auto-scanned for additional instances" echo " - Offer a Caddy reverse proxy and to start the container" return 0 fi + if [ -d "$MC_DIR" ]; then + echo "" + echo " MeshCentral is already installed at $MC_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate MeshCentral instance alongside it (its own" + echo " server, devices, and ports — 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. 'clients'):" "" _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/meshcentral-$_suffix" ]; then + log_warning "meshcentral-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + MC_DIR="$DOCKER_DIR/meshcentral-$_suffix" + CONTAINER="meshcentral-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${AGENT_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + AGENT_PORT=$((AGENT_PORT + 1)) + done + log_info "New instance: $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)" + fi + fi + local MC_HOSTNAME="" prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME MC_HOSTNAME="${MC_HOSTNAME:-localhost}" @@ -239,13 +283,13 @@ networks: fi cat > docker-compose.yml << MC_COMPOSE -name: meshcentral +name: $CONTAINER services: meshcentral: image: ghcr.io/ylianst/meshcentral:latest - container_name: meshcentral - hostname: meshcentral + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped environment: - NODE_ENV=production @@ -260,8 +304,8 @@ services: - ./files:/opt/meshcentral/meshcentral-files - ./backups:/opt/meshcentral/meshcentral-backups ports: - - "4430:443" - - "4433:4433" + - "${WEB_PORT}:443" + - "${AGENT_PORT}:4433" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} MC_COMPOSE @@ -274,18 +318,21 @@ MC_ENV mkdir -p data files backups chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" - log_success "MeshCentral configured at $MC_DIR" + log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)" - configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh" + configure_caddy_for_service "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:443" "mesh${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$MC_DIR" << MD -# MeshCentral +# MeshCentral${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted remote device management — remotely access, manage, and monitor all your computers from a single web interface. Install agents on each device. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own devices, own +ports) — not shared devices with another MeshCentral instance.") -- Web UI: https://localhost:4430 (self-signed cert on first launch) -- Agent listener: port 4433 (devices connect here — forward this port if remote) +- Web UI: https://localhost:${WEB_PORT} (self-signed cert on first launch) +- Agent listener: port ${AGENT_PORT} (devices connect here — forward this port if remote) - Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes) - App data: \`data/\`, \`files/\`, \`backups/\` @@ -299,14 +346,14 @@ docker compose pull && docker compose up -d # update \`\`\` ## First launch -1. Open https://localhost:4430 (accept the self-signed cert warning) +1. Open https://localhost:${WEB_PORT} (accept the self-signed cert warning) 2. Create your admin account 3. Go to "My Devices" → "+ Add Device" → download the agent for each OS 4. Install the agent on every computer you want to manage ## Remote access For devices outside your LAN to connect: -- Forward **TCP port 4433** on your router to this server +- Forward **TCP port ${AGENT_PORT}** on your router to this server - Set \`MC_HOSTNAME\` in \`.env\` to your public domain/IP, then restart ## Docs @@ -314,13 +361,13 @@ https://meshcentral.com/docs/ MD local START_MC="" - prompt_yn "Start MeshCentral now? (y/n):" "y" START_MC + prompt_yn "Start MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_MC if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then - docker compose up -d && log_success "MeshCentral started" || log_warning "Failed to start — check: docker compose logs" + docker compose up -d && log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Access at: https://localhost:4430 (accept self-signed cert)" + echo " Access at: https://localhost:${WEB_PORT} (accept self-signed cert)" echo " First visit: create your admin account" echo "" } diff --git a/services/ntfy.sh b/services/ntfy.sh index 5b92e3d..2d2708e 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -192,13 +192,57 @@ register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090 install_ntfy() { require_docker || return 1 log_info "Installing ntfy..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "ntfy" name/paths/port exactly as before + # (zero behavior change for anyone with a single instance — including + # crowdsec.sh/pstn-trunk.sh, which curl the default port directly). Only + # asking to add a second one introduces suffixed naming — same pattern as + # services/mattermost.sh and services/wordpress.sh. local NTFY_DIR="$DOCKER_DIR/ntfy" + local INSTANCE_SUFFIX="" CONTAINER="ntfy" + local WEB_PORT="8090" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $NTFY_DIR" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $NTFY_DIR(-)" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" return 0 fi + if [ -d "$NTFY_DIR" ]; then + echo "" + echo " ntfy is already installed at $NTFY_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate ntfy instance alongside it (its own" + echo " server, topics, 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. 'alerts'):" "" _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/ntfy-$_suffix" ]; then + log_warning "ntfy-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix" + CONTAINER="ntfy-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $NTFY_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$NTFY_DIR" ensure_docker_dir_ownership "$NTFY_DIR" cd "$NTFY_DIR" || return 1 @@ -227,13 +271,13 @@ networks: fi cat > docker-compose.yml << NTFY_COMPOSE -name: ntfy +name: $CONTAINER services: ntfy: image: binwiederhier/ntfy:latest - container_name: ntfy - hostname: ntfy + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped command: serve environment: @@ -242,7 +286,7 @@ services: - ./cache:/var/cache/ntfy - ./config:/etc/ntfy ports: - - "8090:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} NTFY_COMPOSE @@ -258,7 +302,7 @@ NTFY_ENV if [ ! -f config/server.yml ]; then local NTFY_BASE_URL="" if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then - NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}" + NTFY_BASE_URL="https://ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}" fi cat > config/server.yml << NTFY_CFG # ntfy server configuration — https://docs.ntfy.sh/config/ @@ -291,22 +335,25 @@ NTFY_CFG chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" echo "" - log_success "ntfy configured at $NTFY_DIR" + log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $NTFY_DIR (port $WEB_PORT)" - configure_caddy_for_service "ntfy" "ntfy:80" "ntfy" + configure_caddy_for_service "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$NTFY_DIR" << MD -# ntfy +# ntfy${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Self-hosted push notification server. Send notifications from scripts to your phone or browser. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own topics, own +port) — not shared topics with another ntfy instance.") ## Access -- URL: http://localhost:8090 +- URL: http://localhost:${WEB_PORT} ## Usage -- Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\` -- Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic +- Send a notification: \`curl -d "Hello!" localhost:${WEB_PORT}/mytopic\` +- Subscribe on phone: ntfy app -> Add subscription -> localhost:${WEB_PORT}/mytopic ## Access model \`auth-default-access: read-write\` — anyone who knows a topic name can read @@ -335,15 +382,15 @@ docker compose logs -f # logs MD local START_NTFY="" - prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY + prompt_yn "Start ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_NTFY if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then - docker compose up -d 2>/dev/null && log_success "ntfy started" || log_warning "Failed to start" + docker compose up -d 2>/dev/null && log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start" fi - echo " Access at: http://localhost:8090" + echo " Access at: http://localhost:${WEB_PORT}" echo "" - echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" - echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" + echo " Send notification: curl -d \"Hello!\" localhost:${WEB_PORT}/mytopic" + echo " Subscribe on phone: ntfy app → Add subscription → localhost:${WEB_PORT}/mytopic" echo "" } diff --git a/services/rustdesk.sh b/services/rustdesk.sh index c2060cb..ed7d09d 100644 --- a/services/rustdesk.sh +++ b/services/rustdesk.sh @@ -100,21 +100,76 @@ register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk) install_rustdesk() { require_docker || return 1 log_info "Installing RustDesk server..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "rustdesk" 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 6 ports + # (21115-21119 TCP + 21116 UDP) are fixed *inside* the container — the + # image doesn't expose an env var to change them — so an additional + # instance shifts the whole host-side block by a fixed offset instead of + # scanning port-by-port, the same approach services/traccar.sh uses for + # its device-protocol range. local RD_DIR="$DOCKER_DIR/rustdesk" + local INSTANCE_SUFFIX="" CONTAINER="rustdesk" + local PORT_OFFSET=0 if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $RD_DIR (rustdesk_data/)" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create \$DOCKER_DIR/rustdesk(-) (rustdesk_data/)" echo "[DRY-RUN] Would deploy rustdesk/rustdesk-server-s6:latest" - echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP" + echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP — whole block shifted for additional instances" echo "[DRY-RUN] Would prompt for server FQDN/IP (RELAY env var)" return 0 fi + if [ -d "$RD_DIR" ]; then + echo "" + echo " RustDesk is already installed at $RD_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate RustDesk relay instance alongside it (its own" + echo " server and ports — 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. 'work'):" "" _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/rustdesk-$_suffix" ]; then + log_warning "rustdesk-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + RD_DIR="$DOCKER_DIR/rustdesk-$_suffix" + CONTAINER="rustdesk-$_suffix" + + PORT_OFFSET=10 + while ss -tlnH "sport = :$((21115 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -ulnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21117 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21118 + PORT_OFFSET))" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :$((21119 + PORT_OFFSET))" 2>/dev/null | grep -q .; do + PORT_OFFSET=$((PORT_OFFSET + 10)) + done + log_info "New instance: $RD_DIR (ports $((21115 + PORT_OFFSET))-$((21119 + PORT_OFFSET)))" + fi + fi + mkdir -p "$RD_DIR/rustdesk_data" ensure_docker_dir_ownership "$RD_DIR" cd "$RD_DIR" || return 1 local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + local P_NAT=$((21115 + PORT_OFFSET)) P_ID=$((21116 + PORT_OFFSET)) \ + P_RELAY=$((21117 + PORT_OFFSET)) P_WS=$((21118 + PORT_OFFSET)) P_WSS=$((21119 + PORT_OFFSET)) echo "" echo " RustDesk needs to know its own public hostname or IP." @@ -134,23 +189,23 @@ install_rustdesk() { prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc [ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0" - cat > docker-compose.yml << 'RD_COMPOSE' -name: rustdesk + cat > docker-compose.yml << RD_COMPOSE +name: $CONTAINER services: rustdesk: image: rustdesk/rustdesk-server-s6:latest - container_name: rustdesk - hostname: rustdesk + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped env_file: .env ports: - - "21115:21115" - - "21116:21116" - - "21116:21116/udp" - - "21117:21117" - - "21118:21118" - - "21119:21119" + - "${P_NAT}:21115" + - "${P_ID}:21116" + - "${P_ID}:21116/udp" + - "${P_RELAY}:21117" + - "${P_WS}:21118" + - "${P_WSS}:21119" volumes: - ./rustdesk_data:/data RD_COMPOSE @@ -161,8 +216,8 @@ TZ=$TZ_VAL # ── RustDesk server ─────────────────────────────────────────────────────────── # RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR). -# Include the port if it's non-standard: hostname:21117 -RELAY=$RELAY_HOST:21117 +# Include the port if it's non-standard: hostname:$P_RELAY +RELAY=$RELAY_HOST:$P_RELAY # ENCRYPTED_ONLY: 1 = only clients with the matching public key can connect. # After first startup, copy the key from ./rustdesk_data/id_ed25519.pub to @@ -177,13 +232,16 @@ RD_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$RD_DIR" - log_success "RustDesk configured at $RD_DIR" + log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $RD_DIR (ports $P_NAT-$P_WSS)" write_readme "$RD_DIR" << MD -# RustDesk — self-hosted remote desktop relay +# RustDesk${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — self-hosted remote desktop relay Open-source TeamViewer alternative. This is the server-side relay/rendezvous daemon. Clients use the RustDesk desktop/mobile app to connect. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own key, own port +block) — not shared clients with another RustDesk instance.") ## After starting: get the public key @@ -193,8 +251,8 @@ cat $RD_DIR/rustdesk_data/id_ed25519.pub Paste this key into each client: **Settings → Network → ID/Relay Server** -- ID Server: $RELAY_HOST -- Relay Server: $RELAY_HOST +- ID Server: $RELAY_HOST:$P_ID +- Relay Server: $RELAY_HOST:$P_RELAY - Key: ## Firewall / router rules required @@ -202,11 +260,11 @@ Paste this key into each client: Open these ports to this server's IP: | Port | Protocol | Purpose | |------|----------|---------| -| 21115 | TCP | NAT type test | -| 21116 | TCP+UDP | ID register / hole-punching | -| 21117 | TCP | Relay traffic | -| 21118 | TCP | WebSocket | -| 21119 | TCP | WebSocket HTTPS | +| $P_NAT | TCP | NAT type test | +| $P_ID | TCP+UDP | ID register / hole-punching | +| $P_RELAY | TCP | Relay traffic | +| $P_WS | TCP | WebSocket | +| $P_WSS | TCP | WebSocket HTTPS | ## Cross-VLAN setup Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN @@ -224,10 +282,10 @@ docker compose pull && docker compose up -d # update MD local START_RD="" - prompt_yn "Start RustDesk server now? (y/n):" "y" START_RD + prompt_yn "Start RustDesk server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_RD if [ "$START_RD" = "y" ] || [ "$START_RD" = "Y" ]; then docker compose up -d \ - && log_success "RustDesk started" \ + && log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" echo "" echo " After startup, get the public key:" @@ -237,7 +295,7 @@ MD echo "" echo " Relay host: $RELAY_HOST" - echo " Ports 21115-21119 must be open in your firewall/router." + echo " Ports $P_NAT-$P_WSS must be open in your firewall/router." echo "" } diff --git a/services/unifi.sh b/services/unifi.sh index c18626f..1da9a83 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -98,16 +98,72 @@ register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443 install_unifi() { require_docker || return 1 log_info "Installing UniFi Network Application..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "unifi-db"/"unifi-app" names/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. + # Each instance gets its own dedicated MongoDB container (not shared) — + # see CLAUDE.md's "Multi-instance services" section for why: Kopia's + # generic backup stops the container to snapshot it, so a shared DB would + # back up/restore every instance's site data as one unit instead of + # per-instance. All 4 published ports move together per instance. local UNIFI_DIR="$DOCKER_DIR/unifi" + local INSTANCE_SUFFIX="" PROJECT="unifi" DB_CONTAINER="unifi-db" APP_CONTAINER="unifi-app" + local WEB_PORT="8443" INFORM_PORT="8080" STUN_PORT="3478" DISCOVERY_PORT="10001" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $UNIFI_DIR (mongo_db_data/, unifi_data/)" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create \$DOCKER_DIR/unifi(-) (mongo_db_data/, unifi_data/)" echo "[DRY-RUN] Would deploy mongo:4 + linuxserver/unifi-network-application:latest" echo "[DRY-RUN] Ports: 8443 (HTTPS web UI), 8080 (device inform), 3478/udp (STUN), 10001/udp (discovery)" + echo "[DRY-RUN] — all 4 auto-scanned/shifted together for additional instances" echo "[DRY-RUN] Would generate MongoDB credentials" return 0 fi + if [ -d "$UNIFI_DIR" ]; then + echo "" + echo " UniFi is already installed at $UNIFI_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate UniFi controller instance alongside it (its own" + echo " database, sites, and ports — 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. 'guest-site'):" "" _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/unifi-$_suffix" ]; then + log_warning "unifi-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + UNIFI_DIR="$DOCKER_DIR/unifi-$_suffix" + PROJECT="unifi-$_suffix" + DB_CONTAINER="unifi-db-$_suffix" + APP_CONTAINER="unifi-app-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \ + || ss -tlnH "sport = :${INFORM_PORT}" 2>/dev/null | grep -q . \ + || ss -ulnH "sport = :${STUN_PORT}" 2>/dev/null | grep -q . \ + || ss -ulnH "sport = :${DISCOVERY_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + INFORM_PORT=$((INFORM_PORT + 1)) + STUN_PORT=$((STUN_PORT + 1)) + DISCOVERY_PORT=$((DISCOVERY_PORT + 1)) + done + log_info "New instance: $UNIFI_DIR (web $WEB_PORT, inform $INFORM_PORT, STUN $STUN_PORT, discovery $DISCOVERY_PORT)" + fi + fi + mkdir -p "$UNIFI_DIR" ensure_docker_dir_ownership "$UNIFI_DIR" cd "$UNIFI_DIR" || return 1 @@ -143,13 +199,13 @@ networks: # Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$ cat > docker-compose.yml << UNIFI_COMPOSE -name: unifi +name: $PROJECT services: unifi-db: image: mongo:4 - container_name: unifi-db - hostname: unifi-db + container_name: $DB_CONTAINER + hostname: $DB_CONTAINER restart: unless-stopped env_file: .env volumes: @@ -162,8 +218,8 @@ services: unifi-app: image: lscr.io/linuxserver/unifi-network-application:latest - container_name: unifi-app - hostname: unifi-app + container_name: $APP_CONTAINER + hostname: $APP_CONTAINER restart: unless-stopped env_file: .env depends_on: @@ -171,10 +227,10 @@ services: volumes: - ./unifi_data:/config ports: - - "8443:8443" - - "8080:8080" - - "3478:3478/udp" - - "10001:10001/udp" + - "${WEB_PORT}:8443" + - "${INFORM_PORT}:8080" + - "${STUN_PORT}:3478/udp" + - "${DISCOVERY_PORT}:10001/udp" # Optional — uncomment as needed: # - "1900:1900/udp" # L2 discovery (may conflict with UPnP) # - "8843:8843" # guest portal HTTPS @@ -216,7 +272,7 @@ UNIFI_ENV mkdir -p mongo_db_data unifi_data ensure_docker_dir_ownership "$UNIFI_DIR" - log_success "UniFi configured at $UNIFI_DIR" + log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $UNIFI_DIR (web port $WEB_PORT)" # ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ── local _caddy_mode="none" @@ -232,15 +288,18 @@ UNIFI_ENV fi echo "" local CADDY_UNIFI="" - prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI + prompt_yn "Configure Caddy reverse proxy for UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}? (y/n):" "n" CADDY_UNIFI if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then local UNIFI_DOMAIN="" - local _def_domain="unifi.${SITE_DOMAIN:-example.com}" + local _def_domain="unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}" prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN if [ -n "$UNIFI_DOMAIN" ]; then - # UniFi uses HTTPS internally — upstream must use https:// + skip verify - local _upstream="https://unifi-app:8443" - [ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443" + # UniFi uses HTTPS internally — upstream must use https:// + skip verify. + # Local mode reaches the container over Docker networking (internal port + # never changes); remote mode reaches this host's published port, which + # is WEB_PORT (auto-scanned for additional instances). + local _upstream="https://${APP_CONTAINER}:8443" + [ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:${WEB_PORT}" local _site_block _site_block="$(cat << CBLOCK @@ -277,7 +336,7 @@ CBLOCK || log_warning "Caddy reload failed — check: docker logs caddy" else local _snippet_dir="$DOCKER_DIR/caddy-snippets" - local _snippet_file="$_snippet_dir/unifi.caddy" + local _snippet_file="$_snippet_dir/unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.caddy" mkdir -p "$_snippet_dir" printf '%s\n' "$_site_block" > "$_snippet_file" chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true @@ -290,25 +349,29 @@ CBLOCK fi write_readme "$UNIFI_DIR" << MD -# UniFi Network Application +# UniFi Network Application${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} Ubiquiti network controller. Manages UniFi APs, switches, and gateways. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own dedicated database, own +sites/devices, own ports) — not shared adoption with another UniFi instance. +Devices can only be adopted by one controller at a time.") ## Access -- Web UI: **https://localhost:8443** (HTTPS, self-signed cert — accept the warning) +- Web UI: **https://localhost:${WEB_PORT}** (HTTPS, self-signed cert — accept the warning) - First run: complete the setup wizard and adopt your devices. ## Device adoption -Make sure devices can reach **http://:8080/inform** as the inform URL. +Make sure devices can reach **http://:${INFORM_PORT}/inform** as the inform URL. In the controller: Settings → System → Application Configuration → Override inform host. ## Ports | Port | Protocol | Purpose | |------|----------|---------| -| 8443 | TCP | HTTPS web UI | -| 8080 | TCP | Device inform / HTTP redirect | -| 3478 | UDP | STUN | -| 10001 | UDP | AP discovery | +| ${WEB_PORT} | TCP | HTTPS web UI | +| ${INFORM_PORT} | TCP | Device inform / HTTP redirect | +| ${STUN_PORT} | UDP | STUN | +| ${DISCOVERY_PORT} | UDP | AP discovery | ## Manage \`\`\`bash @@ -327,15 +390,15 @@ docker compose pull && docker compose up -d # update (wait for DB first) MD local START_UNIFI="" - prompt_yn "Start UniFi now? (y/n):" "y" START_UNIFI + prompt_yn "Start UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_UNIFI if [ "$START_UNIFI" = "y" ] || [ "$START_UNIFI" = "Y" ]; then docker compose up -d \ - && log_success "UniFi started (first startup takes ~60 s while DB initializes)" \ + && log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started (first startup takes ~60 s while DB initializes)" \ || log_warning "Failed to start — check: docker compose logs" fi echo "" - echo " Web UI: https://localhost:8443 (accept the self-signed cert warning)" + echo " Web UI: https://localhost:${WEB_PORT} (accept the self-signed cert warning)" echo " MongoDB credentials saved to: $UNIFI_DIR/.env" echo "" } diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 82867df..70607de 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -202,16 +202,61 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V install_vaultwarden() { require_docker || return 1 log_info "Installing Vaultwarden..." + + # ── Instance selection ─────────────────────────────────────────────────── + # First instance keeps the plain "vaultwarden" 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. A real use case: + # separate personal and family/household vaults with independent admin + # tokens, domains, and SMTP. local VW_DIR="$DOCKER_DIR/vaultwarden" + local INSTANCE_SUFFIX="" CONTAINER="vaultwarden" + local WEB_PORT="8888" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $VW_DIR (vaultwarden_data/)" + echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists" + echo "[DRY-RUN] Would create $VW_DIR(-) (vaultwarden_data/)" echo "[DRY-RUN] Would deploy vaultwarden/server:latest" echo "[DRY-RUN] Would generate admin token and prompt for domain" + echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance" echo "[DRY-RUN] Signups disabled by default (enable via admin panel)" return 0 fi + if [ -d "$VW_DIR" ]; then + echo "" + echo " Vaultwarden is already installed at $VW_DIR." + echo " 1) Manage that install (update / full reinstall / cancel)" + echo " 2) Add a NEW, separate Vaultwarden instance alongside it (its own" + echo " server, vault, 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/vaultwarden-$_suffix" ]; then + log_warning "vaultwarden-$_suffix already exists — pick another name."; continue + fi + break + done + INSTANCE_SUFFIX="$_suffix" + VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix" + CONTAINER="vaultwarden-$_suffix" + + while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do + WEB_PORT=$((WEB_PORT + 1)) + done + log_info "New instance: $VW_DIR (port $WEB_PORT)" + fi + fi + mkdir -p "$VW_DIR/vaultwarden_data" ensure_docker_dir_ownership "$VW_DIR" cd "$VW_DIR" || return 1 @@ -225,7 +270,7 @@ install_vaultwarden() { echo " can connect and password-reset emails link correctly." echo "" local VW_DOMAIN="" - local DEFAULT_DOMAIN="https://vault.${SITE_DOMAIN:-example.com}" + local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}" prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN [ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN" @@ -266,19 +311,19 @@ networks: fi cat > docker-compose.yml << VW_COMPOSE -name: vaultwarden +name: $CONTAINER services: vaultwarden: image: vaultwarden/server:latest - container_name: vaultwarden - hostname: vaultwarden + container_name: $CONTAINER + hostname: $CONTAINER restart: unless-stopped env_file: .env volumes: - ./vaultwarden_data:/data ports: - - "8888:80" + - "${WEB_PORT}:80" ${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} VW_COMPOSE @@ -311,15 +356,18 @@ VW_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$VW_DIR" - log_success "Vaultwarden configured at $VW_DIR" + log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $VW_DIR (port $WEB_PORT)" - configure_caddy_for_service "Vaultwarden" "vaultwarden:80" "vault" + configure_caddy_for_service "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" write_readme "$VW_DIR" << MD -# Vaultwarden — Bitwarden-compatible password manager +# Vaultwarden${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — Bitwarden-compatible password manager Lightweight, self-hosted Bitwarden server. Works with all official Bitwarden clients: browser extension, desktop app, and mobile app. +$( [ -n "$INSTANCE_SUFFIX" ] && echo " +This is a separate, fully isolated instance (own server, own vault, own +port) — not shared credentials with another Vaultwarden instance.") ## Setup 1. Point your Bitwarden client to: $VW_DOMAIN @@ -353,10 +401,10 @@ docker compose pull && docker compose up -d # update MD local START_VW="" - prompt_yn "Start Vaultwarden now? (y/n):" "y" START_VW + prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then docker compose up -d \ - && log_success "Vaultwarden started" \ + && log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" fi