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 diff --git a/README.md b/README.md index 9fbf20e..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` | +| `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` | @@ -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/docs/vps-sizing-recommendations.md b/docs/vps-sizing-recommendations.md index 5ca30c0..cb2a1fe 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. @@ -80,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 @@ -113,15 +116,55 @@ 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. + +**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 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 +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"): -- 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 +178,35 @@ 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 (settled baseline, no Emby, no actualbudget, 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, mealie | ~225MB combined | +| WordPress × 2 sites (app ~80MB + dedicated MariaDB ~120MB each) | ~400MB | +| **Total** | **~3.00GB** | + +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. +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. 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 923ca37..826f307 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." @@ -891,39 +923,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 @@ -1324,8 +1323,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 @@ -1344,10 +1344,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 @@ -1425,9 +1428,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" @@ -1533,7 +1536,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/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/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 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 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/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 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 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 diff --git a/services/wordpress.sh b/services/wordpress.sh new file mode 100644 index 0000000..b638075 --- /dev/null +++ b/services/wordpress.sh @@ -0,0 +1,522 @@ +#!/bin/bash +# 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, 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 +# 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, dedicated MariaDB per site) — blogs, business sites, e-commerce via WooCommerce" 8090 + +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 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," + 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 + + # ── 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="" 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="" + 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/db" "$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 + 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: + - 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: + default: + name: $WP_NET +${_CADDY_NET_SECTION} +WPCOMPOSE + + cat > .env << WPENV +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# 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 + +# 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 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 (both wordpress and its db) +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 $WP_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 $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 +WooCommerce's own recommendations in mind, so product/image imports work +without hitting default-image limits on the first try. + +## Backup +\`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="" + 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 "$WP_NET" \ + -v "$DIR/html:/var/www/html" \ + -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" \ + 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 $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 \\" + 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 }