#!/usr/bin/env bash # ANOUMA — automated setup. # # Fresh install: # git clone https://git.maro.run/maro/anouma.git && cd anouma && ./setup.sh # # Safe to re-run on an existing installation: never overwrites secrets that # already exist in .env, never deletes volumes/networks/containers it # doesn't own, and never touches Nginx Proxy Manager (or any other existing # container) beyond optionally joining its Docker network. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- COLOR_INFO="\033[36m"; COLOR_WARN="\033[33m"; COLOR_ERROR="\033[31m"; COLOR_OK="\033[32m"; COLOR_RESET="\033[0m" log_info() { printf "${COLOR_INFO}[INFO]${COLOR_RESET} %s\n" "$1"; } log_warn() { printf "${COLOR_WARN}[WARN]${COLOR_RESET} %s\n" "$1"; } log_error() { printf "${COLOR_ERROR}[ERROR]${COLOR_RESET} %s\n" "$1" >&2; } log_success() { printf "${COLOR_OK}[SUCCESS]${COLOR_RESET} %s\n" "$1"; } fail() { log_error "$1" [ -n "${2:-}" ] && log_error " → $2" exit 1 } # --------------------------------------------------------------------------- # Args # --------------------------------------------------------------------------- NON_INTERACTIVE=false SKIP_SEED=false for arg in "$@"; do case "$arg" in --non-interactive) NON_INTERACTIVE=true ;; --skip-seed) SKIP_SEED=true ;; --help|-h) echo "Usage: ./setup.sh [--non-interactive] [--skip-seed]" exit 0 ;; esac done ask() { # ask "Prompt" "default" -> echoes the answer local prompt="$1" default="$2" answer if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then echo "$default" return fi read -r -p "$prompt [$default]: " answer || true echo "${answer:-$default}" } ask_yn() { # ask_yn "Prompt" "Y|N" -> echoes true/false local prompt="$1" default="$2" answer if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then [ "$default" = "Y" ] && echo true || echo false return fi local hint="y/N"; [ "$default" = "Y" ] && hint="Y/n" read -r -p "$prompt [$hint]: " answer || true answer="${answer:-$default}" case "$answer" in y|Y|yes|Yes|YES) echo true ;; *) echo false ;; esac } echo "" echo "ANOUMA Setup" echo "============" echo "" # --------------------------------------------------------------------------- # 1–4: detect tooling # --------------------------------------------------------------------------- command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden." "Installiere Docker: https://docs.docker.com/engine/install/" log_success "Docker erkannt: $(docker --version)" docker info >/dev/null 2>&1 || fail "Der Docker-Daemon läuft nicht (oder fehlende Berechtigung)." "Starte Docker bzw. führe dieses Skript mit einem Nutzer aus, der Docker verwenden darf." COMPOSE="" if docker compose version >/dev/null 2>&1; then COMPOSE="docker compose" elif command -v docker-compose >/dev/null 2>&1; then COMPOSE="docker-compose" fi [ -n "$COMPOSE" ] || fail "Docker Compose wurde nicht gefunden." "Installiere das Compose-Plugin: https://docs.docker.com/compose/install/" log_success "Docker Compose erkannt: $($COMPOSE version | head -n1)" if command -v git >/dev/null 2>&1; then log_success "Git erkannt: $(git --version)" else log_warn "Git wurde nicht gefunden — für update.sh (Release-Erkennung) empfohlen, aber für den Betrieb selbst nicht zwingend nötig." fi # Node/npm are NOT required on the host — everything runs inside the app # container. This is purely informational. if command -v node >/dev/null 2>&1; then log_info "Node.js auf dem Host erkannt: $(node --version) (wird nicht benötigt — ANOUMA läuft in Docker)" else log_info "Node.js nicht auf dem Host installiert — kein Problem, ANOUMA läuft vollständig in Docker." fi # --------------------------------------------------------------------------- # 5–6: detect existing Docker networks, look for a reverse proxy (e.g. NPM) # --------------------------------------------------------------------------- log_info "Prüfe vorhandene Docker-Netzwerke …" EXISTING_NETWORKS="$(docker network ls --format '{{.Name}}' 2>/dev/null || true)" detect_npm_network() { # 1) Look for a running container that looks like Nginx Proxy Manager and # read the network it's actually attached to. local npm_container npm_container="$(docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'nginx-proxy-manager|jc21/nginx-proxy-manager' | head -n1 | cut -f1 || true)" if [ -n "$npm_container" ]; then docker inspect "$npm_container" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | grep -v '^bridge$' | head -n1 return fi # 2) Fall back to a name heuristic among existing networks. echo "$EXISTING_NETWORKS" | grep -iE 'nginx-proxy-manager|^npm' | head -n1 || true } DETECTED_NPM_NETWORK="$(detect_npm_network || true)" NPM_DETECTED=false if [ -n "$DETECTED_NPM_NETWORK" ]; then NPM_DETECTED=true log_success "Nginx Proxy Manager erkannt (Netzwerk: $DETECTED_NPM_NETWORK)" else log_info "Nginx Proxy Manager wurde nicht erkannt — ANOUMA läuft eigenständig, das ist kein Fehler." fi # --------------------------------------------------------------------------- # 7–10: .env — create if missing, fill in only missing keys otherwise, # generate secrets, never overwrite existing ones # --------------------------------------------------------------------------- gen_secret() { openssl rand -hex 32; } FRESH_ENV=false if [ ! -f .env ]; then FRESH_ENV=true cp .env.example .env log_info ".env aus .env.example erstellt." fi env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; } env_set() { # env_set KEY VALUE — replaces an existing (possibly empty) line, or # appends the key if it's missing entirely. Never touches other keys. local key="$1" value="$2" if grep -qE "^$key=" .env; then local tmp; tmp="$(mktemp)" awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env else printf '%s=%s\n' "$key" "$value" >> .env fi } # Only fills the key in if it is currently missing or blank — this is what # makes secret generation safe to re-run (existing secrets survive updates). env_ensure() { local key="$1" value="$2" local current; current="$(env_get "$key")" if [ -z "$current" ]; then env_set "$key" "$value" return 0 fi return 1 } if [ "$FRESH_ENV" = true ]; then log_info "Domain für diese Installation (z. B. https://anouma.org):" APP_URL="$(ask "Domain" "http://localhost:3000")" env_set NEXT_PUBLIC_SERVER_URL "$APP_URL" fi env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert." env_ensure MEETING_SESSION_SECRET "$(gen_secret)" && log_info "MEETING_SESSION_SECRET generiert." || log_info "MEETING_SESSION_SECRET bereits vorhanden — unverändert." env_ensure CRON_SECRET "$(gen_secret)" && log_info "CRON_SECRET generiert." || log_info "CRON_SECRET bereits vorhanden — unverändert." if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then log_info "POSTGRES_PASSWORD generiert." # Keep DATABASE_URI in sync with the freshly generated password so the # app can actually connect — only rewritten when we just generated the # password ourselves, never when POSTGRES_PASSWORD already existed. PG_USER="$(env_get POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}" PG_DB="$(env_get POSTGRES_DB)"; PG_DB="${PG_DB:-anouma}" PG_PASSWORD="$(env_get POSTGRES_PASSWORD)" env_set DATABASE_URI "postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}" else log_info "POSTGRES_PASSWORD bereits vorhanden — unverändert." fi if [ "$FRESH_ENV" = true ]; then echo "" SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")" if [ "$SETUP_SMTP" = true ]; then env_set SMTP_HOST "$(ask "SMTP-Host" "")" env_set SMTP_PORT "$(ask "SMTP-Port" "587")" env_set SMTP_USER "$(ask "SMTP-Benutzer" "")" read -r -s -p "SMTP-Passwort: " smtp_pw || true; echo "" env_set SMTP_PASSWORD "$smtp_pw" fi if [ "$NPM_DETECTED" = true ]; then USE_NPM="$(ask_yn "Nginx Proxy Manager verwenden (Netzwerk beitreten)?" "Y")" if [ "$USE_NPM" = true ]; then NPM_NETWORK_NAME="$(ask "NPM-Docker-Netzwerk" "$DETECTED_NPM_NETWORK")" env_set NPM_NETWORK "$NPM_NETWORK_NAME" fi fi AUTO_UPDATE_ANSWER="$(ask_yn "Automatische Updates aktivieren?" "N")" env_set AUTO_UPDATE "$AUTO_UPDATE_ANSWER" fi chmod 600 .env || true log_success ".env ist bereit." # --------------------------------------------------------------------------- # Reverse-proxy network override — generated only when configured/detected, # never required. See docker-compose.override.yml.example for the manual # equivalent. # --------------------------------------------------------------------------- NPM_NETWORK_VALUE="$(env_get NPM_NETWORK)" if [ -n "$NPM_NETWORK_VALUE" ]; then if ! echo "$EXISTING_NETWORKS" | grep -qx "$NPM_NETWORK_VALUE"; then log_warn "NPM_NETWORK=$NPM_NETWORK_VALUE ist in .env gesetzt, aber kein Docker-Netzwerk mit diesem Namen existiert — das Override wird trotzdem geschrieben, `docker compose up` wird aber fehlschlagen, bis das Netzwerk existiert." fi sed "s/\${NPM_NETWORK}/$NPM_NETWORK_VALUE/g" docker-compose.override.yml.example > docker-compose.override.yml log_success "docker-compose.override.yml für Reverse-Proxy-Netzwerk '$NPM_NETWORK_VALUE' erstellt." elif [ -f docker-compose.override.yml ]; then log_info "NPM_NETWORK ist leer, aber docker-compose.override.yml existiert bereits — wird unverändert gelassen (nicht automatisch gelöscht)." fi # --------------------------------------------------------------------------- # 9–11: build, start database, wait for healthy, migrate # --------------------------------------------------------------------------- # Waits for a compose service's container healthcheck to report "healthy". # Uses `docker inspect` on the container ID (via `compose ps -q`) rather than # `compose ps --format`, since Go-template support in `ps --format` varies # across Compose versions while `docker inspect` is stable everywhere. wait_healthy() { local service="$1" timeout_iterations="$2" id status for _ in $(seq 1 "$timeout_iterations"); do id="$($COMPOSE ps -q "$service" 2>/dev/null || true)" if [ -n "$id" ]; then status="$(docker inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)" [ "$status" = "healthy" ] && return 0 fi sleep 2 done return 1 } log_info "Baue Docker-Images (das kann beim ersten Mal einige Minuten dauern) …" $COMPOSE build log_info "Starte Datenbank …" $COMPOSE up -d postgres log_info "Warte auf Datenbank-Healthcheck …" DB_READY=false wait_healthy postgres 60 && DB_READY=true [ "$DB_READY" = true ] || fail "Datenbank wurde nicht rechtzeitig healthy." "Prüfe die Logs: $COMPOSE logs postgres" log_success "Datenbank ist bereit." log_info "Führe Datenbank-Migrationen aus …" $COMPOSE run --rm app npm run migrate || fail "Migration fehlgeschlagen." "Prüfe die Ausgabe oben. Die Datenbank wurde nicht verändert, wenn die Migration atomar fehlgeschlagen ist." log_success "Migrationen angewendet." # --------------------------------------------------------------------------- # 13: seed # --------------------------------------------------------------------------- if [ "$SKIP_SEED" = false ]; then DO_SEED="$(ask_yn "Vorhandene ANOUMA-Inhalte (Angebote, Seitentexte) jetzt einspielen?" "Y")" if [ "$DO_SEED" = true ]; then log_info "Spiele Seed-Daten ein …" $COMPOSE run --rm app npm run seed || log_warn "Seed ist fehlgeschlagen — die Installation läuft trotzdem weiter, Inhalte können später im Admin-Bereich gepflegt werden." fi fi # --------------------------------------------------------------------------- # 14–15: start app, healthcheck # --------------------------------------------------------------------------- log_info "Starte Anwendung …" $COMPOSE up -d app log_info "Warte auf Healthcheck der Anwendung …" APP_READY=false wait_healthy app 60 && APP_READY=true # --------------------------------------------------------------------------- # AUTO_UPDATE cron # --------------------------------------------------------------------------- if [ "$(env_get AUTO_UPDATE)" = "true" ] && command -v crontab >/dev/null 2>&1; then CRON_MARKER="# anouma-auto-update" if ! (crontab -l 2>/dev/null | grep -qF "$CRON_MARKER"); then ( crontab -l 2>/dev/null; echo "*/30 * * * * cd $(pwd) && ./update.sh --auto >> $(pwd)/update.log 2>&1 $CRON_MARKER" ) | crontab - log_success "Cronjob für automatische Updates eingerichtet (alle 30 Minuten)." else log_info "Cronjob für automatische Updates ist bereits eingerichtet." fi fi echo "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" > .installed-version 2>/dev/null || true # --------------------------------------------------------------------------- # 16: result # --------------------------------------------------------------------------- echo "" echo "ANOUMA installation complete" echo "" echo "Version:" echo " $(cat .installed-version 2>/dev/null || echo unknown)" echo "" echo "Application:" echo " $(env_get NEXT_PUBLIC_SERVER_URL)" echo "" echo "Docker:" echo " ✓ Running" echo "" echo "Database:" if [ "$DB_READY" = true ]; then echo " ✓ Healthy"; else echo " ✗ Not healthy — check: $COMPOSE logs postgres"; fi echo "" echo "Application healthcheck:" if [ "$APP_READY" = true ]; then echo " ✓ Running"; else echo " ✗ Not healthy yet — check: $COMPOSE logs app"; fi echo "" echo "Nginx Proxy Manager:" if [ "$NPM_DETECTED" = true ]; then echo " ✓ Detected ($DETECTED_NPM_NETWORK)"; else echo " - Not detected"; fi echo "" echo "Next steps:" echo " 1. Visit ${NPM_DETECTED:+your configured domain, or }http://localhost:${APP_PORT:-3000}/admin to create the first admin account." echo " 2. See DEPLOYMENT.md for reverse-proxy setup, backups, updates and troubleshooting." echo ""