#!/usr/bin/env bash # ===================================================================== # TeamVis — Ein-Schritt-Installer # ===================================================================== # Bringt eine komplette TeamVis-Instanz in einem Rutsch hoch. Beim Start # wählst du, woher die Datenbank kommt: # # [1] Bestehende/eigene Supabase — Cloud-Projekt ODER bereits self- # gehostetes Supabase. Es läuft NUR die App (+ optional Caddy/TLS). # # [2] Alles mitinstallieren — schlanker Supabase-Stack als # Container (Postgres + PostgREST + Storage) + App + Caddy/TLS, # Single-Domain. Kein vorab eingerichtetes Supabase nötig. # # Erledigt automatisch: Schlüssel/Secrets erzeugen, ENV + Compose + # Caddyfile schreiben, Schema einspielen, (Modus 2) Rollen-Bootstrap + # Grants, Container starten, ersten Admin anlegen. # # Aufruf: bash deploy/selfhost/install.sh # Variablen lassen sich vorab per ENV setzen (überspringt die Abfrage), # z. B.: APP_DOMAIN=team.kunde.de ADMIN_EMAIL=chef@kunde.de bash install.sh # # Voraussetzungen: docker + docker compose v2, node 20+, openssl; DNS der # Domain auf diesen Host, Ports 80/443 frei; Login an der Registry # git.zoesch.de (docker login) für das App-Image. set -euo pipefail # REPO-Wurzel (dieses Skript liegt in deploy/selfhost/). REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # ── Ausgabe-Helfer ──────────────────────────────────────────────────── c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_red=$'\033[31m'; c_yel=$'\033[33m'; c_rst=$'\033[0m' say() { printf '%s\n' "$*"; } head() { printf '\n%s%s%s\n' "$c_bold" "$*" "$c_rst"; } ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; } warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; } die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; } # ask VAR "Frage" "default" → nutzt $VAR falls gesetzt, sonst fragt nach. ask() { local __var="$1" __q="$2" __def="${3:-}" __cur __ans __cur="$(eval "printf '%s' \"\${$__var:-}\"")" if [ -n "$__cur" ]; then return 0; fi if [ -n "$__def" ]; then printf '%s [%s]: ' "$__q" "$__def"; else printf '%s: ' "$__q"; fi read -r __ans || true __ans="${__ans:-$__def}" printf -v "$__var" '%s' "$__ans" } ask_secret() { local __var="$1" __q="$2" __ans __cur __cur="$(eval "printf '%s' \"\${$__var:-}\"")" if [ -n "$__cur" ]; then return 0; fi printf '%s: ' "$__q"; read -rs __ans || true; printf '\n' printf -v "$__var" '%s' "$__ans" } yesno() { # yesno VAR "Frage" default(j/n) local __var="$1" __q="$2" __def="${3:-j}" __ans __cur __cur="$(eval "printf '%s' \"\${$__var:-}\"")" if [ -n "$__cur" ]; then return 0; fi printf '%s (j/n) [%s]: ' "$__q" "$__def"; read -r __ans || true __ans="${__ans:-$__def}"; printf -v "$__var" '%s' "$__ans" } # ── Voraussetzungen ─────────────────────────────────────────────────── head "TeamVis — Ein-Schritt-Installer" command -v docker >/dev/null || die "docker fehlt." docker compose version >/dev/null 2>&1 || die "docker compose v2 fehlt." command -v node >/dev/null || die "node (20+) fehlt — wird für Keys/Schema/Admin gebraucht." command -v openssl >/dev/null || die "openssl fehlt." DEFAULT_VERSION="$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo latest)" # Registry-Host des App-Images. Default = git.zoesch.de; sobald die Vanity- # Domain live ist, REGISTRY=git.zfx.services setzen (muss zum `docker login` # passen). Image-Pfad bleibt zfx-services/teamvis. REGISTRY="${REGISTRY:-git.zoesch.de}" # ── Gemeinsame Abfragen ─────────────────────────────────────────────── head "1) Basis-Angaben" ask NAME "Kurzname der Instanz (Verzeichnis/Compose-Projekt)" "teamvis" ask APP_DOMAIN "Domain der App (DNS muss auf diesen Host zeigen)" "" [ -n "$APP_DOMAIN" ] || die "Eine Domain ist nötig." ask TEAMVIS_VERSION "TeamVis-Image-Version" "$DEFAULT_VERSION" ask ADMIN_EMAIL "E-Mail des ersten Admins" "" [ -n "$ADMIN_EMAIL" ] || die "Admin-E-Mail ist nötig." ask ADMIN_NAME "Name des ersten Admins" "Administrator" TARGET="${TARGET:-$PWD/$NAME}" ask TARGET "Zielverzeichnis" "$TARGET" if [ -e "$TARGET/docker-compose.yml" ]; then yesno OVERWRITE "Im Zielverzeichnis liegt schon eine Installation — überschreiben?" "n" case "$OVERWRITE" in [jJyY]*) : ;; *) die "Abgebrochen.";; esac fi mkdir -p "$TARGET" head "2) SMTP (optional — leer lassen, dann landen Magic-Links nur im Log)" ask SMTP_HOST "SMTP-Host" "" if [ -n "$SMTP_HOST" ]; then ask SMTP_PORT "SMTP-Port" "587" ask SMTP_USER "SMTP-User" "" ask_secret SMTP_PASS "SMTP-Passwort" ask SMTP_FROM_EMAIL "Absender-Adresse" "noreply@$APP_DOMAIN" ask SMTP_FROM_NAME "Absender-Name" "TeamVis" fi SMTP_PORT="${SMTP_PORT:-587}"; SMTP_USER="${SMTP_USER:-}"; SMTP_PASS="${SMTP_PASS:-}" SMTP_FROM_EMAIL="${SMTP_FROM_EMAIL:-noreply@$APP_DOMAIN}"; SMTP_FROM_NAME="${SMTP_FROM_NAME:-TeamVis}"; SMTP_SECURE="${SMTP_SECURE:-}" # App-Session-Secret immer frisch (pro Instanz einmalig). SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -base64 48)}" SITE_URL="https://$APP_DOMAIN" # ── Modus-Wahl ──────────────────────────────────────────────────────── head "3) Datenbank" say " [1] Bestehende/eigene Supabase nutzen (Cloud oder self-hosted)" say " [2] Schlanke Supabase als Container mitinstallieren (kein Setup nötig)" ask MODE "Auswahl" "2" case "$MODE" in 1|2) : ;; *) die "Bitte 1 oder 2.";; esac # Repo-Abhängigkeiten für create-admin.mjs sicherstellen. ensure_node_deps() { if [ ! -d "$REPO/node_modules/@supabase/supabase-js" ] || [ ! -d "$REPO/node_modules/bcryptjs" ]; then warn "Node-Abhängigkeiten fehlen — installiere (einmalig) im Repo …" ( cd "$REPO" && npm install --no-audit --no-fund ) fi } # Schema-Bündel erzeugen. build_schema() { node "$REPO/scripts/bundle-migrations.mjs" > "$TARGET/teamvis-schema.sql" ok "Schema-Bündel: $TARGET/teamvis-schema.sql ($(grep -c '^-- ' "$TARGET/teamvis-schema.sql" 2>/dev/null || echo '?') Abschnitte)" } write_env() { # write_env "extra lines…" via stdin : > "$TARGET/.env" while IFS= read -r line; do printf '%s\n' "$line" >> "$TARGET/.env"; done chmod 600 "$TARGET/.env" } create_admin() { ensure_node_deps head "Ersten Admin anlegen ($ADMIN_EMAIL)" NEXT_PUBLIC_SUPABASE_URL="$1" \ SUPABASE_SERVICE_ROLE_KEY="$2" \ node "$REPO/scripts/create-admin.mjs" "$ADMIN_EMAIL" --name "$ADMIN_NAME" --role admin } ####################################################################### # MODUS 1 — Bestehende/eigene Supabase ####################################################################### if [ "$MODE" = "1" ]; then head "Modus 1 — bestehende Supabase" ask SUPABASE_URL "Supabase-URL (z. B. https://xxxx.supabase.co)" "" [ -n "$SUPABASE_URL" ] || die "Supabase-URL nötig." ask_secret ANON_KEY "Supabase anon-Key" ask_secret SERVICE_ROLE_KEY "Supabase service_role-Key" [ -n "${ANON_KEY:-}" ] && [ -n "${SERVICE_ROLE_KEY:-}" ] || die "anon- und service_role-Key nötig." yesno WITH_CADDY "Soll Caddy TLS für $APP_DOMAIN übernehmen? (n = du hast einen eigenen Reverse-Proxy)" "j" # .env (App liest zur Laufzeit) write_env < "$TARGET/Caddyfile" < "$TARGET/docker-compose.yml" <<'YAML' name: teamvis-__NAME__ services: app: image: __REGISTRY__/zfx-services/teamvis:${TEAMVIS_VERSION} restart: unless-stopped env_file: .env expose: ["3000"] healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 20s caddy: image: caddy:2-alpine restart: unless-stopped depends_on: [app] ports: ["80:80", "443:443"] volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config volumes: caddy-data: caddy-config: YAML else ask APP_PORT "Lokaler Port für deinen Reverse-Proxy" "3001" cat > "$TARGET/docker-compose.yml" <process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 20s YAML fi sed -i.bak -e "s|__REGISTRY__|$REGISTRY|" -e "s/__NAME__/$NAME/" "$TARGET/docker-compose.yml" && rm -f "$TARGET/docker-compose.yml.bak" ok "docker-compose.yml geschrieben." # Schema head "Schema einspielen" build_schema if [ -n "${DATABASE_URL:-}" ]; then say "→ Wende Schema per DATABASE_URL an …" docker run --rm -i postgres:15 psql "$DATABASE_URL" -v ON_ERROR_STOP=1 < "$TARGET/teamvis-schema.sql" ok "Schema eingespielt." else warn "Kein DATABASE_URL gesetzt — Schema bitte EINMAL in Supabase Studio (SQL-Editor) einspielen:" say " $TARGET/teamvis-schema.sql" read -r -p " Enter drücken, sobald das Schema in der DB ist … " _ || true fi # Start + Admin head "Container starten" ( cd "$TARGET" && docker compose pull && docker compose up -d ) ok "App läuft." create_admin "$SUPABASE_URL" "$SERVICE_ROLE_KEY" ####################################################################### # MODUS 2 — Supabase als Container mitinstallieren (Single-Domain) ####################################################################### else head "Modus 2 — schlanke Supabase mitinstallieren" say "Single-Domain: App, /rest/v1 und /storage/v1 laufen alle unter https://$APP_DOMAIN." POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -base64 30 | tr -d '/+=' | cut -c1-32)}" POSTGRES_DB="${POSTGRES_DB:-postgres}" # JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY erzeugen (HS256, 10 J. gültig). KEYS="$(node "$REPO/deploy/selfhost/gen-keys.mjs")" JWT_SECRET="$(printf '%s\n' "$KEYS" | sed -n 's/^JWT_SECRET=//p')" ANON_KEY="$(printf '%s\n' "$KEYS" | sed -n 's/^ANON_KEY=//p')" SERVICE_ROLE_KEY="$(printf '%s\n' "$KEYS" | sed -n 's/^SERVICE_ROLE_KEY=//p')" ok "Schlüssel erzeugt (JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY)." write_env < "$TARGET/Caddyfile" < "$TARGET/docker-compose.yml" <<'YAML' name: teamvis-__NAME__ services: db: image: supabase/postgres:15.8.1.085 restart: unless-stopped environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - db-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 20 rest: image: postgrest/postgrest:v14.12 restart: unless-stopped depends_on: db: condition: service_healthy environment: PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} PGRST_DB_SCHEMAS: public,storage PGRST_DB_EXTRA_SEARCH_PATH: public PGRST_DB_ANON_ROLE: anon PGRST_DB_MAX_ROWS: "1000" PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" storage: image: supabase/storage-api:v1.60.4 restart: unless-stopped depends_on: db: condition: service_healthy rest: condition: service_started healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"] interval: 5s timeout: 5s retries: 6 start_period: 15s environment: ANON_KEY: ${ANON_KEY} SERVICE_KEY: ${SERVICE_ROLE_KEY} POSTGREST_URL: http://rest:3000 AUTH_JWT_SECRET: ${JWT_SECRET} DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} REQUEST_ALLOW_X_FORWARDED_PATH: "true" FILE_SIZE_LIMIT: "52428800" STORAGE_BACKEND: file GLOBAL_S3_BUCKET: stub FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: stub REGION: stub ENABLE_IMAGE_TRANSFORMATION: "false" volumes: - ./volumes/storage:/var/lib/storage caddy: image: caddy:2-alpine restart: unless-stopped depends_on: - app - rest - storage ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config app: image: __REGISTRY__/zfx-services/teamvis:${TEAMVIS_VERSION} restart: unless-stopped # Hairpin-NAT: die App ruft SUPABASE_PUBLIC_URL (= eigene Domain) auch # serverseitig auf → Domain auf den Host (Caddy) zeigen lassen. extra_hosts: - "__APP_DOMAIN__:host-gateway" depends_on: db: condition: service_healthy environment: SUPABASE_URL: ${SUPABASE_PUBLIC_URL} NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} SESSION_SECRET: ${SESSION_SECRET} NEXT_PUBLIC_SITE_URL: ${SITE_URL} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-} SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} SMTP_FROM_NAME: ${SMTP_FROM_NAME:-TeamVis} expose: - "3000" volumes: db-data: YAML sed -i.bak -e "s|__REGISTRY__|$REGISTRY|" -e "s/__NAME__/$NAME/" -e "s/__APP_DOMAIN__/$APP_DOMAIN/" "$TARGET/docker-compose.yml" && rm -f "$TARGET/docker-compose.yml.bak" ok "docker-compose.yml + Caddyfile geschrieben." # psql-Helfer (Superuser, lokaler Socket-Trust im db-Container). dbsql() { ( cd "$TARGET" && docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d "$POSTGRES_DB" "$@" ); } head "Datenbank starten + Rollen-Bootstrap" ( cd "$TARGET" && docker compose pull && docker compose up -d db ) say "→ Warte auf gesunde Datenbank …" until ( cd "$TARGET" && docker compose exec -T db pg_isready -U postgres -d "$POSTGRES_DB" >/dev/null 2>&1 ); do sleep 2; done # Rollen-Passwörter (das Image setzt nur supabase_admin) — VOR rest/storage. dbsql </dev/null | grep -q 1 ); then break; fi sleep 2 done ok "Storage initialisiert." head "Schema einspielen" build_schema dbsql < "$TARGET/teamvis-schema.sql" ok "TeamVis-Schema eingespielt." # service_role-Grants (auf echtem Supabase automatisch) — NACH dem Schema. dbsql <