#!/bin/bash # Officer — Docker Compose service setup # Sets up Docker Compose services (Nginx Proxy Manager, PostgreSQL, Mailhog, Redis, SearXNG). # Outputs parseable key=value lines to stdout; all prompts go to stderr. # # Usage: # bash scripts/setup/setup-dockers.sh # eval "$(bash scripts/setup/setup-dockers.sh)" # # Environment overrides: # SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none") # SETUP_DOCKER_NETWORK="services" — docker network name # SETUP_NPM_BIND="100.64.0.8" — host address Nginx Proxy Manager publishes on. Defaults to this # node's Tailscale IPv4; set it explicitly to bind somewhere else. set -e # Resolve the real user's home even when running under sudo if [[ -n "${SUDO_USER:-}" ]]; then REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) else REAL_HOME="$HOME" fi GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' info() { echo -e "${CYAN}::${NC} $*" >&2; } ok() { echo -e " ${GREEN}✓${NC} $1" >&2; } warn() { echo -e " ${YELLOW}!${NC} $1" >&2; } fail() { echo -e " ${RED}✗${NC} $1" >&2; } prompt_value() { local varname="$1" prompt="$2" default="$3" if [[ -n "$default" ]]; then echo -en " ${BOLD}${prompt}${NC} [${default}]: " >&2 else echo -en " ${BOLD}${prompt}${NC}: " >&2 fi read -r val if [[ -z "$val" ]]; then val="$default"; fi eval "$varname=\"\$val\"" } # ─── docker network ───────────────────────────────────────────────────────── DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}" # ─── Nginx Proxy Manager bind address ─────────────────────────────────────── # # NPM is the only service here that ever published on 0.0.0.0, and a published Docker port is not # behind the firewall: Docker writes its DNAT rules directly into the nat table, which UFW's INPUT # chain never sees. `ufw default deny incoming` does not cover 80/443/81 — that is what the host's # ufw-docker-rules.conf exists to patch, and patching a rule is weaker than never opening the socket. # # So bind to the tailnet address instead. The kernel then refuses the socket on every other interface # and the firewall stops being load-bearing for this. The address is read at run time rather than # passed in, because by the time this script runs the host provisioning has already done `tailscale up`. resolve_npm_bind() { if [[ -n "${SETUP_NPM_BIND:-}" ]]; then echo "$SETUP_NPM_BIND" return fi local ip ip=$(tailscale ip -4 2>/dev/null | head -1) # 100.64.0.0/10 — the CGNAT range both Tailscale and Headscale allocate from. Anything outside it # means `tailscale ip` answered with something unexpected, and a bind address is not a value to # guess at: the whole point is that it is NOT reachable from the internet. if [[ "$ip" =~ ^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\. ]]; then echo "$ip" return fi echo "" } NPM_BIND="$(resolve_npm_bind)" # Binding to an address that belongs to another service's interface makes that service a boot-order # dependency: if tailscaled has not brought tailscale0 up yet, the container cannot get its socket and # Docker falls back on the restart policy to retry. That converges, but only if the tailnet comes up # at all on its own. if [[ -n "$NPM_BIND" ]] && ! systemctl is-enabled --quiet tailscaled 2>/dev/null; then warn "tailscaled is not enabled at boot — NPM binds $NPM_BIND, which will not exist after a reboot" warn "until the tailnet is up. Fix with: sudo systemctl enable tailscaled" fi # Ensure network exists if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then docker network create "$DOCKER_NETWORK" >/dev/null 2>&1 ok "Created docker network '$DOCKER_NETWORK'" else ok "Docker network '$DOCKER_NETWORK' exists" fi # ─── service selection ─────────────────────────────────────────────────────── SERVICES="${SETUP_DOCKER_SERVICES:-}" if [[ "$SERVICES" == "none" ]]; then info "Skipping Docker Compose services (SETUP_DOCKER_SERVICES=none)" exit 0 fi if [[ -z "$SERVICES" ]]; then info "Select Docker Compose services to include:" echo " [1] Nginx Proxy Manager (jc21/nginx-proxy-manager:latest)" >&2 echo " [2] PostgreSQL 18 (postgres:18-alpine)" >&2 echo " [3] Mailhog (mailhog/mailhog:latest)" >&2 echo " [4] Redis (redis:alpine)" >&2 echo " [5] SearXNG (searxng/searxng:latest)" >&2 prompt_value SERVICES "Enter numbers separated by spaces (e.g. '1 2 4'), or 'all'" "all" fi if [[ "$SERVICES" == "all" ]]; then SERVICES="1 2 3 4 5" fi # ─── compose directory ────────────────────────────────────────────────────── prompt_value COMPOSE_DIR "Docker Compose directory" "$REAL_HOME/.local/dockers" mkdir -p "$COMPOSE_DIR" # ─── build compose file ───────────────────────────────────────────────────── cat > "$COMPOSE_DIR/docker-compose.yaml" <<'HEADER' services: HEADER COMPOSE_SERVICES=() PG_PASSWORD="" MAILHOG_SELECTED=false for svc in $SERVICES; do case "$svc" in 1) if [[ -z "$NPM_BIND" ]]; then fail "Nginx Proxy Manager selected, but no Tailscale IPv4 was found on this host." echo " Bring the tailnet up first (the host provisioning does this), or choose the" >&2 echo " address deliberately: SETUP_NPM_BIND= bash scripts/setup/setup-dockers.sh" >&2 echo " Publishing it on 0.0.0.0 is not offered — Docker bypasses UFW, so that would put" >&2 echo " 80/443/81 on every interface the host has." >&2 exit 1 fi COMPOSE_SERVICES+=("nginx-proxy-manager") cat >> "$COMPOSE_DIR/docker-compose.yaml" </dev/null 2>&1 ok "Database '$PG_DATABASE' ready" else warn "PostgreSQL not ready after 15s — create database manually" fi fi # ─── output parseable values to stdout ─────────────────────────────────────── echo "COMPOSE_DIR=$COMPOSE_DIR" if [[ " ${COMPOSE_SERVICES[*]} " == *" nginx-proxy-manager "* ]]; then echo "NPM_BIND=$NPM_BIND" fi if [[ -n "$PG_PASSWORD" ]]; then echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}" fi if [[ "$MAILHOG_SELECTED" == true ]]; then echo "MAIL_TRANSPORT=smtp://127.0.0.1:1025" fi