Merge pull request #269 from outis1one/claude/vps-capacity-assessment-r57vw3
Claude/vps capacity assessment r57vw3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
+58
-13
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user