It was the only service here publishing on 0.0.0.0, and a published docker port is not behind the firewall: docker writes its DNAT rules straight into the nat table, which ufw's INPUT chain never sees. `ufw default deny incoming` never covered 80/443/81 — ufw-docker-rules.conf on the host exists to patch exactly that, and patching a rule is weaker than never opening the socket. The address is read from `tailscale ip -4` at run time rather than passed in, because the host provisioning has already done `tailscale up` by the time this executes. It is validated against 100.64.0.0/10, the range tailscale and headscale both allocate from. SETUP_NPM_BIND overrides it. With neither, selecting NPM exits instead of falling back to 0.0.0.0 — a fallback would silently undo the point of the change. Two consequences worth knowing. tailscaled becomes a boot-order dependency, so the script warns when it is not enabled at boot; docker's restart policy covers the window but only if the tailnet comes up on its own. And HTTP-01 ACME challenges can no longer reach port 80, so any certificate NPM issues now needs DNS-01. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
10 KiB
Bash
Executable File
300 lines
10 KiB
Bash
Executable File
#!/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=<ip> 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" <<SVC
|
|
nginx-proxy-manager:
|
|
image: jc21/nginx-proxy-manager:latest
|
|
container_name: nginx-proxy-manager
|
|
restart: unless-stopped
|
|
ports:
|
|
- "$NPM_BIND:80:80"
|
|
- "$NPM_BIND:443:443"
|
|
- "$NPM_BIND:81:81"
|
|
volumes:
|
|
- ./npm_data:/data
|
|
- ./npm_letsencrypt:/etc/letsencrypt
|
|
networks:
|
|
- $DOCKER_NETWORK
|
|
|
|
SVC
|
|
;;
|
|
2)
|
|
COMPOSE_SERVICES+=("postgres")
|
|
PG_DEFAULT_PASS=$(openssl rand -base64 36 | tr -d '/+=' | head -c 36)
|
|
prompt_value PG_PASSWORD "PostgreSQL password" "$PG_DEFAULT_PASS"
|
|
prompt_value PG_DATABASE "PostgreSQL database name" "officer_dev"
|
|
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
|
postgres:
|
|
image: postgres:18.3-alpine
|
|
container_name: postgres
|
|
restart: unless-stopped
|
|
ports:
|
|
- "127.0.0.1:5432:5432"
|
|
environment:
|
|
POSTGRES_PASSWORD: $PG_PASSWORD
|
|
PGDATA: /var/lib/postgresql/data
|
|
volumes:
|
|
- ./postgres_data:/var/lib/postgresql/data
|
|
- ./db_dumps:/db_dumps
|
|
networks:
|
|
- $DOCKER_NETWORK
|
|
|
|
SVC
|
|
;;
|
|
3)
|
|
COMPOSE_SERVICES+=("mailhog")
|
|
MAILHOG_SELECTED=true
|
|
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
|
mailhog:
|
|
image: mailhog/mailhog:latest
|
|
container_name: mailhog
|
|
restart: unless-stopped
|
|
ports:
|
|
- "127.0.0.1:1025:1025"
|
|
- "127.0.0.1:8025:8025"
|
|
networks:
|
|
- $DOCKER_NETWORK
|
|
|
|
SVC
|
|
;;
|
|
4)
|
|
COMPOSE_SERVICES+=("redis")
|
|
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
|
redis:
|
|
image: redis:alpine
|
|
container_name: redis
|
|
restart: unless-stopped
|
|
ports:
|
|
- "127.0.0.1:6379:6379"
|
|
volumes:
|
|
- ./redis_data:/data
|
|
networks:
|
|
- $DOCKER_NETWORK
|
|
|
|
SVC
|
|
;;
|
|
5)
|
|
COMPOSE_SERVICES+=("searxng")
|
|
mkdir -p "$COMPOSE_DIR/searxng"
|
|
SEARXNG_SECRET=$(openssl rand -hex 32)
|
|
cat > "$COMPOSE_DIR/searxng/settings.yml" <<SEARXCFG
|
|
use_default_settings: true
|
|
|
|
server:
|
|
secret_key: "$SEARXNG_SECRET"
|
|
limiter: false
|
|
|
|
search:
|
|
formats:
|
|
- html
|
|
- json
|
|
SEARXCFG
|
|
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
|
|
searxng:
|
|
image: searxng/searxng:latest
|
|
container_name: searxng
|
|
restart: unless-stopped
|
|
ports:
|
|
- "127.0.0.1:8080:8080"
|
|
volumes:
|
|
- ./searxng:/etc/searxng
|
|
environment:
|
|
SEARXNG_BASE_URL: http://localhost:8080/
|
|
networks:
|
|
- $DOCKER_NETWORK
|
|
|
|
SVC
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Add networks block
|
|
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<FOOTER
|
|
networks:
|
|
$DOCKER_NETWORK:
|
|
external: true
|
|
FOOTER
|
|
|
|
# ─── start services ─────────────────────────────────────────────────────────
|
|
info "Starting docker compose services..."
|
|
(cd "$COMPOSE_DIR" && docker compose up -d) >&2
|
|
|
|
ok "Docker services: ${COMPOSE_SERVICES[*]}"
|
|
|
|
# ─── create database if PostgreSQL was selected ─────────────────────────────
|
|
if [[ -n "$PG_PASSWORD" ]]; then
|
|
info "Waiting for PostgreSQL to be ready..."
|
|
for i in $(seq 1 15); do
|
|
if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then
|
|
# Create the database if it doesn't exist
|
|
docker exec postgres psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$PG_DATABASE'" | grep -q 1 \
|
|
|| docker exec postgres psql -U postgres -c "CREATE DATABASE $PG_DATABASE" >/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
|