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

Claude/vps capacity assessment r57vw3
This commit is contained in:
Outis
2026-08-09 20:04:57 -04:00
committed by GitHub
42 changed files with 2109 additions and 395 deletions
+110
View File
@@ -596,6 +596,116 @@ 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.
## Port collision avoidance
With 70+ services in this repo, several ship the same default port —
`emby` and `jellyfin` both default to 8096, `changedetection` and `frigate`
both default to 5000, `arm` and `nextcloud` both default to 8080. Nothing
enforced those defaults were actually free on the host: whichever service
started its container second would fail to bind ("port is already
allocated") instead of landing on the next free port. Confirmed live:
installing `jellyfin` after `emby` (or vice versa) writes a
`docker-compose.yml` claiming a port the other service's container already
holds, and it only fails at `docker compose up` time — not at install time,
and not with any warning from the installer itself.
**Every service that publishes a fixed host port must scan for a free one
before writing `docker-compose.yml` — on every install, not only when
adding an explicit additional instance of itself.** Two shared helpers in
`lib/common.sh` do the work:
```bash
port_in_use PORT [PROTO] # true if something's already listening; PROTO defaults to tcp, pass "udp" for UDP-only ports
find_free_port VARNAME START [PROTO] # scans upward from START, writes the free port back into VARNAME
```
Single-port services just call `find_free_port`:
```bash
local WEB_PORT="8080"
find_free_port WEB_PORT "$WEB_PORT"
...
- "${WEB_PORT}:8080" # host side scanned; container-internal side stays literal
```
Services with multiple ports that must move together (a web port + an
agent/RTSP/MQTT port, etc.) loop over `port_in_use` directly instead, the
same pattern `services/meshcentral.sh` and `services/unifi.sh` use:
```bash
while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do
WEB_PORT=$((WEB_PORT + 1))
AGENT_PORT=$((AGENT_PORT + 1))
done
```
On a normal single-install host this is a silent no-op — the default port
is free, so the variable comes back unchanged and nothing about the
install looks any different. It only changes behavior when something else
already holds the port, which is exactly the case that used to fail at
container-startup instead of being handled at install time.
**Standalone-mode stub.** Every service also carries a standalone
bootstrap fallback (`if [[ -f "$_COMMON" ]]; then source it; else <stubs>
fi`, for `sudo bash services/<name>.sh` with no sibling files sourced) that
duplicates the helpers it needs. `port_in_use`/`find_free_port` get the
same treatment — copy the same two function bodies into that `else` block,
matching how `log_info`, `prompt_text`, `configure_caddy_for_service`, etc.
are already duplicated there.
**Only the host side of a `"HOST:CONTAINER"` port mapping changes.** The
container-internal port is fixed by the application itself and stays a
literal number; only the host-published side becomes `${WEB_PORT}` (or
whatever the variable is named). Watch for the same literal number showing
up elsewhere in the file needing the same treatment: `configure_caddy_for_service`
calls (container-internal side stays literal; a *bare*-port host-networking
upstream, like `services/lyrion.sh`'s first instance, does need the
variable), generated companion scripts (`services/koha.sh`'s
`post-setup.sh`, `services/rustdesk.sh`'s relay-host messaging), `.env`
values baked into client-facing config (`services/wg-easy.sh`'s `WG_PORT`
env — WireGuard bakes the port into every generated peer config's
`Endpoint =` line, so it must track the *actual* published port, not just
the host-side compose mapping), and every `echo`/README line that prints
`http://localhost:<port>`.
**Quoted (`<< 'MD'`) README heredocs don't interpolate — check before
editing.** A `write_readme ... << MD` (unquoted) heredoc already
interpolates `${WEB_PORT}` directly. A quoted `<< 'MD'` heredoc doesn't,
and converting it means escaping *every* backtick used for inline-code
formatting (`` \`...\` ``) — miss one and bash tries to execute it as a
command substitution the next time the heredoc is read, the same class of
bug the coturn.sh backtick incident was (see `services/coturn.sh`'s
`write_readme` call). For a README with only one or two backticks,
escaping them is fine. For one with many (`services/iopaint.sh`'s model
reference table), it's safer to leave the heredoc quoted and patch the
port into the *written* `README.md` afterward instead:
```bash
[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md"
```
**`network_mode: host` services can only scan what the app itself lets you
override.** Host networking has no port *remapping* — whatever the app
binds to on its fixed internal port is what's exposed, so `find_free_port`
only helps for ports the app takes as configurable env vars.
`services/lyrion.sh`'s `HTTP_PORT` env is genuinely configurable (LMS
honors it as the actual bind port under host networking too — see its
`_HTTP_PORT_INTERNAL` handling, which must track the scanned port rather
than assuming bridge-mode's fixed `9000`), but its CLI (9090) and player
(3483) ports are hardcoded in the image with no override — a collision
there can only be warned about with `port_in_use`, not silently fixed.
`services/homeassistant.sh` is the same shape: bridge mode (the default)
scans freely; host mode (opt-in, for LAN device discovery) can only warn.
**Caddy is the one deliberate exception — never auto-scanned.**
`services/caddy.sh` keeps 80/443 fixed and only warns via `port_in_use` if
they're already taken. Every other service either points HTTPS clients at
Caddy implicitly (browsers assume 443) or gets routed through it by
domain; silently moving Caddy itself to a random port would leave nothing
listening at the address any client actually tries, which is strictly
worse than the collision it would be "fixing." If 80/443 are already
bound, that's a real conflict (another web server on the host) the user
needs to resolve directly.
## Chaining into another service from within your own
A service can call another service's `install_<name>()` directly as a
+39
View File
@@ -542,6 +542,45 @@ write_readme() {
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true
}
# ── Host port collision avoidance (shared by every service that publishes a
# fixed host port) ────────────────────────────────────────────────────────────
# With 70+ services in this repo, several ship the same default port (e.g.
# emby and jellyfin both default to 8096; changedetection and frigate both
# default to 5000). Nothing enforced those defaults were actually free on the
# host, so whichever service started its container second would fail to bind
# ("port is already allocated") instead of just landing on the next free port.
# Confirmed live: installing jellyfin after emby (or vice versa) writes a
# docker-compose.yml claiming a port the other service's container already
# holds, and only fails at `docker compose up` time — not at install time.
#
# port_in_use PORT [PROTO] — PROTO defaults to tcp; pass "udp" for UDP-only
# ports. Returns 0 (true, in use) or 1 (free).
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
# find_free_port VARNAME START [PROTO]
# Scans upward from START for a free host port and writes the result back
# into VARNAME. Single-port convenience wrapper around port_in_use — every
# service's install_<name>() should run its default/candidate port through
# this (or a hand-rolled port_in_use loop for multiple ports that must move
# together, e.g. a web port + an agent port) before writing docker-compose.yml,
# not only when adding an explicit additional instance of itself. On a normal
# single-install host this is a silent no-op (the default port is free, so
# VARNAME comes back unchanged); it only changes behavior when something else
# already holds the port, which is exactly the case that used to fail at
# startup instead of at install time.
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"]
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
+74 -12
View File
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -186,16 +201,60 @@ register_service actualbudget utilities "Open-source personal finance & budgetin
install_actualbudget() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "actualbudget" name/paths/port exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh.
local AB_DIR="$DOCKER_DIR/actualbudget"
local INSTANCE_SUFFIX="" CONTAINER="actualbudget"
local WEB_PORT="5006"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Actual Budget would:"
echo " - Create $AB_DIR with docker-compose.yml (data/)"
echo " - Expose port 5006"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/actualbudget(-<name>) with docker-compose.yml (data/)"
echo " - Auto-scan for a free host port if this is an additional instance"
echo " - Offer a Caddy reverse proxy and to start the container"
return 0
fi
if [ -d "$AB_DIR" ]; then
echo ""
echo " Actual Budget is already installed at $AB_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Actual Budget instance alongside it (its own"
echo " server, budget file, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/actualbudget-$_suffix" ]; then
log_warning "actualbudget-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
AB_DIR="$DOCKER_DIR/actualbudget-$_suffix"
CONTAINER="actualbudget-$_suffix"
log_info "New instance: $AB_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port (e.g.
# emby and jellyfin both default to 8096) — see CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$AB_DIR/data"
ensure_docker_dir_ownership "$AB_DIR"
cd "$AB_DIR" || return 1
@@ -226,15 +285,15 @@ networks:
fi
cat > docker-compose.yml << AB_COMPOSE
name: actualbudget
name: $CONTAINER
services:
actualbudget:
image: actualbudget/actual-server:latest
container_name: actualbudget
container_name: $CONTAINER
restart: unless-stopped
ports:
- "5006:5006"
- "${WEB_PORT}:5006"
volumes:
- ./data:/data
env_file:
@@ -248,17 +307,20 @@ CADDY_NET=$SITE_CADDY_NET
AB_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR"
log_success "Actual Budget configured at $AB_DIR"
log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AB_DIR (port $WEB_PORT)"
configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget"
configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$AB_DIR" << MD
# Actual Budget
# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Open-source personal finance and budgeting tool. Supports bank sync via
SimpleFIN (requires a SimpleFIN account at simplefin.org).
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own budget file,
own port) — not shared data with another Actual Budget instance.")
- Web UI: http://localhost:5006
- Web UI: http://localhost:${WEB_PORT}
- App data: \`data/\`
## Manage
@@ -276,13 +338,13 @@ docker compose pull && docker compose up -d # update
MD
local START_AB=""
prompt_yn "Start Actual Budget now? (y/n):" "y" START_AB
prompt_yn "Start Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_AB
if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
docker compose up -d && log_success "Actual Budget started" || log_warning "Failed to start — check: docker compose logs"
docker compose up -d && log_success "Actual Budget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:5006"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Bank sync: simplefin.org (optional, paid)"
echo ""
}
+35 -13
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -182,15 +197,22 @@ install_archivebox() {
require_docker || return 1
local AB_DIR="$DOCKER_DIR/archivebox"
local WEB_PORT="8000"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] archivebox would:"
echo " - Create $AB_DIR with docker-compose.yml"
echo " - Initialize ArchiveBox data directory"
echo " - Expose port 8000 (web UI)"
echo " - Expose port 8000 (web UI), auto-scanned for a free host port"
return 0
fi
# Scan for a free host port — this default (8000) isn't unique to
# ArchiveBox in this repo, so a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$AB_DIR"
ensure_docker_dir_ownership "$AB_DIR"
cd "$AB_DIR" || return 1
@@ -236,7 +258,7 @@ services:
volumes:
- ./data:/data
ports:
- "8000:8000"
- "${WEB_PORT}:8000"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ABCOMPOSE
@@ -254,47 +276,47 @@ ABENV
configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive"
write_readme "$AB_DIR" << 'MD'
write_readme "$AB_DIR" << MD
# ArchiveBox
Self-hosted web archiving — saves full snapshots of web pages (HTML, screenshots,
PDFs, WARC) like a personal Wayback Machine.
- Web UI: http://localhost:8000
- Web UI: http://localhost:${WEB_PORT}
## Manage
```bash
\`\`\`bash
cd ~/docker/archivebox
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
```
\`\`\`
## Add URLs to archive
```bash
# Via web UI — visit http://localhost:8000 and use the Add page
\`\`\`bash
# Via web UI — visit http://localhost:${WEB_PORT} and use the Add page
# Via CLI:
echo "https://example.com" | docker compose run --rm archivebox add
docker compose run --rm archivebox add --depth=1 https://example.com
```
\`\`\`
## Create admin user
```bash
\`\`\`bash
docker compose run --rm archivebox manage createsuperuser
```
\`\`\`
MD
local START_AB=""
prompt_yn "Start ArchiveBox now? (y/n):" "y" START_AB
if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
docker compose up -d \
&& log_success "ArchiveBox started — http://localhost:8000" \
&& log_success "ArchiveBox started — http://localhost:${WEB_PORT}" \
|| log_warning "Start failed — check: docker compose logs"
fi
echo ""
echo " Web UI: http://localhost:8000"
echo " Web UI: http://localhost:${WEB_PORT}"
echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add"
echo ""
}
+26 -4
View File
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -198,6 +213,7 @@ install_arm() {
local ARM_DIR="$DOCKER_DIR/arm"
local DEFAULT_OUTPUT="$ACTUAL_HOME/ripped"
local WEB_PORT="8080"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] A.R.M. would:"
@@ -205,7 +221,7 @@ install_arm() {
echo " - Detect optical drives (/dev/sr*) — defaults to /dev/sr0"
echo " - Create ripped output dirs (movies/ music/) under $DEFAULT_OUTPUT"
echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER") with privileged: true"
echo " - Expose port 8080"
echo " - Expose port 8080 (auto-scanned for a free host port)"
echo " - Offer a Caddy reverse proxy and to start the container"
return 0
fi
@@ -225,6 +241,12 @@ install_arm() {
OPTICAL_DRIVES="/dev/sr0"
fi
# Scan for a free host port — this default (8080) isn't unique to A.R.M.
# in this repo (nextcloud also defaults to 8080), so a plain install
# shouldn't silently claim a port another already-running service holds.
# See CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$ARM_DIR"
ensure_docker_dir_ownership "$ARM_DIR"
cd "$ARM_DIR" || return 1
@@ -275,7 +297,7 @@ services:
- \${ARM_OUTPUT}/movies:/home/arm/media/completed
- \${ARM_OUTPUT}/music:/home/arm/music
ports:
- "8080:8080"
- "${WEB_PORT}:8080"
devices:
- /dev/sr0:/dev/sr0
# Add more optical drives as needed:
@@ -303,7 +325,7 @@ ARM_ENV
Auto-rips DVDs, Blu-rays, and CDs when you insert them — identifies the disc,
fetches metadata, and transcodes to a usable format.
- Web UI: http://localhost:8080 (complete setup on first visit)
- Web UI: http://localhost:${WEB_PORT} (complete setup on first visit)
- Ripped output: \`$ARM_OUTPUT\` → movies and music subdirs
- App data: \`config/\` and \`logs/\`
@@ -338,7 +360,7 @@ MD
fi
echo ""
echo " Access at: http://localhost:8080"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Complete setup in browser on first visit."
echo ""
}
+22 -5
View File
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -232,14 +247,16 @@ install_audiobookshelf() {
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)"
log_info "New instance: $ABS_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
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%/}"
+15
View File
@@ -46,6 +46,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
+31
View File
@@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -233,6 +248,22 @@ install_caddy() {
fi
fi
# Unlike every other service, Caddy's ports (80/443) are NOT auto-scanned
# or shifted on a collision — see CLAUDE.md's "Port collision avoidance"
# section. Every other service in this repo either points HTTPS clients
# at Caddy implicitly (browsers assume 443) or gets its own reverse-proxy
# domain through it; silently moving Caddy to a random port would leave
# nothing at the address clients actually try, which is strictly worse
# than the collision itself. If 80/443 are already taken, that's a real
# conflict (another web server already bound to those ports) the user
# needs to resolve directly — warn loudly instead of masking it.
if port_in_use 80 || port_in_use 443; then
log_warning "Port 80 and/or 443 is already in use by another process."
log_warning "Caddy needs both to serve HTTPS — find what's using them"
log_warning "(sudo ss -tlnp 'sport = :80' / ':443') and stop it, or Caddy"
log_warning "will fail to start. Continuing anyway — this is not auto-fixed."
fi
mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config"
ensure_docker_dir_ownership "$CADDY_DIR"
+30 -8
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -186,13 +201,20 @@ install_calibre-web() {
log_info "Installing Calibre-Web..."
local CW_DIR="$DOCKER_DIR/calibre-web"
local WEB_PORT="8083"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $CW_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$CW_DIR/config" "$CW_DIR/books"
ensure_docker_dir_ownership "$CW_DIR"
cd "$CW_DIR" || return 1
@@ -238,7 +260,7 @@ services:
- ./config:/config
- ./books:/books
ports:
- "8083:8083"
- "${WEB_PORT}:8083"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
CW_COMPOSE
@@ -253,20 +275,20 @@ CW_ENV
configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books"
write_readme "$CW_DIR" << 'MD'
write_readme "$CW_DIR" << MD
# Calibre-Web
Web-based ebook library with metadata editing, reading, and format conversion
powered by Calibre.
## First-run setup
1. Open the UI (http://localhost:8083) and log in with admin / admin123
2. When prompted for the database location, enter: `/books`
1. Open the UI (http://localhost:${WEB_PORT}) and log in with admin / admin123
2. When prompted for the database location, enter: \`/books\`
(point this at your existing Calibre library or an empty directory)
3. Change the default password immediately under Admin → Edit User
## Ebook conversion
The `DOCKER_MODS=linuxserver/mods:universal-calibre` environment variable
The \`DOCKER_MODS=linuxserver/mods:universal-calibre\` environment variable
installs the full Calibre binary inside the container, enabling on-the-fly
ebook conversion (e.g. EPUB → MOBI/AZW3).
@@ -275,15 +297,15 @@ Place your Calibre library (or individual books) in:
~/docker/calibre-web/books/
If you already have a Calibre library elsewhere, mount that path instead by
editing the `./books:/books` volume line in docker-compose.yml.
editing the \`./books:/books\` volume line in docker-compose.yml.
## Manage
```bash
\`\`\`bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose down && docker compose up -d
```
\`\`\`
MD
local START_CW=""
+31 -8
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -186,13 +201,21 @@ install_changedetection() {
log_info "Installing Changedetection.io..."
local CD_DIR="$DOCKER_DIR/changedetection"
local WEB_PORT="5000"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $CD_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
# Scan for a free host port — this default (5000) isn't unique to
# Changedetection.io in this repo (frigate also defaults to 5000), so a
# plain install shouldn't silently claim a port another already-running
# service holds. See CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$CD_DIR/data"
ensure_docker_dir_ownership "$CD_DIR"
cd "$CD_DIR" || return 1
@@ -230,7 +253,7 @@ services:
hostname: changedetection
restart: unless-stopped
ports:
- "5000:5000"
- "${WEB_PORT}:5000"
environment:
- BASE_URL=\${BASE_URL}
- PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000
@@ -263,7 +286,7 @@ CD_ENV
configure_caddy_for_service "Changedetection" "changedetection:5000" "changes"
write_readme "$CD_DIR" << 'MD'
write_readme "$CD_DIR" << MD
# Changedetection.io
Monitor web pages for changes and receive notifications via email, Slack,
@@ -271,20 +294,20 @@ Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar
for JavaScript-heavy pages.
## Access
- URL: http://localhost:5000
- URL: http://localhost:${WEB_PORT}
- Optional password can be set in Settings → General within the UI.
## Manage
```bash
\`\`\`bash
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose down && docker compose up -d # update
```
\`\`\`
## Environment
Edit `.env` to change `BASE_URL` (used for notification links),
then restart: `docker compose down && docker compose up -d`
Edit \`.env\` to change \`BASE_URL\` (used for notification links),
then restart: \`docker compose down && docker compose up -d\`
MD
local START_CD=""
@@ -295,7 +318,7 @@ MD
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:5000"
echo " Access at: http://localhost:${WEB_PORT}"
echo ""
}
+33 -10
View File
@@ -42,6 +42,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -187,15 +202,23 @@ install_drum-rhythm-game() {
local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game"
local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git"
local WEB_PORT="8096"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] drum-rhythm-game would:"
echo " - Clone $REPO_URL to $DRUM_DIR/html"
echo " - Build the repo's own Dockerfile (nginx, gzip, /healthz) on port 8096"
echo " (auto-scanned for a free host port — 8096 is also emby/jellyfin's default)"
echo " - Offer Authelia SSO protection via Caddy (no built-in auth)"
return 0
fi
# Scan for a free host port — this default (8096) is also emby's and
# jellyfin's default, so a plain install shouldn't silently claim a port
# another already-running service holds. See CLAUDE.md's "Port collision
# avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$DRUM_DIR"
ensure_docker_dir_ownership "$DRUM_DIR"
cd "$DRUM_DIR" || return 1
@@ -248,7 +271,7 @@ services:
hostname: drum-rhythm-game
restart: unless-stopped
ports:
- "8096:80"
- "${WEB_PORT}:80"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
DRUM_COMPOSE
@@ -268,7 +291,7 @@ DRUM_ENV
fi
configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK"
write_readme "$DRUM_DIR" << 'MD'
write_readme "$DRUM_DIR" << MD
# Drum Rhythm Game
Browser-based drum rhythm game — 18 genres covering 119 synth-orchestra
@@ -281,36 +304,36 @@ Multiplayer take-turns mode, adjustable speed/volume, and a leaderboard —
all state lives in browser localStorage. No server required; all audio is
synthesized in-browser via the Web Audio API.
Built and served from the repo's own `Dockerfile` (nginx + gzip + a
`/healthz` endpoint) — only `index.html` ends up in the image, so the
Built and served from the repo's own \`Dockerfile\` (nginx + gzip + a
\`/healthz\` endpoint) — only \`index.html\` ends up in the image, so the
repo's docs/license/dev files never get served.
Source: https://github.com/outis1one/drum-rhythm-game
## Access
- URL: http://localhost:8096
- URL: http://localhost:${WEB_PORT}
## Manage
```bash
\`\`\`bash
cd ~/docker/drum-rhythm-game
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
```
\`\`\`
## Update game
```bash
\`\`\`bash
cd ~/docker/drum-rhythm-game
git -C html pull
docker compose up -d --build
```
\`\`\`
MD
local START_DRUM=""
prompt_yn "Start drum-rhythm-game now? (y/n):" "y" START_DRUM
if [ "$START_DRUM" = "y" ] || [ "$START_DRUM" = "Y" ]; then
docker compose up -d --build \
&& log_success "Drum Rhythm Game started — http://localhost:8096" \
&& log_success "Drum Rhythm Game started — http://localhost:${WEB_PORT}" \
|| log_warning "Start failed — check: docker compose logs"
fi
+24 -8
View File
@@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -246,17 +261,18 @@ install_emby() {
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)"
log_info "New instance: $EMBY_DIR"
fi
fi
# Scan for free ports unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed these default ports
# (e.g. jellyfin also defaults to 8096) — see CLAUDE.md's "Port collision
# avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
find_free_port HTTPS_PORT "$HTTPS_PORT"
local MUSIC_ONLY=""
prompt_yn "Set this up as a music-only server (skip movies/TV)? (y/n):" "n" MUSIC_ONLY
+74 -12
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -184,14 +199,58 @@ install_filebrowser() {
require_docker || return 1
log_info "Installing FileBrowser Quantum..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "filebrowser" name/paths/port exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh.
local FB_DIR="$DOCKER_DIR/filebrowser"
local INSTANCE_SUFFIX="" CONTAINER="filebrowser"
local WEB_PORT="8085"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $FB_DIR"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $FB_DIR(-<name>)"
echo "[DRY-RUN] Would write docker-compose.yml and data/config.yaml"
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
return 0
fi
if [ -d "$FB_DIR" ]; then
echo ""
echo " FileBrowser is already installed at $FB_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate FileBrowser instance alongside it (its own"
echo " server, config, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'shared'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/filebrowser-$_suffix" ]; then
log_warning "filebrowser-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
FB_DIR="$DOCKER_DIR/filebrowser-$_suffix"
CONTAINER="filebrowser-$_suffix"
log_info "New instance: $FB_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$FB_DIR/data"
ensure_docker_dir_ownership "$FB_DIR"
cd "$FB_DIR" || return 1
@@ -223,13 +282,13 @@ networks:
fi
cat > docker-compose.yml << FB_COMPOSE
name: filebrowser
name: $CONTAINER
services:
filebrowser:
image: gtstef/filebrowser:stable
container_name: filebrowser
hostname: filebrowser
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- TZ=${SITE_TZ:-UTC}
@@ -237,7 +296,7 @@ services:
- ./data:/home/filebrowser/data
- ${FB_PATH}:/files
ports:
- "8085:80"
- "${WEB_PORT}:80"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FB_COMPOSE
@@ -282,18 +341,21 @@ FB_CONFIG
fi
echo ""
log_success "FileBrowser Quantum configured at $FB_DIR"
log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FB_DIR (port $WEB_PORT)"
configure_caddy_for_service "FileBrowser" "filebrowser:80" "files"
configure_caddy_for_service "FileBrowser${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "files${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$FB_DIR" << MD
# FileBrowser Quantum
# FileBrowser Quantum${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Web-based file manager with multi-source support, office preview, and
per-user access control.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own config, own
port) — not shared sources with another FileBrowser instance.")
## Access
- URL: http://localhost:8085
- URL: http://localhost:${WEB_PORT}
- Default login: admin / admin (change immediately!)
## Adding sources (extra directories)
@@ -320,14 +382,14 @@ docker compose pull && docker compose down && docker compose up -d # update
MD
local START_FB=""
prompt_yn "Start FileBrowser Quantum now? (y/n):" "y" START_FB
prompt_yn "Start FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FB
if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "FileBrowser Quantum started" \
&& log_success "FileBrowser Quantum${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:8085"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Default login: admin / admin (change immediately!)"
echo ""
}
+75 -14
View File
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -196,17 +211,60 @@ register_service fmd utilities "Android device tracking — alternative to Googl
install_fmd() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "fmd" name/paths/port exactly as before
# (zero behavior change for anyone with a single instance). Only asking to
# add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh.
local FMD_DIR="$DOCKER_DIR/fmd"
local INSTANCE_SUFFIX="" CONTAINER="fmd"
local WEB_PORT="8084"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] FindMyDevice would:"
echo " - Create $FMD_DIR with docker-compose.yml + .env (data/)"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/fmd(-<name>) with docker-compose.yml + .env (data/)"
echo " - Generate a random admin password"
echo " - Expose port 8084"
echo " - Auto-scan for a free host port if this is an additional instance"
echo " - Offer a Caddy reverse proxy and to start the container"
return 0
fi
if [ -d "$FMD_DIR" ]; then
echo ""
echo " FindMyDevice is already installed at $FMD_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate FindMyDevice instance alongside it (its own"
echo " server, password, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/fmd-$_suffix" ]; then
log_warning "fmd-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
FMD_DIR="$DOCKER_DIR/fmd-$_suffix"
CONTAINER="fmd-$_suffix"
log_info "New instance: $FMD_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$FMD_DIR"
ensure_docker_dir_ownership "$FMD_DIR"
cd "$FMD_DIR" || return 1
@@ -238,20 +296,20 @@ networks:
fi
cat > docker-compose.yml << FMD_COMPOSE
name: fmd
name: $CONTAINER
services:
fmd:
image: nulide/findmydevice
container_name: fmd
hostname: fmd
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD}
volumes:
- ./data:/fmd/data
ports:
- "8084:8080"
- "${WEB_PORT}:8080"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FMD_COMPOSE
@@ -262,17 +320,20 @@ FMD_ENV
mkdir -p data
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR"
log_success "FindMyDevice configured at $FMD_DIR"
log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FMD_DIR (port $WEB_PORT)"
configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd"
configure_caddy_for_service "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "fmd${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$FMD_DIR" << MD
# FindMyDevice (FMD)
# FindMyDevice (FMD)${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted Android device tracking — locate, lock, or wipe your device
from the web UI. Alternative to Google's Find My Device.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own password, own
port) — not shared devices with another FindMyDevice instance.")
- Web UI: http://localhost:8084
- Web UI: http://localhost:${WEB_PORT}
- Admin password: stored in \`.env\` (\`FMD_ADMIN_PASSWORD\`)
- App data: \`data/\`
@@ -287,19 +348,19 @@ docker compose pull && docker compose up -d # update
## Mobile app
Install **FindMyDevice** from **F-Droid** (not the Play Store version):
1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:8084\`
1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:${WEB_PORT}\`
2. Enter your admin password from \`.env\`
3. Grant location and accessibility permissions
MD
local START_FMD=""
prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD
prompt_yn "Start FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_FMD
if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then
docker compose up -d && log_success "FindMyDevice started" || log_warning "Failed to start — check: docker compose logs"
docker compose up -d && log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:8084"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Password: $FMD_PASS (saved in .env)"
echo " Mobile app: FindMyDevice on F-Droid"
echo ""
+41 -7
View File
@@ -62,6 +62,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$_len"
@@ -216,6 +231,7 @@ install_frigate-audio() {
require_docker || return 1
local DIR="$DOCKER_DIR/frigate-audio"
local WEB_PORT="8971" DEBUG_PORT="5001" RTSP_PORT="8554" WEBRTC_PORT="8555" MQTT_PORT="1883"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] frigate-audio would:"
@@ -223,9 +239,27 @@ install_frigate-audio() {
echo " Prompt for camera RTSP credentials, IPs, MQTT password, ntfy server"
echo " Generate docker-compose.yml, frigate config, mosquitto config, .env"
echo " Bootstrap the Mosquitto password file"
echo " Ports 8971/5001/8554/8555/1883 auto-scanned/shifted together if occupied"
return 0
fi
# Scan for free host ports, moving all 5 together — a plain install
# shouldn't silently claim a port another already-running service holds
# (e.g. 1883 is the standard MQTT port and could already be in use by a
# Home Assistant add-on or another broker). Container-internal ports and
# container-to-container references (mosquitto:1883, 127.0.0.1:5000/8554
# inside the frigate container) are unaffected by the host-side ports
# scanned here. See CLAUDE.md's "Port collision avoidance" section.
while port_in_use "$WEB_PORT" || port_in_use "$DEBUG_PORT" \
|| port_in_use "$RTSP_PORT" || port_in_use "$WEBRTC_PORT" \
|| port_in_use "$WEBRTC_PORT" udp || port_in_use "$MQTT_PORT"; do
WEB_PORT=$((WEB_PORT + 1))
DEBUG_PORT=$((DEBUG_PORT + 1))
RTSP_PORT=$((RTSP_PORT + 1))
WEBRTC_PORT=$((WEBRTC_PORT + 1))
MQTT_PORT=$((MQTT_PORT + 1))
done
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ Frigate + Mosquitto + frigate-notify (audio-ready stack) ║"
@@ -434,11 +468,11 @@ COMPOSEEOF
tmpfs:
size: 1000000000
ports:
- "8971:8971"
- "5001:5000"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
- "${WEB_PORT}:8971"
- "${DEBUG_PORT}:5000"
- "${RTSP_PORT}:8554"
- "${WEBRTC_PORT}:8555/tcp"
- "${WEBRTC_PORT}:8555/udp"
${_CADDY_NET_BLOCK} healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"]
interval: 10s
@@ -452,7 +486,7 @@ ${_CADDY_NET_BLOCK} healthcheck:
image: eclipse-mosquitto:2
restart: unless-stopped
ports:
- "1883:1883"
- "${MQTT_PORT}:1883"
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
@@ -760,7 +794,7 @@ FNEOF
if [[ ${START_NOW:-n} =~ ^[Yy]$ ]]; then
log_info "Starting frigate-audio stack..."
if ( cd "$DIR" && docker compose up -d ); then
log_success "Stack started — Frigate UI: http://localhost:8971"
log_success "Stack started — Frigate UI: http://localhost:${WEB_PORT}"
else
log_warning "Start failed — check: cd $DIR && docker compose logs"
fi
+40 -9
View File
@@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -497,12 +512,15 @@ install_frigate() {
require_docker || return 1
local FRIGATE_DIR="$DOCKER_DIR/frigate"
local WEB_PORT="5000" RTSP_PORT="8554" WEBRTC_PORT="8555"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Frigate would:"
echo " - Create $FRIGATE_DIR with docker-compose.yml + .env + config/config.yml"
echo " - Auto-enable /dev/dri/renderD128 for GPU-assisted detection if present"
echo " - Expose ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC)"
echo " — all 3 auto-scanned/shifted together if occupied (5000 is also"
echo " changedetection's default)"
echo " - If already configured: show existing cameras and offer to keep,"
echo " back up + start fresh, add more, or remove some"
echo " - Prompt to add cameras interactively (RTSP creds go in .env)"
@@ -511,6 +529,19 @@ install_frigate() {
return 0
fi
# Scan for free host ports, moving all 3 together — a plain install
# shouldn't silently claim a port another already-running service holds
# (changedetection.io also defaults to 5000). See CLAUDE.md's "Port
# collision avoidance" section. Internal go2rtc restream URLs elsewhere
# in this file (rtsp://127.0.0.1:8554/...) are container-internal
# loopback references, unaffected by the host-side port used here.
while port_in_use "$WEB_PORT" || port_in_use "$RTSP_PORT" \
|| port_in_use "$WEBRTC_PORT" || port_in_use "$WEBRTC_PORT" udp; do
WEB_PORT=$((WEB_PORT + 1))
RTSP_PORT=$((RTSP_PORT + 1))
WEBRTC_PORT=$((WEBRTC_PORT + 1))
done
mkdir -p "$FRIGATE_DIR"
ensure_docker_dir_ownership "$FRIGATE_DIR"
cd "$FRIGATE_DIR" || return 1
@@ -605,10 +636,10 @@ $DEVICE_BLOCK
tmpfs:
size: 1000000000
ports:
- "5000:5000"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
- "${WEB_PORT}:5000"
- "${RTSP_PORT}:8554"
- "${WEBRTC_PORT}:8555/tcp"
- "${WEBRTC_PORT}:8555/udp"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FRIGATE_COMPOSE
@@ -709,9 +740,9 @@ FRIGATE_ENV
AI-powered network video recorder with real-time object detection for
security cameras. Detects people, cars, animals, and more.
- Web UI: http://localhost:5000
- RTSP restream: port 8554
- WebRTC: port 8555
- Web UI: http://localhost:${WEB_PORT}
- RTSP restream: port ${RTSP_PORT}
- WebRTC: port ${WEBRTC_PORT}
- Recordings: \`$FRIGATE_MEDIA\`
- Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total)
- Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables
@@ -736,7 +767,7 @@ container startup.
## First steps
1. Review \`config/config.yml\` — adjust detection zones, masks, retention
2. Start Frigate: \`docker compose up -d\`
3. Open http://localhost:5000 to view cameras and configure detection zones
3. Open http://localhost:${WEB_PORT} to view cameras and configure detection zones
## Hardware acceleration
- Intel/AMD GPU: uncomment the \`devices: [/dev/dri/renderD128]\` block
@@ -761,7 +792,7 @@ MD
fi
echo ""
echo " Access at: http://localhost:5000"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Config: $FRIGATE_DIR/config/config.yml"
echo ""
}
+26 -4
View File
@@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -196,14 +211,21 @@ install_gatus() {
require_docker || return 1
log_info "Installing Gatus..."
local GATUS_DIR="$DOCKER_DIR/gatus"
local WEB_PORT="8086"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $GATUS_DIR (gatus_config/, gatus_data/)"
echo "[DRY-RUN] Would deploy twinproduction/gatus:latest"
echo "[DRY-RUN] Port 8086 published, config at gatus_config/config.yaml"
echo "[DRY-RUN] Port 8086 published (auto-scanned for a free host port),"
echo "[DRY-RUN] config at gatus_config/config.yaml"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$GATUS_DIR/gatus_config" "$GATUS_DIR/gatus_data"
ensure_docker_dir_ownership "$GATUS_DIR"
cd "$GATUS_DIR" || return 1
@@ -244,7 +266,7 @@ services:
restart: unless-stopped
env_file: .env
ports:
- "8086:8080"
- "${WEB_PORT}:8080"
volumes:
- ./gatus_config:/config
- ./gatus_data:/data
@@ -324,7 +346,7 @@ GATUS_CFG
Clean, self-hosted status page. Polls HTTP, TCP, DNS, and ICMP endpoints.
## Access
- URL: http://localhost:8086
- URL: http://localhost:${WEB_PORT}
## Configuration
Edit \`gatus_config/config.yaml\` — changes are **hot-reloaded** without restarting.
@@ -355,7 +377,7 @@ MD
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:8086"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Config: $GATUS_DIR/gatus_config/config.yaml (hot-reloaded)"
echo ""
}
+33 -5
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -189,9 +204,13 @@ install_homeassistant() {
require_docker || return 1
log_info "Installing Home Assistant..."
local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant"
local WEB_PORT="8123"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR"
echo "[DRY-RUN] Bridge mode would auto-scan for a free host port; host mode"
echo "[DRY-RUN] can only warn on a collision (Home Assistant's own port isn't"
echo "[DRY-RUN] configurable, and host networking has no remapping)"
return 0
fi
@@ -224,16 +243,25 @@ install_homeassistant() {
HA_NET_LINES=" network_mode: host"
HA_CADDY_NET_LINES=""
echo " → Host networking selected (best device discovery)."
# Host mode has no port remapping and Home Assistant's own port isn't
# configurable via env var — a collision here can only be warned
# about, not silently fixed. See CLAUDE.md's "Port collision
# avoidance" section.
port_in_use "$WEB_PORT" && log_warning "Port $WEB_PORT is already in use by another process — Home Assistant won't be reachable until that's resolved (this port isn't configurable with host networking)."
else
# Bridge mode can freely remap — scan for a free host port so a
# plain install doesn't silently claim a port another
# already-running service holds.
find_free_port WEB_PORT "$WEB_PORT"
HA_NET_LINES=" ports:
- \"8123:8123\""
- \"${WEB_PORT}:8123\""
if [ "$_CADDY_MODE" = "local" ]; then
HA_CADDY_NET_LINES=" networks:
- caddy_net"
else
HA_CADDY_NET_LINES=""
fi
echo " → Bridge networking selected (port 8123 published)."
echo " → Bridge networking selected (port $WEB_PORT published)."
fi
local _CADDY_NET_SECTION=""
@@ -292,7 +320,7 @@ HA_CONFIG
log_success "Home Assistant configured at $HOMEASSISTANT_DIR"
if [ "$HA_NETMODE" = "2" ]; then
configure_caddy_for_service "Home Assistant" "8123" "home"
configure_caddy_for_service "Home Assistant" "$WEB_PORT" "home"
else
configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home"
fi
@@ -303,7 +331,7 @@ HA_CONFIG
Home automation hub. Built-in auth — no Authelia needed.
## Access
- URL: http://localhost:8123
- URL: http://localhost:${WEB_PORT}
- First run: create your admin account through the onboarding wizard
## Manage
@@ -322,7 +350,7 @@ MD
docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start"
fi
echo " Access at: http://localhost:8123"
echo " Access at: http://localhost:${WEB_PORT}"
echo " First run: open the URL and create your admin account (onboarding)."
echo " Note: first startup can take a minute while HA initializes."
echo ""
+80 -17
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -185,14 +200,58 @@ install_homebox() {
require_docker || return 1
log_info "Installing Homebox..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "homebox" name/paths/port exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh.
local HB_DIR="$DOCKER_DIR/homebox"
local INSTANCE_SUFFIX="" CONTAINER="homebox"
local WEB_PORT="7745"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $HB_DIR"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $HB_DIR(-<name>)"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
return 0
fi
if [ -d "$HB_DIR" ]; then
echo ""
echo " Homebox is already installed at $HB_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Homebox instance alongside it (its own"
echo " server, inventory, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'garage'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/homebox-$_suffix" ]; then
log_warning "homebox-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
HB_DIR="$DOCKER_DIR/homebox-$_suffix"
CONTAINER="homebox-$_suffix"
log_info "New instance: $HB_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$HB_DIR/data"
ensure_docker_dir_ownership "$HB_DIR"
cd "$HB_DIR" || return 1
@@ -221,13 +280,13 @@ networks:
fi
cat > docker-compose.yml << HB_COMPOSE
name: homebox
name: $CONTAINER
services:
homebox:
image: ghcr.io/sysadminsmedia/homebox:latest
container_name: homebox
hostname: homebox
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- HBOX_LOG_LEVEL=info
@@ -235,7 +294,7 @@ services:
volumes:
- ./data:/data
ports:
- "7745:7745"
- "${WEB_PORT}:7745"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
HB_COMPOSE
@@ -246,18 +305,21 @@ HB_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR"
echo ""
log_success "Homebox configured at $HB_DIR"
log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $HB_DIR (port $WEB_PORT)"
configure_caddy_for_service "Homebox" "homebox:7745" "homebox"
configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$HB_DIR" << 'MD'
# Homebox
write_readme "$HB_DIR" << MD
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Home inventory and asset management. Track items, locations, labels,
warranties, and attachments across your household.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own inventory, own
port) — not shared items with another Homebox instance.")
## Access
- URL: http://localhost:7745
- URL: http://localhost:${WEB_PORT}
- Register your account on first visit — the first user becomes the admin.
## Data
@@ -265,27 +327,28 @@ warranties, and attachments across your household.
## Configuration
Key environment variables (edit docker-compose.yml to change):
- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error)
- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10)
- \`HBOX_LOG_LEVEL\` — log verbosity (info, debug, warn, error)
- \`HBOX_WEB_MAX_UPLOAD_SIZE\` — max attachment upload size in MB (default: 10)
## Manage
```bash
\`\`\`bash
cd $HB_DIR
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose down && docker compose up -d
```
\`\`\`
MD
local START_HB=""
prompt_yn "Start Homebox now? (y/n):" "y" START_HB
prompt_yn "Start Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_HB
if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Homebox started" \
&& log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:7745"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Register your account on first visit."
echo ""
}
+91 -19
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -189,23 +204,77 @@ register_service immich media "Self-hosted photo & video backup — like Google
install_immich() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "immich" name/paths/port and
# immich_server/immich_machine_learning/immich_redis/immich_postgres
# container names exactly as before (zero behavior change for anyone with
# a single instance). Only asking to add a second one introduces suffixed
# naming — same pattern as services/mattermost.sh and services/wordpress.sh.
# Each instance gets its own dedicated Postgres (already the case — one
# per compose project) and its own model-cache volume (scoped by the
# per-instance Compose project name), so instances are fully isolated for
# backup/restore too, per CLAUDE.md's "Multi-instance services" section.
local IMMICH_DIR="$DOCKER_DIR/immich"
local INSTANCE_SUFFIX="" PROJECT="immich"
local C_SERVER="immich_server" C_ML="immich_machine_learning" C_REDIS="immich_redis" C_DB="immich_postgres"
local WEB_PORT="2283"
local DEFAULT_PHOTOS="$ACTUAL_HOME/photos"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Immich would:"
echo " - Create $IMMICH_DIR with docker-compose.yml + .env"
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/immich(-<name>) with docker-compose.yml + .env"
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres (dedicated per instance)"
echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper"
echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate"
echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible"
echo " object storage instead of local disk (native IMMICH_STORAGE_ENGINE=s3 —"
echo " NOT a FUSE mount, those are unreliable for Immich's access pattern)"
echo " - Expose port 2283"
echo " - Expose port 2283, auto-scanned for additional instances"
echo " - Offer a Caddy reverse proxy and to start the stack"
return 0
fi
if [ -d "$IMMICH_DIR" ]; then
echo ""
echo " Immich is already installed at $IMMICH_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Immich instance alongside it (its own"
echo " server, database, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/immich-$_suffix" ]; then
log_warning "immich-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
IMMICH_DIR="$DOCKER_DIR/immich-$_suffix"
PROJECT="immich-$_suffix"
C_SERVER="immich_server_$_suffix"
C_ML="immich_ml_$_suffix"
C_REDIS="immich_redis_$_suffix"
C_DB="immich_postgres_$_suffix"
DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix"
log_info "New instance: $IMMICH_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
# ── Photo library setup ─────────────────────────────────────────────────
echo ""
echo " PHOTO LIBRARY SETUP"
@@ -384,18 +453,18 @@ networks:
"
cat > docker-compose.yml << IMMICH_COMPOSE
name: immich
name: $PROJECT
services:
immich-server:
container_name: immich_server
container_name: $C_SERVER
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
volumes:
${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- 2283:2283
- ${WEB_PORT}:2283
depends_on:
- redis
- database
@@ -404,7 +473,7 @@ ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localt
disable: false
${_CADDY_NET_BLOCK}
immich-machine-learning:
container_name: immich_machine_learning
container_name: $C_ML
image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release}
volumes:
- model-cache:/cache
@@ -415,14 +484,14 @@ ${_CADDY_NET_BLOCK}
disable: false
redis:
container_name: immich_redis
container_name: $C_REDIS
image: docker.io/valkey/valkey:9-bookworm
healthcheck:
test: valkey-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
container_name: $C_DB
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: \${DB_PASSWORD}
@@ -541,7 +610,7 @@ IMMICH_ENV
IMPORT_HEAD
cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS
IMMICH_URL="http://localhost:2283"
IMMICH_URL="http://localhost:${WEB_PORT}"
SOURCE_DIR="$EXISTING_PHOTOS_SOURCE"
IMMICH_DIR="$IMMICH_DIR"
IMPORT_VARS
@@ -791,17 +860,20 @@ IMPORT_BODY
log_success "Import helper written: $IMMICH_DIR/import-photos.sh"
fi
log_success "Immich configured at $IMMICH_DIR"
log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $IMMICH_DIR (port $WEB_PORT)"
configure_caddy_for_service "Immich" "immich-server:2283" "immich"
configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$IMMICH_DIR" << MD
# Immich
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted photo and video backup — like Google Photos but private.
Mobile apps (iOS/Android) auto-upload in the background.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own dedicated
database, own port) — not shared photos with another Immich instance.")
- Web UI: http://localhost:2283
- Web UI: http://localhost:${WEB_PORT}
- Photo storage: $( [ "$USE_S3" = true ] && echo "S3 bucket \`$S3_BUCKET\` (thumbnails, encoded video, new uploads)" || echo "\`$UPLOAD_LOCATION\`" )
- App data (postgres, model cache): inside this folder
- Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply.
@@ -836,8 +908,8 @@ docker compose pull && docker compose up -d # update
\`\`\`
## First launch
1. Open http://localhost:2283 and create your admin account.
2. Install the Immich mobile app and point it at \`http://<server-ip>:2283\`.
1. Open http://localhost:${WEB_PORT} and create your admin account.
2. Install the Immich mobile app and point it at \`http://<server-ip>:${WEB_PORT}\`.
3. (External library mode) Go to Admin → External Libraries → Create Library,
set import path to \`/usr/src/app/external\`, and click Scan.
@@ -855,13 +927,13 @@ docker compose pull && docker compose up -d # update
MD
local START_IMMICH=""
prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH
prompt_yn "Start Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_IMMICH
if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then
docker compose up -d && log_success "Immich started" || log_warning "Failed to start — check: docker compose logs"
docker compose up -d && log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:2283"
echo " Access at: http://localhost:${WEB_PORT}"
echo " First launch: create your admin account in the web UI."
echo ""
}
+31 -3
View File
@@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -194,15 +209,22 @@ install_iopaint() {
log_info "Installing IOPaint..."
local IOPAINT_DIR="$DOCKER_DIR/iopaint"
local WEB_PORT="8100"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $IOPAINT_DIR"
echo "[DRY-RUN] Would prompt for model and GPU (CUDA) support"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
echo "[DRY-RUN] Would offer Authelia SSO"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$IOPAINT_DIR"
ensure_docker_dir_ownership "$IOPAINT_DIR"
cd "$IOPAINT_DIR" || return 1
@@ -308,7 +330,7 @@ services:
--port=8080
--host=0.0.0.0
ports:
- "8100:8080"
- "${WEB_PORT}:8080"
env_file: .env
volumes:
- ./models:/root/.cache
@@ -340,7 +362,7 @@ services:
--port=8080
--host=0.0.0.0
ports:
- "8100:8080"
- "${WEB_PORT}:8080"
env_file: .env
volumes:
- ./models:/root/.cache
@@ -467,6 +489,12 @@ docker compose pull && docker compose down && docker compose up -d
- input/, output/ — optional file staging
MD
# The README above is a quoted (non-interpolating) heredoc — dense with
# literal backticks for inline code spans, too risky to convert to an
# interpolating heredoc without escaping every one of them. Patch the
# port in afterward instead when it was scanned away from the default.
[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md"
local START_IO=""
prompt_yn "Start IOPaint now? (y/n):" "y" START_IO
if [ "$START_IO" = "y" ] || [ "$START_IO" = "Y" ]; then
@@ -476,7 +504,7 @@ MD
fi
echo ""
echo " URL: http://localhost:8100"
echo " URL: http://localhost:${WEB_PORT}"
echo " Model: $IOPAINT_MODEL"
echo " Device: $DEVICE_VAL"
echo " Switch: edit MODEL= in $IOPAINT_DIR/.env and restart"
+97 -16
View File
@@ -43,6 +43,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -188,19 +203,70 @@ register_service jellyfin media "Free media server — movies, TV, music (Jellyf
install_jellyfin() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "jellyfin" name/paths/ports exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh (and services/emby.sh,
# the same idea for a sibling media server).
local JELLYFIN_DIR="$DOCKER_DIR/jellyfin"
local INSTANCE_SUFFIX="" CONTAINER="jellyfin"
local WEB_PORT="8096"
local DEFAULT_MEDIA="$ACTUAL_HOME/media"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Jellyfin would:"
echo " - Create $JELLYFIN_DIR with docker-compose.yml + .env (config/ cache/)"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/jellyfin(-<name>) with docker-compose.yml + .env (config/ cache/)"
echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media"
echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists"
echo " - Expose port 8096 (+ DLNA 1900/udp, discovery 7359/udp)"
echo " - Expose port 8096, auto-scanned for additional instances"
echo " - First instance only: DLNA 1900/udp + discovery 7359/udp (host-wide;"
echo " additional instances skip these to avoid a fixed-port conflict)"
echo " - Offer a Caddy reverse proxy and to start the container"
return 0
fi
if [ -d "$JELLYFIN_DIR" ]; then
echo ""
echo " Jellyfin is already installed at $JELLYFIN_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Jellyfin instance alongside it (its own"
echo " server, library, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/jellyfin-$_suffix" ]; then
log_warning "jellyfin-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix"
CONTAINER="jellyfin-$_suffix"
DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix"
log_info "New instance: $JELLYFIN_DIR"
log_warning "DLNA/discovery (1900/udp, 7359/udp) are fixed, host-wide ports already"
log_warning "claimed by the first instance — this instance skips them (web UI and"
log_warning "app-based streaming are unaffected; DLNA auto-discovery is not)."
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port (e.g.
# emby also defaults to 8096) — see CLAUDE.md's "Port collision avoidance"
# section.
find_free_port WEB_PORT "$WEB_PORT"
local MEDIA_PATH=""
prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH
MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}"
@@ -248,14 +314,25 @@ networks:
"
fi
# DLNA (1900/udp) and discovery (7359/udp) are fixed, host-wide UDP ports —
# only the first instance publishes them to avoid a bind conflict with an
# existing instance. Additional instances still work for the web UI and
# every app-based client; only DLNA/network auto-discovery is instance-1-only.
local _DISCOVERY_PORTS=""
if [ -z "$INSTANCE_SUFFIX" ]; then
_DISCOVERY_PORTS=' - "1900:1900/udp"
- "7359:7359/udp"
'
fi
cat > docker-compose.yml << JELLYFIN_COMPOSE
name: jellyfin
name: $CONTAINER
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
hostname: jellyfin
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- TZ=$TZ_VAL
@@ -265,10 +342,8 @@ $HWACCEL_BLOCK
- ./cache:/cache
- \${MEDIA_PATH}:/media:ro
ports:
- "8096:8096"
- "1900:1900/udp"
- "7359:7359/udp"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
- "${WEB_PORT}:8096"
${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
JELLYFIN_COMPOSE
cat > .env << JELLYFIN_ENV
@@ -278,16 +353,22 @@ JELLYFIN_ENV
mkdir -p config cache
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR"
log_success "Jellyfin configured at $JELLYFIN_DIR"
log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JELLYFIN_DIR (port $WEB_PORT)"
configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin"
configure_caddy_for_service "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8096" "jellyfin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$JELLYFIN_DIR" << MD
# Jellyfin
# Jellyfin${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Free media server (movies, TV, music) — a no-paywall alternative to Emby.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own library, own
port) — not a shared library with another Jellyfin instance. DLNA and
network auto-discovery are only published for the first instance (fixed,
host-wide ports); this instance still works for the web UI and every
app-based client.")
- Web UI: http://localhost:8096
- Web UI: http://localhost:${WEB_PORT}
- Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media
- App data: \`config/\` and \`cache/\` in this folder
- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`.
@@ -309,13 +390,13 @@ docker compose pull && docker compose up -d # update
MD
local START_JF=""
prompt_yn "Start Jellyfin now? (y/n):" "y" START_JF
prompt_yn "Start Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JF
if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then
docker compose up -d && log_success "Jellyfin started" || log_warning "Failed to start — check: docker compose logs"
docker compose up -d && log_success "Jellyfin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:8096"
echo " Access at: http://localhost:${WEB_PORT}"
echo ""
}
+83 -16
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -185,14 +200,63 @@ install_joplin() {
require_docker || return 1
log_info "Installing Joplin Server..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "joplin"/"joplin-db" names/paths/port
# exactly as before (zero behavior change for anyone with a single
# instance). Only asking to add a second one introduces suffixed naming —
# same pattern as services/mattermost.sh and services/wordpress.sh.
# Each instance gets its own dedicated Postgres container (not shared) —
# see CLAUDE.md's "Multi-instance services" section for why: Kopia's
# generic backup stops the container to snapshot it, so a shared DB would
# back up/restore every instance's notes as one unit instead of per-instance.
local JOPLIN_DIR="$DOCKER_DIR/joplin"
local INSTANCE_SUFFIX="" CONTAINER="joplin" DB_CONTAINER="joplin-db"
local WEB_PORT="22300"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $JOPLIN_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create \$DOCKER_DIR/joplin(-<name>)"
echo "[DRY-RUN] Would write docker-compose.yml and .env (dedicated Postgres per instance)"
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
return 0
fi
if [ -d "$JOPLIN_DIR" ]; then
echo ""
echo " Joplin Server is already installed at $JOPLIN_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Joplin Server instance alongside it (its own"
echo " server, database, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'work'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/joplin-$_suffix" ]; then
log_warning "joplin-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix"
CONTAINER="joplin-$_suffix"
DB_CONTAINER="joplin-db-$_suffix"
log_info "New instance: $JOPLIN_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$JOPLIN_DIR"
ensure_docker_dir_ownership "$JOPLIN_DIR"
cd "$JOPLIN_DIR" || return 1
@@ -203,7 +267,7 @@ install_joplin() {
local DB_PASS=""
[ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)"
[ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)"
local BASE_URL="https://joplin.${SITE_DOMAIN}"
local BASE_URL="https://joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}"
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
@@ -229,24 +293,24 @@ networks:
fi
cat > docker-compose.yml << JOPLIN_COMPOSE
name: joplin
name: $CONTAINER
services:
joplin:
image: joplin/server:latest
container_name: joplin
hostname: joplin
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
depends_on:
- joplin-db
env_file: .env
ports:
- "22300:22300"
- "${WEB_PORT}:22300"
${_CADDY_NET_BLOCK}
joplin-db:
image: postgres:15-alpine
container_name: joplin-db
hostname: joplin-db
container_name: $DB_CONTAINER
hostname: $DB_CONTAINER
restart: unless-stopped
env_file: .env
volumes:
@@ -278,19 +342,22 @@ JOPLIN_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR"
echo ""
log_success "Joplin Server configured at $JOPLIN_DIR"
log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $JOPLIN_DIR (port $WEB_PORT)"
log_info "APP_BASE_URL set to: $BASE_URL"
log_warning "APP_BASE_URL in .env must match the public URL used by Joplin clients."
configure_caddy_for_service "Joplin" "joplin:22300" "joplin"
configure_caddy_for_service "Joplin${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:22300" "joplin${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$JOPLIN_DIR" << MD
# Joplin Server
# Joplin Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted sync server for the Joplin note-taking app.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own dedicated
database, own port) — not shared notes with another Joplin instance.")
## Access
- URL: http://localhost:22300
- URL: http://localhost:${WEB_PORT}
- Default admin: admin@localhost / admin (change immediately after first login!)
## Important
@@ -319,14 +386,14 @@ docker compose pull && docker compose down && docker compose up -d # update
MD
local START_JOPLIN=""
prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN
prompt_yn "Start Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_JOPLIN
if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then
docker compose up -d 2>/dev/null \
&& log_success "Joplin Server started" \
&& log_success "Joplin Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:22300"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Default login: admin@localhost / admin (change immediately!)"
echo ""
}
+41 -15
View File
@@ -45,6 +45,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
@@ -196,6 +211,17 @@ install_koha() {
require_docker || return 1
local KOHA_DIR="$DOCKER_DIR/koha"
local OPAC_PORT="8097" STAFF_PORT="8098"
# Scan for free host ports, moving both together — a plain install
# shouldn't silently claim a port another already-running service holds.
# Run before any prompts so every message below (including DRY-RUN)
# reflects the real port. See CLAUDE.md's "Port collision avoidance"
# section.
while port_in_use "$OPAC_PORT" || port_in_use "$STAFF_PORT"; do
OPAC_PORT=$((OPAC_PORT + 1))
STAFF_PORT=$((STAFF_PORT + 1))
done
echo ""
echo "╔═══════════════════════════════════════════════════════════════════╗"
@@ -211,7 +237,7 @@ install_koha() {
echo " SETUP OVERVIEW:"
echo " 1) Answer the questions below (collects library details + credentials)"
echo " 2) Koha starts — takes 23 min on first boot"
echo " 3) Open http://localhost:8098 and complete the brief web installer (~2 min)"
echo " 3) Open http://localhost:${STAFF_PORT} and complete the brief web installer (~2 min)"
echo " 4) Run $KOHA_DIR/post-setup.sh to auto-configure library, items, locations"
echo ""
@@ -219,8 +245,8 @@ install_koha() {
echo "[DRY-RUN] Would create $KOHA_DIR with docker-compose.yml + config-main.env"
echo "[DRY-RUN] Would deploy teogramm/koha + MariaDB (./data/) + Memcached + RabbitMQ"
echo "[DRY-RUN] Would generate post-setup.sh for REST API configuration"
echo "[DRY-RUN] OPAC (patron UI): port 8097"
echo "[DRY-RUN] Staff/admin: port 8098"
echo "[DRY-RUN] OPAC (patron UI): port $OPAC_PORT"
echo "[DRY-RUN] Staff/admin: port $STAFF_PORT"
return 0
fi
@@ -399,8 +425,8 @@ services:
env_file:
- config-main.env
ports:
- "8097:8080"
- "8098:8081"
- "${OPAC_PORT}:8080"
- "${STAFF_PORT}:8081"
depends_on:
- koha-db
- koha-memcached
@@ -485,13 +511,13 @@ KOHA_ENV
cat > post-setup.sh << POSTSETUP
#!/bin/bash
# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:8098
# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:${STAFF_PORT}
# Configures library, item types, and shelf locations via the Koha REST API.
# Generated by ubuntu-post-install on $(date '+%F').
set -uo pipefail
KOHA_STAFF_URL="http://localhost:8098"
KOHA_STAFF_URL="http://localhost:${STAFF_PORT}"
KOHA_DIR="${KOHA_DIR}"
CONF="\${KOHA_DIR}/config-main.env"
@@ -627,8 +653,8 @@ echo "════════════════════════
echo ""
echo " Your Koha library is ready to use."
echo ""
echo " OPAC (patron browsing): http://localhost:8097"
echo " Staff / admin: http://localhost:8098"
echo " OPAC (patron browsing): http://localhost:${OPAC_PORT}"
echo " Staff / admin: http://localhost:${STAFF_PORT}"
echo ""
echo " Next steps in Staff UI:"
echo " 1. Administration → Patron categories → add patron types"
@@ -658,8 +684,8 @@ POSTSETUP
Full Integrated Library System for managing a physical book collection.
## Access
- **OPAC** (patron browsing): http://localhost:8097
- **Staff / admin**: http://localhost:8098
- **OPAC** (patron browsing): http://localhost:${OPAC_PORT}
- **Staff / admin**: http://localhost:${STAFF_PORT}
## Quick setup (4 steps)
@@ -671,7 +697,7 @@ docker compose up -d
Takes 23 minutes on first boot while Koha initialises.
### Step 2 — Complete the web installer
1. Open **http://localhost:8098**
1. Open **http://localhost:${STAFF_PORT}**
2. You may see a "Database connection" page first — wait 12 min and refresh
3. The installer wizard appears automatically:
- **Language**: click "Install for language English" → Continue
@@ -743,12 +769,12 @@ MD
echo " └──────────────────────────────────────────────────────────────┘"
echo ""
echo " NEXT STEPS:"
echo " 1. Wait ~3 min, then open: http://localhost:8098"
echo " 1. Wait ~3 min, then open: http://localhost:${STAFF_PORT}"
echo " 2. Complete the web installer (use password above when asked)"
echo " 3. Run: sudo $KOHA_DIR/post-setup.sh"
echo ""
echo " OPAC (patron UI): http://localhost:8097"
echo " Staff / admin: http://localhost:8098"
echo " OPAC (patron UI): http://localhost:${OPAC_PORT}"
echo " Staff / admin: http://localhost:${STAFF_PORT}"
echo ""
}
+142 -21
View File
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -196,19 +211,96 @@ register_service lyrion media "Music streaming server — Squeezebox, Chromecast
install_lyrion() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "lyrion" name/paths/ports and
# network_mode: host exactly as before (zero behavior change for anyone
# with a single instance) — host networking is what makes Chromecast and
# Squeezebox UDP broadcast/multicast discovery work without manual setup.
#
# A second instance CANNOT also use network_mode: host — both would bind
# the same fixed host ports (9000/9090/3483) and collide outright, and
# Docker only allows one container on host networking to own a given port.
# So additional instances switch to bridge networking with auto-scanned,
# per-instance ports instead. The tradeoff: bridge mode means this
# instance loses the zero-config broadcast/multicast auto-discovery that
# host networking provides — Chromecasts and Squeezebox hardware won't
# find it automatically. Players still work, just not auto-discovered:
# point the Squeezer app / Squeezebox firmware at this server's address
# and port manually instead of relying on discovery.
local LYRION_DIR="$DOCKER_DIR/lyrion"
local INSTANCE_SUFFIX="" CONTAINER="lyrion"
local USE_HOST_NETWORK=true
local WEB_PORT="9000" CLI_PORT="9090" PLAYER_PORT="3483"
local DEFAULT_MUSIC="$ACTUAL_HOME/music"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Lyrion Music Server would:"
echo " - Create $LYRION_DIR with docker-compose.yml + .env (config/ playlists/)"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/lyrion(-<name>) with docker-compose.yml + .env (config/ playlists/)"
echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music"
echo " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)"
echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)"
echo " - First instance: network_mode: host (Chromecast/Squeezebox UDP discovery)"
echo " ports 9000 (web), 9090 (CLI), 3483 (players)"
echo " - Additional instances: bridge networking with auto-scanned ports —"
echo " loses zero-config discovery; players need the server address entered manually"
echo " - Offer a Caddy reverse proxy and to start the container"
return 0
fi
if [ -d "$LYRION_DIR" ]; then
echo ""
echo " Lyrion is already installed at $LYRION_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Lyrion instance alongside it (its own"
echo " server, library, and ports — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/lyrion-$_suffix" ]; then
log_warning "lyrion-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
LYRION_DIR="$DOCKER_DIR/lyrion-$_suffix"
CONTAINER="lyrion-$_suffix"
DEFAULT_MUSIC="$ACTUAL_HOME/music-$_suffix"
USE_HOST_NETWORK=false
log_warning "Additional Lyrion instances use bridge networking (not host) so they"
log_warning "don't collide with the first instance's fixed ports. This instance"
log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address"
log_warning "manually in the Squeezer app / Squeezebox firmware instead."
log_info "New instance: $LYRION_DIR"
fi
fi
# Port collision avoidance — see CLAUDE.md's "Port collision avoidance"
# section. Bridge-mode instances can fully auto-scan (all 3 ports move
# together). Host-mode (the first instance) can only auto-adjust
# WEB_PORT — HTTP_PORT is a real env var the image honors even under
# host networking — CLI_PORT/PLAYER_PORT are hardcoded inside the image
# with no override, so a collision there can only be warned about, not
# silently fixed.
if [ "$USE_HOST_NETWORK" = true ]; then
find_free_port WEB_PORT "$WEB_PORT"
port_in_use "$CLI_PORT" && log_warning "CLI port $CLI_PORT is already in use by another process — Lyrion's CLI interface won't be reachable until that's resolved (this port isn't configurable in the image)."
port_in_use "$PLAYER_PORT" && log_warning "Player port $PLAYER_PORT is already in use by another process — Squeezebox/app connections won't work until that's resolved (this port isn't configurable in the image)."
else
while port_in_use "$WEB_PORT" || port_in_use "$CLI_PORT" || port_in_use "$PLAYER_PORT"; do
WEB_PORT=$((WEB_PORT + 1))
CLI_PORT=$((CLI_PORT + 1))
PLAYER_PORT=$((PLAYER_PORT + 1))
done
fi
local MUSIC_PATH=""
prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH
MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}"
@@ -221,18 +313,35 @@ install_lyrion() {
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER")
# Host mode has no ports: remapping — HTTP_PORT is the only thing that
# actually controls the bind port, so it must track the scanned WEB_PORT.
# Bridge mode keeps the container's internal port fixed at 9000 and lets
# the ports: line do the remapping instead.
local _NETWORK_BLOCK=" network_mode: host
"
local _PORTS_BLOCK=""
local _HTTP_PORT_INTERNAL="$WEB_PORT"
if [ "$USE_HOST_NETWORK" != true ]; then
_NETWORK_BLOCK=""
_PORTS_BLOCK=" ports:
- \"${WEB_PORT}:9000\"
- \"${CLI_PORT}:9090\"
- \"${PLAYER_PORT}:3483\"
"
_HTTP_PORT_INTERNAL="9000"
fi
cat > docker-compose.yml << LYRION_COMPOSE
name: lyrion
name: $CONTAINER
services:
lyrion:
image: lmscommunity/lyrionmusicserver:stable
container_name: lyrion
hostname: lyrion
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
network_mode: host
environment:
- HTTP_PORT=9000
${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
- HTTP_PORT=$_HTTP_PORT_INTERNAL
- PUID=$UID_VAL
- PGID=$GID_VAL
- TZ=$TZ_VAL
@@ -251,19 +360,26 @@ LYRION_ENV
mkdir -p config playlists
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR"
log_success "Lyrion Music Server configured at $LYRION_DIR"
log_success "Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $LYRION_DIR (port $WEB_PORT)"
configure_caddy_for_service "Lyrion" "9000" "lyrion"
configure_caddy_for_service "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${WEB_PORT}" "lyrion${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$LYRION_DIR" << MD
# Lyrion Music Server
# Lyrion Music Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast.
Formerly known as Logitech Media Server (LMS).
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own library, own
ports) — not a shared library with another Lyrion instance. It runs on
**bridge networking**, not host networking, so it does NOT get zero-config
Chromecast/Squeezebox discovery — enter this server's address and port
manually in the Squeezer app or Squeezebox firmware instead of relying on
auto-discovery.")
- Web UI: http://localhost:9000
- Player port: 3483 (Squeezeboxes / apps)
- CLI port: 9090
- Web UI: http://localhost:${WEB_PORT}
- Player port: ${PLAYER_PORT} (Squeezeboxes / apps)
- CLI port: ${CLI_PORT}
- Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music
- App data: \`config/\` and \`playlists/\`
@@ -277,21 +393,26 @@ docker compose pull && docker compose up -d # update
\`\`\`
## Notes
- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices
works without manual port mapping.
$( [ "$USE_HOST_NETWORK" = true ] && echo "- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices
works without manual port mapping." || echo "- Uses bridge networking (auto-scanned ports) since the first instance already
owns the fixed host-networking ports. Discovery is manual for this instance." )
- Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`.
- Add music libraries in the web UI under Settings → Music Library.
MD
local START_LMS=""
prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS
prompt_yn "Start Lyrion Music Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_LMS
if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then
docker compose up -d && log_success "Lyrion started" || log_warning "Failed to start — check: docker compose logs"
docker compose up -d && log_success "Lyrion${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:9000"
echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery"
echo " Access at: http://localhost:${WEB_PORT}"
if [ "$USE_HOST_NETWORK" = true ]; then
echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery"
else
echo " Note: uses bridge networking — no auto-discovery, configure players manually"
fi
echo ""
}
+30 -16
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -251,25 +266,24 @@ install_mattermost() {
MM_CONTAINER="mattermost-$_suffix"
DB_CONTAINER="mattermost-$_suffix-db"
COTURN_CONSUMER="mattermost-$_suffix"
# Free-port scan — same pattern services/asterisk.sh uses for its
# web admin port. WEB_PORT is also set as Mattermost's own
# internal ListenAddress below (not just the host publish side),
# so configure_caddy_for_service's single upstream "name:port"
# string works unmodified in both local and remote-Caddy mode —
# it assumes host-published-port == container-internal-port,
# true for every other service in this repo and made true here
# too rather than special-casing the shared helper for one caller.
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
WEB_PORT=$((WEB_PORT + 1))
done
while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do
CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1))
done
log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)"
log_info "New instance: $DIR"
fi
fi
# Free-port scan — runs unconditionally, not just when adding an explicit
# additional instance, so a plain first install also can't collide with
# an unrelated service that already claimed these default ports. Same
# pattern services/asterisk.sh uses for its web admin port. WEB_PORT is
# also set as Mattermost's own internal ListenAddress below (not just the
# host publish side), so configure_caddy_for_service's single upstream
# "name:port" string works unmodified in both local and remote-Caddy mode —
# it assumes host-published-port == container-internal-port, true for
# every other service in this repo and made true here too rather than
# special-casing the shared helper for one caller. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
find_free_port CALLS_UDP_PORT "$CALLS_UDP_PORT" udp
log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..."
if [ "$DRY_RUN" = true ]; then
+22 -5
View File
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -230,14 +245,16 @@ install_mealie() {
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)"
log_info "New instance: $MEALIE_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$MEALIE_DIR"
ensure_docker_dir_ownership "$MEALIE_DIR"
cd "$MEALIE_DIR" || return 1
+83 -17
View File
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -196,17 +211,65 @@ register_service meshcentral utilities "Self-hosted remote device management ser
install_meshcentral() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "meshcentral" name/paths/ports exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh. Two ports (web + agent)
# move together so they stay easy to reason about instance-to-instance.
local MC_DIR="$DOCKER_DIR/meshcentral"
local INSTANCE_SUFFIX="" CONTAINER="meshcentral"
local WEB_PORT="4430" AGENT_PORT="4433"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] MeshCentral would:"
echo " - Create $MC_DIR with docker-compose.yml + .env (data/ files/ backups/)"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/meshcentral(-<name>) with docker-compose.yml + .env (data/ files/ backups/)"
echo " - Prompt for hostname (domain or IP for agent connections)"
echo " - Expose port 4430 (HTTPS web) and 4433 (agent)"
echo " - Expose port 4430 (HTTPS web) and 4433 (agent), auto-scanned for additional instances"
echo " - Offer a Caddy reverse proxy and to start the container"
return 0
fi
if [ -d "$MC_DIR" ]; then
echo ""
echo " MeshCentral is already installed at $MC_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate MeshCentral instance alongside it (its own"
echo " server, devices, and ports — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'clients'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/meshcentral-$_suffix" ]; then
log_warning "meshcentral-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
MC_DIR="$DOCKER_DIR/meshcentral-$_suffix"
CONTAINER="meshcentral-$_suffix"
log_info "New instance: $MC_DIR"
fi
fi
# Scan for free ports unconditionally, moving both together — not just
# when adding an explicit additional instance. A plain first install can
# just as easily collide with an unrelated service that already claimed
# one of these default ports — see CLAUDE.md's "Port collision
# avoidance" section.
while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do
WEB_PORT=$((WEB_PORT + 1))
AGENT_PORT=$((AGENT_PORT + 1))
done
local MC_HOSTNAME=""
prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME
MC_HOSTNAME="${MC_HOSTNAME:-localhost}"
@@ -239,13 +302,13 @@ networks:
fi
cat > docker-compose.yml << MC_COMPOSE
name: meshcentral
name: $CONTAINER
services:
meshcentral:
image: ghcr.io/ylianst/meshcentral:latest
container_name: meshcentral
hostname: meshcentral
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- NODE_ENV=production
@@ -260,8 +323,8 @@ services:
- ./files:/opt/meshcentral/meshcentral-files
- ./backups:/opt/meshcentral/meshcentral-backups
ports:
- "4430:443"
- "4433:4433"
- "${WEB_PORT}:443"
- "${AGENT_PORT}:4433"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
MC_COMPOSE
@@ -274,18 +337,21 @@ MC_ENV
mkdir -p data files backups
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR"
log_success "MeshCentral configured at $MC_DIR"
log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)"
configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh"
configure_caddy_for_service "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:443" "mesh${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$MC_DIR" << MD
# MeshCentral
# MeshCentral${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted remote device management — remotely access, manage, and monitor
all your computers from a single web interface. Install agents on each device.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own devices, own
ports) — not shared devices with another MeshCentral instance.")
- Web UI: https://localhost:4430 (self-signed cert on first launch)
- Agent listener: port 4433 (devices connect here — forward this port if remote)
- Web UI: https://localhost:${WEB_PORT} (self-signed cert on first launch)
- Agent listener: port ${AGENT_PORT} (devices connect here — forward this port if remote)
- Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes)
- App data: \`data/\`, \`files/\`, \`backups/\`
@@ -299,14 +365,14 @@ docker compose pull && docker compose up -d # update
\`\`\`
## First launch
1. Open https://localhost:4430 (accept the self-signed cert warning)
1. Open https://localhost:${WEB_PORT} (accept the self-signed cert warning)
2. Create your admin account
3. Go to "My Devices" → "+ Add Device" → download the agent for each OS
4. Install the agent on every computer you want to manage
## Remote access
For devices outside your LAN to connect:
- Forward **TCP port 4433** on your router to this server
- Forward **TCP port ${AGENT_PORT}** on your router to this server
- Set \`MC_HOSTNAME\` in \`.env\` to your public domain/IP, then restart
## Docs
@@ -314,13 +380,13 @@ https://meshcentral.com/docs/
MD
local START_MC=""
prompt_yn "Start MeshCentral now? (y/n):" "y" START_MC
prompt_yn "Start MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_MC
if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then
docker compose up -d && log_success "MeshCentral started" || log_warning "Failed to start — check: docker compose logs"
docker compose up -d && log_success "MeshCentral${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: https://localhost:4430 (accept self-signed cert)"
echo " Access at: https://localhost:${WEB_PORT} (accept self-signed cert)"
echo " First visit: create your admin account"
echo ""
}
+30 -8
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -186,13 +201,20 @@ install_n8n() {
log_info "Installing n8n..."
local N8N_DIR="$DOCKER_DIR/n8n"
local WEB_PORT="5678"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $N8N_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$N8N_DIR/data"
ensure_docker_dir_ownership "$N8N_DIR"
cd "$N8N_DIR" || return 1
@@ -230,7 +252,7 @@ services:
hostname: n8n
restart: unless-stopped
ports:
- "5678:5678"
- "${WEB_PORT}:5678"
env_file:
- .env
environment:
@@ -260,27 +282,27 @@ N8N_ENV
configure_caddy_for_service "n8n" "n8n:5678" "n8n"
write_readme "$N8N_DIR" << 'MD'
write_readme "$N8N_DIR" << MD
# n8n
Workflow automation platform — connect all your self-hosted services with
a visual editor. Create webhooks, scheduled jobs, and multi-step automations.
## Access
- URL: http://localhost:5678
- URL: http://localhost:${WEB_PORT}
- On first run, n8n prompts you to create an owner account.
## Manage
```bash
\`\`\`bash
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose down && docker compose up -d # update
```
\`\`\`
## Environment
Edit `.env` to change `WEBHOOK_URL` or `N8N_HOST` after deployment,
then restart: `docker compose down && docker compose up -d`
Edit \`.env\` to change \`WEBHOOK_URL\` or \`N8N_HOST\` after deployment,
then restart: \`docker compose down && docker compose up -d\`
MD
local START_N8N=""
@@ -291,7 +313,7 @@ MD
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:5678"
echo " Access at: http://localhost:${WEB_PORT}"
echo " Create your owner account on first visit."
echo ""
}
+26 -3
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
@@ -180,12 +195,20 @@ install_nextcloud() {
require_docker || return 1
log_info "Installing Nextcloud..."
local DIR="$DOCKER_DIR/nextcloud"
local WEB_PORT="8080"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env"
echo "[DRY-RUN] Would auto-scan for a free host port (8080 default)"
return 0
fi
# Scan for a free host port — this default (8080) isn't unique to
# Nextcloud in this repo (arm also defaults to 8080), so a plain install
# shouldn't silently claim a port another already-running service holds.
# See CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
@@ -253,7 +276,7 @@ services:
- ./config:/var/www/html/config
- ./custom_apps:/var/www/html/custom_apps
ports:
- "8080:80"
- "${WEB_PORT}:80"
${_CADDY_NET_BLOCK}
db:
image: mariadb:10.11
@@ -328,7 +351,7 @@ Self-hosted cloud storage with SMB/local file access.
## Access
- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080)
- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT})
- Admin: admin
- Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\`
@@ -366,7 +389,7 @@ Back up these directories:
NCREADME
echo ""
echo " Access URL: http://localhost:8080"
echo " Access URL: http://localhost:${WEB_PORT}"
echo " Admin user: admin"
echo " Admin pass: $NC_ADMIN_PASS"
echo " Config dir: $DIR"
+81 -17
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -192,13 +207,59 @@ register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090
install_ntfy() {
require_docker || return 1
log_info "Installing ntfy..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "ntfy" name/paths/port exactly as before
# (zero behavior change for anyone with a single instance — including
# crowdsec.sh/pstn-trunk.sh, which curl the default port directly). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh.
local NTFY_DIR="$DOCKER_DIR/ntfy"
local INSTANCE_SUFFIX="" CONTAINER="ntfy"
local WEB_PORT="8090"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $NTFY_DIR"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $NTFY_DIR(-<name>)"
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
return 0
fi
if [ -d "$NTFY_DIR" ]; then
echo ""
echo " ntfy is already installed at $NTFY_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate ntfy instance alongside it (its own"
echo " server, topics, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'alerts'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/ntfy-$_suffix" ]; then
log_warning "ntfy-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix"
CONTAINER="ntfy-$_suffix"
log_info "New instance: $NTFY_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$NTFY_DIR"
ensure_docker_dir_ownership "$NTFY_DIR"
cd "$NTFY_DIR" || return 1
@@ -227,13 +288,13 @@ networks:
fi
cat > docker-compose.yml << NTFY_COMPOSE
name: ntfy
name: $CONTAINER
services:
ntfy:
image: binwiederhier/ntfy:latest
container_name: ntfy
hostname: ntfy
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
command: serve
environment:
@@ -242,7 +303,7 @@ services:
- ./cache:/var/cache/ntfy
- ./config:/etc/ntfy
ports:
- "8090:80"
- "${WEB_PORT}:80"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
NTFY_COMPOSE
@@ -258,7 +319,7 @@ NTFY_ENV
if [ ! -f config/server.yml ]; then
local NTFY_BASE_URL=""
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}"
NTFY_BASE_URL="https://ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}"
fi
cat > config/server.yml << NTFY_CFG
# ntfy server configuration — https://docs.ntfy.sh/config/
@@ -291,22 +352,25 @@ NTFY_CFG
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR"
echo ""
log_success "ntfy configured at $NTFY_DIR"
log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $NTFY_DIR (port $WEB_PORT)"
configure_caddy_for_service "ntfy" "ntfy:80" "ntfy"
configure_caddy_for_service "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$NTFY_DIR" << MD
# ntfy
# ntfy${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted push notification server. Send notifications from scripts to your
phone or browser.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own topics, own
port) — not shared topics with another ntfy instance.")
## Access
- URL: http://localhost:8090
- URL: http://localhost:${WEB_PORT}
## Usage
- Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\`
- Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic
- Send a notification: \`curl -d "Hello!" localhost:${WEB_PORT}/mytopic\`
- Subscribe on phone: ntfy app -> Add subscription -> localhost:${WEB_PORT}/mytopic
## Access model
\`auth-default-access: read-write\` — anyone who knows a topic name can read
@@ -335,15 +399,15 @@ docker compose logs -f # logs
MD
local START_NTFY=""
prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY
prompt_yn "Start ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_NTFY
if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then
docker compose up -d 2>/dev/null && log_success "ntfy started" || log_warning "Failed to start"
docker compose up -d 2>/dev/null && log_success "ntfy${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start"
fi
echo " Access at: http://localhost:8090"
echo " Access at: http://localhost:${WEB_PORT}"
echo ""
echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic"
echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic"
echo " Send notification: curl -d \"Hello!\" localhost:${WEB_PORT}/mytopic"
echo " Subscribe on phone: ntfy app → Add subscription → localhost:${WEB_PORT}/mytopic"
echo ""
}
+25 -3
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
@@ -226,12 +241,19 @@ install_onlyoffice() {
log_info "Installing OnlyOffice Document Server..."
local DIR="$DOCKER_DIR/onlyoffice"
local WEB_PORT="8082"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env"
echo "[DRY-RUN] Would auto-scan for a free host port (8082 default)"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
@@ -281,7 +303,7 @@ services:
- ./data:/var/www/onlyoffice/Data
- ./fonts:/usr/share/fonts/truetype/custom
ports:
- "8082:80"
- "${WEB_PORT}:80"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
OOCOMPOSE
@@ -333,7 +355,7 @@ Self-hosted document editing server, integrated with Nextcloud and FileBrowser Q
## Access
- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082)
- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT})
- The document server itself has no user-facing login page — it is accessed
through Nextcloud or FileBrowser Quantum.
@@ -381,7 +403,7 @@ OOREAD
echo ""
echo " OnlyOffice Document Server"
echo " Access URL: http://localhost:8082"
echo " Access URL: http://localhost:${WEB_PORT}"
echo " JWT secret: $JWT_SECRET"
echo " Config dir: $DIR"
echo ""
+31 -6
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -184,12 +199,22 @@ install_portainer() {
require_docker || return 1
log_info "Installing Portainer..."
local PORTAINER_DIR="$DOCKER_DIR/portainer"
local HTTP_PORT="9000" HTTPS_PORT="9443"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $PORTAINER_DIR"
echo "[DRY-RUN] Would auto-scan for free host ports (9000/9443 defaults)"
return 0
fi
# Scan for free host ports, moving both together — a plain install
# shouldn't silently claim a port another already-running service holds.
# See CLAUDE.md's "Port collision avoidance" section.
while port_in_use "$HTTP_PORT" || port_in_use "$HTTPS_PORT"; do
HTTP_PORT=$((HTTP_PORT + 1))
HTTPS_PORT=$((HTTPS_PORT + 1))
done
mkdir -p "$PORTAINER_DIR"
ensure_docker_dir_ownership "$PORTAINER_DIR"
cd "$PORTAINER_DIR" || return 1
@@ -230,8 +255,8 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
ports:
- "9000:9000"
- "9443:9443"
- "${HTTP_PORT}:9000"
- "${HTTPS_PORT}:9443"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
PORTAINER_COMPOSE
@@ -239,7 +264,7 @@ PORTAINER_COMPOSE
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR"
echo ""
log_success "Portainer configured at $PORTAINER_DIR"
log_success "Portainer configured at $PORTAINER_DIR (HTTP $HTTP_PORT, HTTPS $HTTPS_PORT)"
configure_caddy_for_service "Portainer" "portainer:9000" "portainer"
@@ -249,8 +274,8 @@ PORTAINER_COMPOSE
Web UI for managing Docker — containers, images, volumes, and networks.
## Access
- HTTPS: https://localhost:9443
- HTTP: http://localhost:9000
- HTTPS: https://localhost:${HTTPS_PORT}
- HTTP: http://localhost:${HTTP_PORT}
- Create your admin account on first visit.
## Data
@@ -272,7 +297,7 @@ MD
docker compose up -d 2>/dev/null && log_success "Portainer started" || log_warning "Failed to start"
fi
echo " Access at: https://localhost:9443"
echo " Access at: https://localhost:${HTTPS_PORT}"
echo " Create admin account on first visit"
echo ""
}
+103 -26
View File
@@ -56,6 +56,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -100,21 +115,80 @@ register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk)
install_rustdesk() {
require_docker || return 1
log_info "Installing RustDesk server..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "rustdesk" name/paths/ports exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh. The 6 ports
# (21115-21119 TCP + 21116 UDP) are fixed *inside* the container — the
# image doesn't expose an env var to change them — so an additional
# instance shifts the whole host-side block by a fixed offset instead of
# scanning port-by-port, the same approach services/traccar.sh uses for
# its device-protocol range.
local RD_DIR="$DOCKER_DIR/rustdesk"
local INSTANCE_SUFFIX="" CONTAINER="rustdesk"
local PORT_OFFSET=0
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $RD_DIR (rustdesk_data/)"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create \$DOCKER_DIR/rustdesk(-<name>) (rustdesk_data/)"
echo "[DRY-RUN] Would deploy rustdesk/rustdesk-server-s6:latest"
echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP"
echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP — whole block shifted for additional instances"
echo "[DRY-RUN] Would prompt for server FQDN/IP (RELAY env var)"
return 0
fi
if [ -d "$RD_DIR" ]; then
echo ""
echo " RustDesk is already installed at $RD_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate RustDesk relay instance alongside it (its own"
echo " server and ports — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'work'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/rustdesk-$_suffix" ]; then
log_warning "rustdesk-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
RD_DIR="$DOCKER_DIR/rustdesk-$_suffix"
CONTAINER="rustdesk-$_suffix"
log_info "New instance: $RD_DIR"
fi
fi
# Scan for a free port block unconditionally, shifting the whole 6-port
# block together — not just when adding an explicit additional instance.
# A plain first install can just as easily collide with an unrelated
# service already bound to one of these default ports — see CLAUDE.md's
# "Port collision avoidance" section.
while port_in_use "$((21115 + PORT_OFFSET))" \
|| port_in_use "$((21116 + PORT_OFFSET))" \
|| port_in_use "$((21116 + PORT_OFFSET))" udp \
|| port_in_use "$((21117 + PORT_OFFSET))" \
|| port_in_use "$((21118 + PORT_OFFSET))" \
|| port_in_use "$((21119 + PORT_OFFSET))"; do
PORT_OFFSET=$((PORT_OFFSET + 10))
done
mkdir -p "$RD_DIR/rustdesk_data"
ensure_docker_dir_ownership "$RD_DIR"
cd "$RD_DIR" || return 1
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
local P_NAT=$((21115 + PORT_OFFSET)) P_ID=$((21116 + PORT_OFFSET)) \
P_RELAY=$((21117 + PORT_OFFSET)) P_WS=$((21118 + PORT_OFFSET)) P_WSS=$((21119 + PORT_OFFSET))
echo ""
echo " RustDesk needs to know its own public hostname or IP."
@@ -134,23 +208,23 @@ install_rustdesk() {
prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
cat > docker-compose.yml << 'RD_COMPOSE'
name: rustdesk
cat > docker-compose.yml << RD_COMPOSE
name: $CONTAINER
services:
rustdesk:
image: rustdesk/rustdesk-server-s6:latest
container_name: rustdesk
hostname: rustdesk
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
ports:
- "21115:21115"
- "21116:21116"
- "21116:21116/udp"
- "21117:21117"
- "21118:21118"
- "21119:21119"
- "${P_NAT}:21115"
- "${P_ID}:21116"
- "${P_ID}:21116/udp"
- "${P_RELAY}:21117"
- "${P_WS}:21118"
- "${P_WSS}:21119"
volumes:
- ./rustdesk_data:/data
RD_COMPOSE
@@ -161,8 +235,8 @@ TZ=$TZ_VAL
# ── RustDesk server ───────────────────────────────────────────────────────────
# RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR).
# Include the port if it's non-standard: hostname:21117
RELAY=$RELAY_HOST:21117
# Include the port if it's non-standard: hostname:$P_RELAY
RELAY=$RELAY_HOST:$P_RELAY
# ENCRYPTED_ONLY: 1 = only clients with the matching public key can connect.
# After first startup, copy the key from ./rustdesk_data/id_ed25519.pub to
@@ -177,13 +251,16 @@ RD_ENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$RD_DIR"
log_success "RustDesk configured at $RD_DIR"
log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $RD_DIR (ports $P_NAT-$P_WSS)"
write_readme "$RD_DIR" << MD
# RustDesk — self-hosted remote desktop relay
# RustDesk${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — self-hosted remote desktop relay
Open-source TeamViewer alternative. This is the server-side relay/rendezvous
daemon. Clients use the RustDesk desktop/mobile app to connect.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own key, own port
block) — not shared clients with another RustDesk instance.")
## After starting: get the public key
@@ -193,8 +270,8 @@ cat $RD_DIR/rustdesk_data/id_ed25519.pub
Paste this key into each client:
**Settings → Network → ID/Relay Server**
- ID Server: $RELAY_HOST
- Relay Server: $RELAY_HOST
- ID Server: $RELAY_HOST:$P_ID
- Relay Server: $RELAY_HOST:$P_RELAY
- Key: <paste id_ed25519.pub contents>
## Firewall / router rules required
@@ -202,11 +279,11 @@ Paste this key into each client:
Open these ports to this server's IP:
| Port | Protocol | Purpose |
|------|----------|---------|
| 21115 | TCP | NAT type test |
| 21116 | TCP+UDP | ID register / hole-punching |
| 21117 | TCP | Relay traffic |
| 21118 | TCP | WebSocket |
| 21119 | TCP | WebSocket HTTPS |
| $P_NAT | TCP | NAT type test |
| $P_ID | TCP+UDP | ID register / hole-punching |
| $P_RELAY | TCP | Relay traffic |
| $P_WS | TCP | WebSocket |
| $P_WSS | TCP | WebSocket HTTPS |
## Cross-VLAN setup
Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN
@@ -224,10 +301,10 @@ docker compose pull && docker compose up -d # update
MD
local START_RD=""
prompt_yn "Start RustDesk server now? (y/n):" "y" START_RD
prompt_yn "Start RustDesk server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_RD
if [ "$START_RD" = "y" ] || [ "$START_RD" = "Y" ]; then
docker compose up -d \
&& log_success "RustDesk started" \
&& log_success "RustDesk${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|| log_warning "Failed to start — check: docker compose logs"
echo ""
echo " After startup, get the public key:"
@@ -237,7 +314,7 @@ MD
echo ""
echo " Relay host: $RELAY_HOST"
echo " Ports 21115-21119 must be open in your firewall/router."
echo " Ports $P_NAT-$P_WSS must be open in your firewall/router."
echo ""
}
+25 -3
View File
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -186,13 +201,20 @@ install_stirling-pdf() {
log_info "Installing Stirling PDF..."
local PDF_DIR="$DOCKER_DIR/stirling-pdf"
local WEB_PORT="8070"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $PDF_DIR"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$PDF_DIR"
ensure_docker_dir_ownership "$PDF_DIR"
cd "$PDF_DIR" || return 1
@@ -231,7 +253,7 @@ services:
restart: unless-stopped
env_file: .env
ports:
- "8070:8080"
- "${WEB_PORT}:8080"
volumes:
- ./training-data:/usr/share/tessdata
- ./extraConfigs:/configs
@@ -275,7 +297,7 @@ PDF_ENV
Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more.
## Access
- URL: http://localhost:8070
- URL: http://localhost:${WEB_PORT}
- No login required by default (security disabled)
## Enabling built-in auth
@@ -314,7 +336,7 @@ MD
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:8070"
echo " Access at: http://localhost:${WEB_PORT}"
echo ""
}
+44 -9
View File
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -157,13 +172,28 @@ install_syncthing() {
log_info "Installing Syncthing..."
local DIR="$DOCKER_DIR/syncthing"
local WEB_PORT="8384" SYNC_PORT="22000" DISCOVERY_PORT="21027"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env"
echo "[DRY-RUN] Would expose web UI on 8384, sync protocol on 22000 (tcp+udp), discovery on 21027/udp"
echo "[DRY-RUN] All 3 auto-scanned/shifted together if occupied"
return 0
fi
# Scan for free host ports, moving all 3 together — a plain install
# shouldn't silently claim a port another already-running service holds.
# Note: DISCOVERY_PORT (LAN broadcast) only works for zero-config local
# discovery at its standard number — shifting it away from 21027 means
# peers must be added manually by device ID instead of relying on
# auto-discovery. See CLAUDE.md's "Port collision avoidance" section.
while port_in_use "$WEB_PORT" || port_in_use "$SYNC_PORT" \
|| port_in_use "$SYNC_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do
WEB_PORT=$((WEB_PORT + 1))
SYNC_PORT=$((SYNC_PORT + 1))
DISCOVERY_PORT=$((DISCOVERY_PORT + 1))
done
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
@@ -209,10 +239,10 @@ services:
- PGID=\${PGID:-$PGID}
- TZ=\${SITE_TZ:-UTC}
ports:
- "8384:8384"
- "22000:22000/tcp"
- "22000:22000/udp"
- "21027:21027/udp"
- "${WEB_PORT}:8384"
- "${SYNC_PORT}:22000/tcp"
- "${SYNC_PORT}:22000/udp"
- "${DISCOVERY_PORT}:21027/udp"
volumes:
- ./config:/var/syncthing/config
- ./data:/var/syncthing
@@ -237,15 +267,20 @@ ENV
Continuous, decentralised file synchronisation between devices.
## Access
- Web UI: http://localhost:8384
- Web UI: http://localhost:${WEB_PORT}
- First run: go to **Settings → GUI** and set a username and password.
$( [ "$DISCOVERY_PORT" != "21027" ] && echo "
**Note:** the default ports were already in use, so this instance's ports
were shifted — see below. Local LAN auto-discovery only works at the
standard 21027/udp; with a shifted discovery port, add other devices
manually by device ID instead of relying on auto-discovery." )
## Firewall ports (for LAN sync)
Open these on the host firewall so other Syncthing devices can reach this node:
\`\`\`
sudo ufw allow 22000/tcp comment "Syncthing sync protocol"
sudo ufw allow 22000/udp comment "Syncthing sync protocol (QUIC)"
sudo ufw allow 21027/udp comment "Syncthing local discovery"
sudo ufw allow ${SYNC_PORT}/tcp comment "Syncthing sync protocol"
sudo ufw allow ${SYNC_PORT}/udp comment "Syncthing sync protocol (QUIC)"
sudo ufw allow ${DISCOVERY_PORT}/udp comment "Syncthing local discovery"
\`\`\`
## Manage
@@ -266,7 +301,7 @@ MD
|| log_warning "Start failed — check: docker compose logs"
fi
echo " Access at: http://localhost:8384"
echo " Access at: http://localhost:${WEB_PORT}"
echo " First run: set a username and password in Settings → GUI"
echo ""
}
+24 -5
View File
@@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
@@ -252,10 +267,6 @@ install_traccar() {
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).
@@ -265,10 +276,18 @@ install_traccar() {
PROTO_MIN=$((5000 + _offset))
PROTO_MAX=$((5150 + _offset))
log_info "New instance: $TRACCAR_DIR (web port $WEB_PORT, device protocols $PROTO_MIN-$PROTO_MAX)"
log_info "New instance: $TRACCAR_DIR (device protocols $PROTO_MIN-$PROTO_MAX)"
fi
fi
# Scan for a free web port unconditionally — not just when adding an
# explicit additional instance. A plain first install can just as easily
# collide with an unrelated service that already claimed this default
# port — see CLAUDE.md's "Port collision avoidance" section. The
# PROTO_MIN/PROTO_MAX device-protocol range above is a different,
# directory-count-based mechanism (not an ss scan) and is unaffected.
find_free_port WEB_PORT "$WEB_PORT"
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
+108 -27
View File
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
@@ -98,16 +113,75 @@ register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443
install_unifi() {
require_docker || return 1
log_info "Installing UniFi Network Application..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "unifi-db"/"unifi-app" names/paths/ports
# exactly as before (zero behavior change for anyone with a single
# instance). Only asking to add a second one introduces suffixed naming —
# same pattern as services/mattermost.sh and services/wordpress.sh.
# Each instance gets its own dedicated MongoDB container (not shared) —
# see CLAUDE.md's "Multi-instance services" section for why: Kopia's
# generic backup stops the container to snapshot it, so a shared DB would
# back up/restore every instance's site data as one unit instead of
# per-instance. All 4 published ports move together per instance.
local UNIFI_DIR="$DOCKER_DIR/unifi"
local INSTANCE_SUFFIX="" PROJECT="unifi" DB_CONTAINER="unifi-db" APP_CONTAINER="unifi-app"
local WEB_PORT="8443" INFORM_PORT="8080" STUN_PORT="3478" DISCOVERY_PORT="10001"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $UNIFI_DIR (mongo_db_data/, unifi_data/)"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create \$DOCKER_DIR/unifi(-<name>) (mongo_db_data/, unifi_data/)"
echo "[DRY-RUN] Would deploy mongo:4 + linuxserver/unifi-network-application:latest"
echo "[DRY-RUN] Ports: 8443 (HTTPS web UI), 8080 (device inform), 3478/udp (STUN), 10001/udp (discovery)"
echo "[DRY-RUN] — all 4 auto-scanned/shifted together for additional instances"
echo "[DRY-RUN] Would generate MongoDB credentials"
return 0
fi
if [ -d "$UNIFI_DIR" ]; then
echo ""
echo " UniFi is already installed at $UNIFI_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate UniFi controller instance alongside it (its own"
echo " database, sites, and ports — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'guest-site'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/unifi-$_suffix" ]; then
log_warning "unifi-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
UNIFI_DIR="$DOCKER_DIR/unifi-$_suffix"
PROJECT="unifi-$_suffix"
DB_CONTAINER="unifi-db-$_suffix"
APP_CONTAINER="unifi-app-$_suffix"
log_info "New instance: $UNIFI_DIR"
fi
fi
# Scan for free ports unconditionally, moving all 4 together — not just
# when adding an explicit additional instance. A plain first install can
# just as easily collide with an unrelated service that already claimed
# one of these default ports — see CLAUDE.md's "Port collision
# avoidance" section.
while port_in_use "$WEB_PORT" || port_in_use "$INFORM_PORT" \
|| port_in_use "$STUN_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do
WEB_PORT=$((WEB_PORT + 1))
INFORM_PORT=$((INFORM_PORT + 1))
STUN_PORT=$((STUN_PORT + 1))
DISCOVERY_PORT=$((DISCOVERY_PORT + 1))
done
mkdir -p "$UNIFI_DIR"
ensure_docker_dir_ownership "$UNIFI_DIR"
cd "$UNIFI_DIR" || return 1
@@ -143,13 +217,13 @@ networks:
# Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$
cat > docker-compose.yml << UNIFI_COMPOSE
name: unifi
name: $PROJECT
services:
unifi-db:
image: mongo:4
container_name: unifi-db
hostname: unifi-db
container_name: $DB_CONTAINER
hostname: $DB_CONTAINER
restart: unless-stopped
env_file: .env
volumes:
@@ -162,8 +236,8 @@ services:
unifi-app:
image: lscr.io/linuxserver/unifi-network-application:latest
container_name: unifi-app
hostname: unifi-app
container_name: $APP_CONTAINER
hostname: $APP_CONTAINER
restart: unless-stopped
env_file: .env
depends_on:
@@ -171,10 +245,10 @@ services:
volumes:
- ./unifi_data:/config
ports:
- "8443:8443"
- "8080:8080"
- "3478:3478/udp"
- "10001:10001/udp"
- "${WEB_PORT}:8443"
- "${INFORM_PORT}:8080"
- "${STUN_PORT}:3478/udp"
- "${DISCOVERY_PORT}:10001/udp"
# Optional — uncomment as needed:
# - "1900:1900/udp" # L2 discovery (may conflict with UPnP)
# - "8843:8843" # guest portal HTTPS
@@ -216,7 +290,7 @@ UNIFI_ENV
mkdir -p mongo_db_data unifi_data
ensure_docker_dir_ownership "$UNIFI_DIR"
log_success "UniFi configured at $UNIFI_DIR"
log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $UNIFI_DIR (web port $WEB_PORT)"
# ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ──
local _caddy_mode="none"
@@ -232,15 +306,18 @@ UNIFI_ENV
fi
echo ""
local CADDY_UNIFI=""
prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI
prompt_yn "Configure Caddy reverse proxy for UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}? (y/n):" "n" CADDY_UNIFI
if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then
local UNIFI_DOMAIN=""
local _def_domain="unifi.${SITE_DOMAIN:-example.com}"
local _def_domain="unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}"
prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN
if [ -n "$UNIFI_DOMAIN" ]; then
# UniFi uses HTTPS internally — upstream must use https:// + skip verify
local _upstream="https://unifi-app:8443"
[ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443"
# UniFi uses HTTPS internally — upstream must use https:// + skip verify.
# Local mode reaches the container over Docker networking (internal port
# never changes); remote mode reaches this host's published port, which
# is WEB_PORT (auto-scanned for additional instances).
local _upstream="https://${APP_CONTAINER}:8443"
[ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:${WEB_PORT}"
local _site_block
_site_block="$(cat << CBLOCK
@@ -277,7 +354,7 @@ CBLOCK
|| log_warning "Caddy reload failed — check: docker logs caddy"
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/unifi.caddy"
local _snippet_file="$_snippet_dir/unifi${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
@@ -290,25 +367,29 @@ CBLOCK
fi
write_readme "$UNIFI_DIR" << MD
# UniFi Network Application
# UniFi Network Application${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Ubiquiti network controller. Manages UniFi APs, switches, and gateways.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own dedicated database, own
sites/devices, own ports) — not shared adoption with another UniFi instance.
Devices can only be adopted by one controller at a time.")
## Access
- Web UI: **https://localhost:8443** (HTTPS, self-signed cert — accept the warning)
- Web UI: **https://localhost:${WEB_PORT}** (HTTPS, self-signed cert — accept the warning)
- First run: complete the setup wizard and adopt your devices.
## Device adoption
Make sure devices can reach **http://<server-ip>:8080/inform** as the inform URL.
Make sure devices can reach **http://<server-ip>:${INFORM_PORT}/inform** as the inform URL.
In the controller: Settings → System → Application Configuration → Override inform host.
## Ports
| Port | Protocol | Purpose |
|------|----------|---------|
| 8443 | TCP | HTTPS web UI |
| 8080 | TCP | Device inform / HTTP redirect |
| 3478 | UDP | STUN |
| 10001 | UDP | AP discovery |
| ${WEB_PORT} | TCP | HTTPS web UI |
| ${INFORM_PORT} | TCP | Device inform / HTTP redirect |
| ${STUN_PORT} | UDP | STUN |
| ${DISCOVERY_PORT} | UDP | AP discovery |
## Manage
\`\`\`bash
@@ -327,15 +408,15 @@ docker compose pull && docker compose up -d # update (wait for DB first)
MD
local START_UNIFI=""
prompt_yn "Start UniFi now? (y/n):" "y" START_UNIFI
prompt_yn "Start UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_UNIFI
if [ "$START_UNIFI" = "y" ] || [ "$START_UNIFI" = "Y" ]; then
docker compose up -d \
&& log_success "UniFi started (first startup takes ~60 s while DB initializes)" \
&& log_success "UniFi${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started (first startup takes ~60 s while DB initializes)" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Web UI: https://localhost:8443 (accept the self-signed cert warning)"
echo " Web UI: https://localhost:${WEB_PORT} (accept the self-signed cert warning)"
echo " MongoDB credentials saved to: $UNIFI_DIR/.env"
echo ""
}
+25 -3
View File
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
@@ -186,12 +201,19 @@ install_uptimekuma() {
require_docker || return 1
log_info "Installing Uptime Kuma..."
local UPTIME_DIR="$DOCKER_DIR/uptime-kuma"
local WEB_PORT="3001"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $UPTIME_DIR"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
# Scan for a free host port — a plain install shouldn't silently claim a
# port another already-running service holds. See CLAUDE.md's "Port
# collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$UPTIME_DIR"
ensure_docker_dir_ownership "$UPTIME_DIR"
cd "$UPTIME_DIR" || return 1
@@ -232,7 +254,7 @@ services:
- ./data:/app/data
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "3001:3001"
- "${WEB_PORT}:3001"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
UPTIME_COMPOSE
@@ -249,7 +271,7 @@ Self-hosted uptime/status monitoring dashboard. Monitor websites, servers, and
Docker containers.
## Access
- URL: http://localhost:3001
- URL: http://localhost:${WEB_PORT}
- Create your admin account on first visit.
## Data
@@ -278,7 +300,7 @@ MD
docker compose up -d 2>/dev/null && log_success "Uptime Kuma started" || log_warning "Failed to start"
fi
echo " Access at: http://localhost:3001"
echo " Access at: http://localhost:${WEB_PORT}"
echo ""
}
+76 -11
View File
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
@@ -202,16 +217,63 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V
install_vaultwarden() {
require_docker || return 1
log_info "Installing Vaultwarden..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "vaultwarden" name/paths/port exactly as
# before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/mattermost.sh and services/wordpress.sh. A real use case:
# separate personal and family/household vaults with independent admin
# tokens, domains, and SMTP.
local VW_DIR="$DOCKER_DIR/vaultwarden"
local INSTANCE_SUFFIX="" CONTAINER="vaultwarden"
local WEB_PORT="8888"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $VW_DIR (vaultwarden_data/)"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $VW_DIR(-<name>) (vaultwarden_data/)"
echo "[DRY-RUN] Would deploy vaultwarden/server:latest"
echo "[DRY-RUN] Would generate admin token and prompt for domain"
echo "[DRY-RUN] Would auto-scan for a free host port if this is an additional instance"
echo "[DRY-RUN] Signups disabled by default (enable via admin panel)"
return 0
fi
if [ -d "$VW_DIR" ]; then
echo ""
echo " Vaultwarden is already installed at $VW_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Vaultwarden instance alongside it (its own"
echo " server, vault, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/vaultwarden-$_suffix" ]; then
log_warning "vaultwarden-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix"
CONTAINER="vaultwarden-$_suffix"
log_info "New instance: $VW_DIR"
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$VW_DIR/vaultwarden_data"
ensure_docker_dir_ownership "$VW_DIR"
cd "$VW_DIR" || return 1
@@ -225,7 +287,7 @@ install_vaultwarden() {
echo " can connect and password-reset emails link correctly."
echo ""
local VW_DOMAIN=""
local DEFAULT_DOMAIN="https://vault.${SITE_DOMAIN:-example.com}"
local DEFAULT_DOMAIN="https://vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN:-example.com}"
prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN
[ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN"
@@ -266,19 +328,19 @@ networks:
fi
cat > docker-compose.yml << VW_COMPOSE
name: vaultwarden
name: $CONTAINER
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
hostname: vaultwarden
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
volumes:
- ./vaultwarden_data:/data
ports:
- "8888:80"
- "${WEB_PORT}:80"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
VW_COMPOSE
@@ -311,15 +373,18 @@ VW_ENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$VW_DIR"
log_success "Vaultwarden configured at $VW_DIR"
log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $VW_DIR (port $WEB_PORT)"
configure_caddy_for_service "Vaultwarden" "vaultwarden:80" "vault"
configure_caddy_for_service "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "vault${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
write_readme "$VW_DIR" << MD
# Vaultwarden — Bitwarden-compatible password manager
# Vaultwarden${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — Bitwarden-compatible password manager
Lightweight, self-hosted Bitwarden server. Works with all official
Bitwarden clients: browser extension, desktop app, and mobile app.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own vault, own
port) — not shared credentials with another Vaultwarden instance.")
## Setup
1. Point your Bitwarden client to: $VW_DOMAIN
@@ -353,10 +418,10 @@ docker compose pull && docker compose up -d # update
MD
local START_VW=""
prompt_yn "Start Vaultwarden now? (y/n):" "y" START_VW
prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW
if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then
docker compose up -d \
&& log_success "Vaultwarden started" \
&& log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
+37 -10
View File
@@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
@@ -198,6 +213,7 @@ install_wg-easy() {
require_docker || return 1
local WGEASY_DIR="$DOCKER_DIR/wg-easy"
local WEB_PORT="51821" VPN_PORT="51820"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] wg-easy would:"
@@ -205,14 +221,24 @@ install_wg-easy() {
echo " - Auto-detect public IP for WG_HOST"
echo " - Generate a random web UI password"
echo " - Pin WG_DEFAULT_ADDRESS=10.8.0.x (subnet 10.8.0.0/24)"
echo " - Expose port 51821 (web UI) + 51820/udp (VPN)"
echo " - Require router port-forward: UDP 51820 → this server"
echo " - Expose port 51821 (web UI) + 51820/udp (VPN), both auto-scanned if occupied"
echo " - Require router port-forward: UDP <VPN port> → this server"
echo " - Offer a Caddy reverse proxy and to start the container"
echo " - Offer to also allow SSH from the VPN subnet (additive, doesn't remove public SSH)"
echo " - Write sync-ssh-aliases.sh — generates ~/.ssh/config aliases for connected peers"
return 0
fi
# Scan for free host ports, moving both together — a plain install
# shouldn't silently claim a port another already-running service holds.
# Whatever VPN_PORT ends up as is what needs forwarding on the router
# (the messaging below reflects the final value, not the 51820 default).
# See CLAUDE.md's "Port collision avoidance" section.
while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do
WEB_PORT=$((WEB_PORT + 1))
VPN_PORT=$((VPN_PORT + 1))
done
mkdir -p "$WGEASY_DIR"
ensure_docker_dir_ownership "$WGEASY_DIR"
cd "$WGEASY_DIR" || return 1
@@ -287,11 +313,12 @@ services:
- WG_DEFAULT_DNS=1.1.1.1
- WG_DEFAULT_ADDRESS=${WG_DEFAULT_ADDRESS}
- WG_ALLOWED_IPS=0.0.0.0/0, ::/0
- WG_PORT=${VPN_PORT}
volumes:
- ./config:/etc/wireguard
ports:
- "51820:51820/udp"
- "51821:51821/tcp"
- "${VPN_PORT}:${VPN_PORT}/udp"
- "${WEB_PORT}:51821/tcp"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
WGEASY_COMPOSE
@@ -416,8 +443,8 @@ SYNCEOF
WireGuard VPN with a web UI for managing clients, generating QR codes,
and monitoring connections.
- Web UI: http://localhost:51821
- VPN: UDP port 51820 (forward this on your router)
- Web UI: http://localhost:${WEB_PORT}
- VPN: UDP port ${VPN_PORT} (forward this on your router)
- Password: stored in \`.env\` (\`WG_PASSWORD\`)
- VPN host: \`$WG_HOST\` (update \`WG_HOST\` in .env if your IP changes)
- VPN subnet: \`$WG_SUBNET_CIDR\`
@@ -433,10 +460,10 @@ docker compose pull && docker compose up -d # update
\`\`\`
## Router setup
Forward **UDP port 51820** to this server's LAN IP for external VPN access.
Forward **UDP port ${VPN_PORT}** to this server's LAN IP for external VPN access.
## Adding clients
Open http://localhost:51821, log in with your password, click "+ New Client",
Open http://localhost:${WEB_PORT}, log in with your password, click "+ New Client",
download or scan the QR code with the WireGuard app.
## Mesh — peers reach each other automatically
@@ -471,11 +498,11 @@ MD
fi
echo ""
echo " Web UI: http://localhost:51821"
echo " Web UI: http://localhost:${WEB_PORT}"
echo " Password: $WG_PASSWORD (saved in .env)"
[[ -n "$WG_PASSWORD_HASH" ]] && echo " Auth: bcrypt hash configured (v14+ compatible)" \
|| echo " Auth: WARNING — bcrypt hash generation failed; see README"
echo " Router: forward UDP 51820 → this server for external VPN access"
echo " Router: forward UDP ${VPN_PORT} → this server for external VPN access"
echo ""
}
+15
View File
@@ -57,6 +57,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"