Merge pull request #269 from outis1one/claude/vps-capacity-assessment-r57vw3

Claude/vps capacity assessment r57vw3
This commit is contained in:
Outis
2026-08-09 18:05:15 -04:00
committed by GitHub
15 changed files with 1384 additions and 228 deletions
+111
View File
@@ -485,6 +485,117 @@ a reason (a stray Enter on a service you're just checking on shouldn't
trigger anything). `fresh` runs the exact same flow a first-time install
would, prompts included.
## Multi-instance services
Any service where running two genuinely separate copies is a real use case
(two Mattermost teams, two WordPress sites, two Traccar fleets, a
music-only Emby alongside a movies one) should support it — this isn't
opt-in per service, it's the default shape for anything that stores its
own data and isn't inherently single-tenant (skip it for things like
`caddy` or `crowdsec`, where a second instance wouldn't mean anything).
**The pattern** (see `services/mattermost.sh` for the original, and
`services/audiobookshelf.sh`/`services/emby.sh`/`services/mealie.sh`/
`services/traccar.sh`/`services/wordpress.sh` for more examples): the first
instance keeps the plain name/directory/container/ports exactly as they'd
be without any of this — zero behavior change for anyone with a single
instance already installed. Only *choosing* to add a second introduces
suffixed naming. `services/wordpress.sh` is the one exception that requires
a name from every instance including the first — reasonable for a
brand-new service with no existing single-instance installs to stay
compatible with, but not the default choice for an established service.
```bash
local DIR="$DOCKER_DIR/myservice"
local INSTANCE_SUFFIX="" CONTAINER="myservice"
local WEB_PORT="9000"
if [ -d "$DIR" ]; then
echo ""
echo " MyService is already installed at $DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate MyService instance alongside it (its own"
echo " server and data — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
[ -z "$_suffix" ] && { log_warning "Name can't be empty."; continue; }
[ -d "$DOCKER_DIR/myservice-$_suffix" ] && { log_warning "myservice-$_suffix already exists — pick another name."; continue; }
break
done
INSTANCE_SUFFIX="$_suffix"
DIR="$DOCKER_DIR/myservice-$_suffix"
CONTAINER="myservice-$_suffix"
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
WEB_PORT=$((WEB_PORT + 1))
done
log_info "New instance: $DIR (port $WEB_PORT)"
fi
fi
```
Everything downstream — `container_name`, `hostname`, published ports, the
Caddy subdomain default passed to `configure_caddy_for_service`, log/prompt
text, the generated README's title — reads from `$CONTAINER`/`$WEB_PORT`/
`$INSTANCE_SUFFIX` instead of the literal service name, so it's already
correct for either the first instance or a named one with no further
branching.
**Databases: dedicated per instance, not shared.** `services/wordpress.sh`
started as one shared MariaDB container with a separate database per site
(same resource-sharing idea as the shared `coturn` below), and was
deliberately changed away from that. The reason generalizes: Kopia's
generic backup (`services/backup.sh`) stops a service's *container* to get
a consistent snapshot, so a shared database instance backs up — and would
have to be restored — as one unit covering every instance's data at once,
not one instance independently. A dedicated database container per
instance costs more RAM (a full container each instead of one instance
split across several) in exchange for real backup/restore isolation. Data
is typically isolated either way (separate database + user regardless), so
the shared-vs-dedicated choice is about the container/process and its
backup blast radius, not about the data being mixed. Default to dedicated
per instance; only share if a service's own architecture makes that
awkward and the resource savings are worth the backup-coupling tradeoff.
**Ports beyond a single one need more than one `ss` scan, but never
port-by-port for a large range.** A service publishing two or three fixed
ports (`services/emby.sh`, `services/mattermost.sh`'s web+Calls-UDP ports)
just runs the same `ss` scan once per port. A service publishing a large
*range* (`services/traccar.sh`'s ~150-port device-protocol range) can't be
scanned port-by-port — instead shift the whole range by a fixed offset per
instance, sized off how many `$DOCKER_DIR/<name>*` directories already
exist (`find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name '<name>*' -type d
| wc -l` — the `-mindepth 1` matters, since without it `find` also matches
`$DOCKER_DIR` itself if its own basename happens to start with the service
name). Check whether the *first* instance's range carves out exclusions for
another service's fixed ports (traccar's does, for Asterisk's AMI/SIP
ports) — a large enough offset on additional instances usually clears those
same fixed ports automatically, so the exclusions typically don't need to
be repeated for instance 2+.
**Docker labels used by sidecar tooling need per-instance scoping too, not
just container names.** `services/traccar.sh`'s `autoheal` sidecar watches
containers by a Docker label that's visible host-wide, not scoped to a
compose project — two instances both using the literal `autoheal` label
would each try to restart the *other* instance's container too. Give the
label itself a per-instance value (`autoheal-<name>-<suffix>`) and point
that instance's `autoheal` container at the same value via
`AUTOHEAL_CONTAINER_LABEL`, the same way container names get suffixed.
**Verify port/count logic by actually running it, not just by reading it.**
Both real bugs caught while building this pattern into
`services/traccar.sh` — the `find` matching `$DOCKER_DIR` itself, and the
port-scan needing a genuinely free-vs-taken state to prove it increments —
were things code review alone would have missed. Install two instances in
sequence (a fake `docker`/`ss` shim standing in for a live daemon is fine)
and confirm the second one's directory, container names, and ports are
actually distinct before trusting the logic.
## Chaining into another service from within your own
A service can call another service's `install_<name>()` directly as a
+3 -1
View File
@@ -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
+89 -14
View File
@@ -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 droplets2GB, and
this plan is "fine for a couple of extensions and light personal use."
README section) — a swapfile is added automatically (RAM4GB, 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.
+41
View File
@@ -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
+57 -51
View File
@@ -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-gatedevery
# 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 512MB1GB 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 512MB1GB 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" \
+58 -13
View File
@@ -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(-<name>) 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 ""
}
+4
View File
@@ -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
+1 -1
View File
@@ -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
+63 -15
View File
@@ -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(-<name>) 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 ""
}
+124 -75
View File
@@ -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://<bucket>/<prefix> 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
+120
View File
@@ -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 <path-to-sql-dump> <path-to-files-dir>
################################################################################
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 <path-to-sql-dump> <path-to-files-dir>"
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
+59 -15
View File
@@ -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(-<name>) 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 ""
}
+128 -43
View File
@@ -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(-<name>) 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
+522
View File
@@ -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-<site> 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 <command>
\`\`\`
## 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=<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
+4
View File
@@ -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
}