Files
teamvis-selfhost/deploy/selfhost/install.sh
T
2026-06-25 19:54:40 +02:00

483 lines
19 KiB
Bash
Executable File

#!/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.zfx.services (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 (muss zum `docker login` passen). Image-Pfad
# bleibt zfx-services/teamvis. Per ENV überschreibbar (z. B. git.zoesch.de).
REGISTRY="${REGISTRY:-git.zfx.services}"
# ── 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 <<EOF
NEXT_PUBLIC_SUPABASE_URL=$SUPABASE_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
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_VERSION=$TEAMVIS_VERSION
EOF
ok ".env geschrieben."
# Compose
if [ "${WITH_CADDY:0:1}" = "j" ] || [ "${WITH_CADDY:0:1}" = "J" ]; then
cat > "$TARGET/Caddyfile" <<EOF
$APP_DOMAIN {
reverse_proxy app:3000
}
EOF
cat > "$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" <<YAML
name: teamvis-__NAME__
services:
app:
image: __REGISTRY__/zfx-services/teamvis:\${TEAMVIS_VERSION}
restart: unless-stopped
env_file: .env
ports: ["127.0.0.1:$APP_PORT: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
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 <<EOF
# ── Postgres ──────────────────────────────────────────
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
POSTGRES_DB=$POSTGRES_DB
# ── Supabase-Keys (mit JWT_SECRET signiert) ───────────
JWT_SECRET=$JWT_SECRET
ANON_KEY=$ANON_KEY
SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY
# ── Öffentliche URLs (Single-Domain) ──────────────────
SITE_URL=$SITE_URL
SUPABASE_PUBLIC_URL=$SITE_URL
# ── App-Session ───────────────────────────────────────
SESSION_SECRET=$SESSION_SECRET
# ── App-SMTP ──────────────────────────────────────────
SMTP_HOST=$SMTP_HOST
SMTP_PORT=$SMTP_PORT
SMTP_SECURE=$SMTP_SECURE
SMTP_USER=$SMTP_USER
SMTP_PASS=$SMTP_PASS
SMTP_FROM_EMAIL=$SMTP_FROM_EMAIL
SMTP_FROM_NAME=$SMTP_FROM_NAME
# ── App-Image ─────────────────────────────────────────
TEAMVIS_VERSION=$TEAMVIS_VERSION
EOF
ok ".env geschrieben."
# Caddyfile (Single-Domain: TLS + Pfad-Routing)
cat > "$TARGET/Caddyfile" <<EOF
$APP_DOMAIN {
handle_path /rest/v1/* {
reverse_proxy rest:3000
}
handle_path /storage/v1/* {
reverse_proxy storage:5000
}
handle {
reverse_proxy app:3000
}
}
EOF
# Compose (db + rest + storage + caddy + app). ${...} bleibt literal
# → docker liest die Werte zur Laufzeit aus der .env.
cat > "$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 <<SQL
alter role authenticator with login password '$POSTGRES_PASSWORD';
alter role supabase_storage_admin with login password '$POSTGRES_PASSWORD';
alter role supabase_auth_admin with login password '$POSTGRES_PASSWORD';
SQL
ok "Rollen-Passwörter gesetzt."
head "REST + Storage starten"
( cd "$TARGET" && docker compose up -d rest storage )
say "→ Warte auf Storage (legt sein storage-Schema an) …"
for _ in $(seq 1 30); do
if ( cd "$TARGET" && docker compose exec -T db psql -U postgres -d "$POSTGRES_DB" -tAc "select 1 from information_schema.schemata where schema_name='storage'" 2>/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 <<SQL
alter role service_role bypassrls;
grant all on all tables in schema public to service_role;
grant all on all sequences in schema public to service_role;
grant all on all routines in schema public to service_role;
alter default privileges in schema public grant all on tables to service_role;
alter default privileges in schema public grant all on sequences to service_role;
SQL
ok "service_role-Grants gesetzt."
head "App + Caddy starten"
( cd "$TARGET" && docker compose up -d )
ok "Stack läuft."
create_admin "$SITE_URL" "$SERVICE_ROLE_KEY"
fi
#######################################################################
# Abschluss
#######################################################################
head "Fertig 🎉"
ok "App: $SITE_URL"
ok "Admin: $SITE_URL/admin/login ($ADMIN_EMAIL)"
say "$c_dim Verzeichnis: $TARGET$c_rst"
head "Nächste Schritte"
say " 1. DNS für $APP_DOMAIN muss auf diesen Host zeigen (Caddy holt das TLS-Zertifikat dann automatisch)."
say " 2. Anmelden, beim ersten Login Passwort setzen."
say " 3. Admin → Branding (Logo, Farben, Impressum)."
say " 4. Admin → Funktionen: Module gestaffelt freischalten (Start = nur Visitenkarten)."
say " 5. Smoke-Test: öffentliche Karte, vCard, Foto-Upload, Magic-Link."
[ "$MODE" = "2" ] && say " $c_yel→ Backup nicht vergessen:$c_rst DB-Volume (db-data) + $TARGET/volumes/storage sichern."
say ""
warn "Schlägt 'docker compose pull' beim App-Image fehl? → 'docker login $REGISTRY' nachholen."