TeamVis Self-Host-Bundle v0.31.0
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# =====================================================================
|
||||
# TeamVis-Zusatzvariablen für die All-in-One-Installation
|
||||
# =====================================================================
|
||||
# Diese Werte gehören in die .env des supabase/docker-Verzeichnisses
|
||||
# (also UNTEN an die bestehende Supabase-.env anhängen). Die Supabase-
|
||||
# eigenen Werte (POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY,
|
||||
# SERVICE_ROLE_KEY, SUPABASE_PUBLIC_URL, SITE_URL) bleiben dort und
|
||||
# werden NICHT dupliziert.
|
||||
#
|
||||
# JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY werden mit gen-keys.mjs
|
||||
# erzeugt und ersetzen die unsicheren Demo-Defaults der Supabase-.env.
|
||||
|
||||
# Welches TeamVis-Image (= App-Version).
|
||||
TEAMVIS_VERSION=0.12.0
|
||||
|
||||
# App-Session-Cookie. >= 32 Zeichen, pro Instanz EINMALIG erzeugen, z.B.:
|
||||
# openssl rand -base64 48
|
||||
SESSION_SECRET=
|
||||
|
||||
# Öffentliche URLs (müssen zu den Domains im Caddyfile passen):
|
||||
# SITE_URL → App/Karten (https://team.kunde.de)
|
||||
# SUPABASE_PUBLIC_URL → Supabase (https://supabase.kunde.de)
|
||||
# Beide stehen schon in der Supabase-.env — dort auf die echten Domains
|
||||
# setzen (NICHT localhost).
|
||||
|
||||
# --- SMTP der App (eigene Variablen, getrennt von Supabase/GoTrue) ---
|
||||
# Fehlt der Host, landen Magic-Links nur im Container-Log.
|
||||
TEAMVIS_SMTP_HOST=
|
||||
TEAMVIS_SMTP_PORT=587
|
||||
TEAMVIS_SMTP_SECURE=
|
||||
TEAMVIS_SMTP_USER=
|
||||
TEAMVIS_SMTP_PASS=
|
||||
TEAMVIS_SMTP_FROM_EMAIL=noreply@team.kunde.de
|
||||
TEAMVIS_SMTP_FROM_NAME=TeamVis
|
||||
|
||||
# Optional: zusätzliche next/image-Hosts (i.d.R. leer lassen).
|
||||
SUPABASE_IMAGE_HOSTS=
|
||||
@@ -0,0 +1,20 @@
|
||||
# TeamVis All-in-One — TLS-Ingress (Caddy, Auto-HTTPS via Let's Encrypt)
|
||||
#
|
||||
# Voraussetzung: beide (Sub-)Domains zeigen per DNS auf die öffentliche
|
||||
# IP dieses Servers, Ports 80 + 443 sind offen. Caddy holt die
|
||||
# Zertifikate selbst.
|
||||
#
|
||||
# Kopieren nach ./Caddyfile und die zwei Domains anpassen.
|
||||
|
||||
# --- Öffentliche App + Admin + Portal -------------------------------
|
||||
team.kunde.de {
|
||||
reverse_proxy teamvis:3000
|
||||
}
|
||||
|
||||
# --- Supabase (REST + Storage) --------------------------------------
|
||||
# Muss öffentlich erreichbar sein: die Foto-URLs der Karten zeigen
|
||||
# hierher (https://supabase.kunde.de/storage/v1/object/public/...).
|
||||
# = Wert von SUPABASE_PUBLIC_URL in der .env.
|
||||
supabase.kunde.de {
|
||||
reverse_proxy kong:8000
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
# TeamVis — All-in-One Self-Hosting (App + Supabase)
|
||||
|
||||
## Schnellstart: `install.sh` (ein Schritt)
|
||||
|
||||
Für eine neue Instanz gibt es einen interaktiven Installer, der alles in einem
|
||||
Rutsch erledigt und beim Start fragt, woher die Datenbank kommt:
|
||||
|
||||
```bash
|
||||
bash deploy/selfhost/install.sh
|
||||
```
|
||||
|
||||
- **Modus 1 — bestehende/eigene Supabase** (Cloud oder self-hosted): es läuft nur
|
||||
die App (+ optional Caddy/TLS). Entspricht `docs/NEUKUNDE.md`, nur automatisiert.
|
||||
- **Modus 2 — alles mitinstallieren:** schlanker Supabase-Stack als Container
|
||||
(Postgres + PostgREST + Storage) + App + Caddy, **Single-Domain** (App,
|
||||
`/rest/v1`, `/storage/v1` alle unter `https://team.kunde.de` — eine Domain, ein
|
||||
Zertifikat). Kein vorab eingerichtetes Supabase nötig.
|
||||
|
||||
Das Skript erzeugt Schlüssel/Secrets, schreibt `.env` + `docker-compose.yml` +
|
||||
`Caddyfile`, spielt das Schema ein, macht in Modus 2 den **Rollen-Bootstrap +
|
||||
Grants automatisch** (der manuelle Block unter „Variante B" entfällt damit) und
|
||||
legt den ersten Admin an. Voraussetzungen: docker + compose v2, node 20+,
|
||||
openssl, DNS auf den Host, Ports 80/443 frei, `docker login git.zoesch.de`.
|
||||
|
||||
Variablen lassen sich vorab per ENV setzen (überspringt die Abfrage), z. B.
|
||||
`APP_DOMAIN=team.kunde.de ADMIN_EMAIL=chef@kunde.de MODE=2 bash deploy/selfhost/install.sh`.
|
||||
|
||||
> Die folgenden Abschnitte beschreiben die **manuellen** Varianten (zum
|
||||
> Verständnis / für Sonderfälle). Für den Normalfall reicht `install.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Manuell (Hintergrund)
|
||||
|
||||
**Option/Status:** ausgearbeitet **und end-to-end auf zfx-vps getestet**
|
||||
(Lean-Datenebene). Karte, vCard, Storage-Upload/-Abruf, anon-RLS und der
|
||||
Spalten-Revoke (0045) laufen gegen self-hosted Postgres + PostgREST +
|
||||
Storage. Der Testlauf hat drei Bootstrap-Lücken aufgedeckt — alle gelöst,
|
||||
siehe [Befunde](#befunde-aus-dem-testlauf-2026-06-07).
|
||||
|
||||
Diese Variante bringt mit **einem Compose-Stack** sowohl die TeamVis-App als
|
||||
auch eine eigene Supabase-Instanz hoch — der Kunde braucht **kein** vorab
|
||||
eingerichtetes Supabase (weder Cloud noch self-hosted). Gegensatz zum
|
||||
Standard-Onboarding (`docs/NEUKUNDE.md`), das eine **externe** Supabase
|
||||
voraussetzt.
|
||||
|
||||
---
|
||||
|
||||
## Was TeamVis von Supabase wirklich braucht
|
||||
|
||||
Analyse des Codes (`lib/supabase-server.ts`, alle `*.actions.ts`):
|
||||
|
||||
| Supabase-Komponente | Von TeamVis genutzt? | Wofür |
|
||||
|---|---|---|
|
||||
| **Postgres** | ✅ Pflicht | alle Daten |
|
||||
| **PostgREST** (`/rest/v1`) | ✅ Pflicht | sämtliche DB-Reads/Writes (`@supabase/supabase-js`) |
|
||||
| **Storage** (`/storage/v1`) | ✅ Pflicht | Buckets `employee-photos` + Branding, **öffentliche** URLs (`getPublicUrl`) |
|
||||
| GoTrue / Auth (`/auth/v1`) | ❌ | TeamVis nutzt **keine** Supabase-Auth (Admin = bcrypt+iron-session, Portal = Magic-Link) |
|
||||
| Realtime | ❌ | — |
|
||||
| imgproxy (Bild-Transforms) | ❌ | Karten nutzen `next/image`, keine Storage-Transforms |
|
||||
| Studio / Meta | ⚪ optional | bequemes DB-/SQL-UI für den Betreiber |
|
||||
|
||||
**Folge:** technisch genügen **Postgres + PostgREST + Storage + ein
|
||||
Pfad-Router**. Auth/Realtime/imgproxy sind verzichtbar.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────── Caddy (Auto-HTTPS) ───────────────┐
|
||||
Browser ── 443 ───▶ │ team.kunde.de → teamvis:3000 │
|
||||
(Karten, Admin) │ supabase.kunde.de → kong:8000 │
|
||||
└───────────────┬──────────────────┬───────────────┘
|
||||
│ │
|
||||
┌──────────▼─────┐ ┌────────▼─────────────────┐
|
||||
│ teamvis (App) │ │ Supabase-Stack (kong → │
|
||||
│ Next.js :3000 │──▶│ rest + storage + db …) │
|
||||
└────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
- Die **Foto-URLs der Karten zeigen auf `supabase.kunde.de`** (öffentliches
|
||||
`getPublicUrl`). Deshalb muss diese Domain **öffentlich per HTTPS**
|
||||
erreichbar sein — `next/image` erlaubt nur `https` auf
|
||||
`/storage/v1/object/public/**` (siehe `next.config.ts`, bereits
|
||||
vorbereitet: jeder https-Host auf diesem Pfad ist zulässig).
|
||||
- Das **App-Image ist unverändert** das SaaS-Image — alle Supabase-Werte
|
||||
kommen zur Laufzeit aus ENV (`lib/runtime-env.ts`). Kein Rebuild pro Kunde.
|
||||
|
||||
---
|
||||
|
||||
## Variante A — offizieller Supabase-Stack + Override (empfohlen)
|
||||
|
||||
Lehnt sich an den **getesteten** `supabase/docker`-Stack an und legt TeamVis
|
||||
per Override darüber. Vorteil: korrekte, aufeinander abgestimmte Image-Pins
|
||||
(Postgres/PostgREST/Storage/Kong) ohne eigenes Versions-Risiko.
|
||||
|
||||
### Schritte
|
||||
|
||||
```bash
|
||||
# 0) Voraussetzungen auf dem Server: docker + compose, node 20+, openssl,
|
||||
# DNS für team.kunde.de und supabase.kunde.de auf diese IP, Ports 80/443 frei.
|
||||
|
||||
# 1) Supabase-Stack holen
|
||||
git clone --depth 1 https://github.com/supabase/supabase
|
||||
cd supabase/docker
|
||||
cp .env.example .env
|
||||
|
||||
# 2) TeamVis-Override + Caddy + Zusatz-ENV hineinlegen
|
||||
REPO=/pfad/zum/team-stwhas
|
||||
cp $REPO/deploy/selfhost/docker-compose.override.yml .
|
||||
cp $REPO/deploy/selfhost/Caddyfile.example ./Caddyfile # Domains anpassen!
|
||||
cat $REPO/deploy/selfhost/.env.teamvis.example >> .env # Werte ausfüllen
|
||||
|
||||
# 3) In .env setzen/ersetzen:
|
||||
# POSTGRES_PASSWORD → starkes Passwort
|
||||
# SITE_URL=https://team.kunde.de
|
||||
# SUPABASE_PUBLIC_URL=https://supabase.kunde.de
|
||||
# API_EXTERNAL_URL=https://supabase.kunde.de
|
||||
# SESSION_SECRET → openssl rand -base64 48
|
||||
# (JWT_SECRET/ANON_KEY/SERVICE_ROLE_KEY macht bootstrap.sh in Schritt 4)
|
||||
|
||||
# 4) Bootstrap: Keys + Stack + Schema + erster Admin
|
||||
cp $REPO/deploy/selfhost/bootstrap.sh .
|
||||
TEAMVIS_REPO=$REPO ADMIN_EMAIL=chef@kunde.de bash bootstrap.sh
|
||||
```
|
||||
|
||||
`bootstrap.sh` erledigt: Schlüssel erzeugen (ersetzt die unsicheren
|
||||
Demo-Keys), `docker compose up -d`, auf gesunde DB warten,
|
||||
TeamVis-Schema (Migrations-Bündel) einspielen, ersten Admin anlegen.
|
||||
|
||||
### Optional schlanker (Footprint senken)
|
||||
|
||||
Der Volldownload startet ~10 Container. Nicht genutzte abschalten —
|
||||
in der `.env` bzw. per `docker compose` weglassen: **studio, auth
|
||||
(GoTrue), realtime, imgproxy, analytics/vector, meta, supavisor/pooler**.
|
||||
Übrig bleiben **db, rest, storage, kong** (+ teamvis, caddy). Das ist die
|
||||
empfohlene Ziel-Konfiguration für eine Einzel-Mandanten-Appliance —
|
||||
sollte aber im selben Probelauf mitgetestet werden (Kong-Routen + Storage
|
||||
hängen minimal an Auth-Annahmen).
|
||||
|
||||
---
|
||||
|
||||
## Variante B — komplett schlank (eigener Mini-Stack)
|
||||
|
||||
Nur `supabase/postgres:15.8.1.085` + `postgrest/postgrest:v14.12` +
|
||||
`supabase/storage-api:v1.60.4` + Caddy als Pfad-Router (kein Kong, kein
|
||||
GoTrue/Realtime/imgproxy). **Genau das wurde getestet** (siehe Befunde) und
|
||||
läuft — braucht aber einen Rollen-Bootstrap, den der offizielle Stack
|
||||
automatisch macht. Caddy-Router statt Kong:
|
||||
|
||||
```caddyfile
|
||||
:8000 {
|
||||
handle_path /rest/v1/* { reverse_proxy rest:3000 }
|
||||
handle_path /storage/v1/* { reverse_proxy storage:5000 }
|
||||
}
|
||||
```
|
||||
|
||||
**Pflicht-Bootstrap nach dem ersten Start** (einmalig, als `supabase_admin`,
|
||||
Passwort = `POSTGRES_PASSWORD`):
|
||||
|
||||
```sql
|
||||
-- 1) Rollen-Passwörter (das Image setzt nur supabase_admin auf POSTGRES_PASSWORD)
|
||||
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>';
|
||||
|
||||
-- 2) service_role-Rechte (auf echtem Supabase automatisch vorhanden)
|
||||
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;
|
||||
```
|
||||
|
||||
Reihenfolge: Passwörter (1) **vor** dem ersten Start von `rest`/`storage`,
|
||||
die Grants (2) **nach** dem Schema-Load. Wegen dieses Mehraufwands ist für
|
||||
die *erste* Kundeninstallation **Variante A empfohlen** (dort entfällt der
|
||||
ganze Block).
|
||||
|
||||
---
|
||||
|
||||
## Stolpersteine (wichtig)
|
||||
|
||||
1. **`SUPABASE_PUBLIC_URL` muss öffentlich + HTTPS sein** — sie steckt in den
|
||||
Foto-URLs der Karten. Interner Hostname (`http://kong:8000`) funktioniert
|
||||
**nicht** für die Bildanzeige.
|
||||
2. **SMTP-Kollision:** der Supabase-Stack belegt `SMTP_*` selbst (GoTrue).
|
||||
Die App liest deshalb `TEAMVIS_SMTP_*` (siehe Override) — nicht
|
||||
vermischen.
|
||||
3. **Reihenfolge:** Schema erst einspielen, wenn Storage einmal gestartet ist
|
||||
(Migration `0044` legt den Bucket in `storage.buckets` an). `bootstrap.sh`
|
||||
wartet darauf.
|
||||
4. **Keys sind JWTs:** `ANON_KEY`/`SERVICE_ROLE_KEY` sind mit `JWT_SECRET`
|
||||
signierte Tokens (`gen-keys.mjs`). Wird `JWT_SECRET` geändert, müssen beide
|
||||
Keys neu erzeugt werden.
|
||||
5. **Backups:** jetzt liegt die DB beim Kunden. Postgres-Volume + Storage-
|
||||
Volume sichern (`pg_dump` + `volumes/storage`). Beim externen Supabase war
|
||||
das deren Sache.
|
||||
6. **Modul-Rollout:** frische Instanz startet minimal (nur Visitenkarten).
|
||||
Danach unter **Admin → Funktionen** gestaffelt freischalten
|
||||
(siehe `docs/NEUKUNDE.md` §5a).
|
||||
|
||||
---
|
||||
|
||||
## Footprint (grob)
|
||||
|
||||
| | Container | RAM (Daumenregel) |
|
||||
|---|---|---|
|
||||
| Variante A voll | ~12 | ~2–3 GB |
|
||||
| Variante A schlank | 6 (db, rest, storage, kong, teamvis, caddy) | ~1–1.5 GB |
|
||||
| Externe Supabase (NEUKUNDE.md) | 2 (teamvis, caddy) | ~0.4 GB |
|
||||
|
||||
---
|
||||
|
||||
## Befunde aus dem Testlauf (2026-06-07)
|
||||
|
||||
Lean-Stack (db + rest + storage + Caddy-Router + app) auf zfx-vps hochgezogen,
|
||||
nur an localhost gebunden. Drei Bootstrap-Lücken gefunden:
|
||||
|
||||
1. **Basis-Schema fehlte** — die Migrationen legen `employees`/`admin_users`
|
||||
nicht an (nur additive `add column if not exists`). `bundle-migrations`
|
||||
bootstrappt eine leere DB also nicht. **Gelöst:** neue Migration
|
||||
`supabase/migrations/0000_base_schema.sql` (idempotent, auf
|
||||
Bestandsinstanzen ein No-Op). Betrifft auch den `NEUKUNDE.md`-Weg.
|
||||
2. **Rollen-Passwörter** — `supabase/postgres` setzt nur `supabase_admin` auf
|
||||
`POSTGRES_PASSWORD`; `authenticator`/`supabase_storage_admin` brauchen es
|
||||
manuell (Variante B). Variante A erledigt das.
|
||||
3. **service_role-Grants** — frisch erzeugte Tabellen geben `service_role`
|
||||
keine Rechte; auf echtem Supabase passiert das automatisch. Grants +
|
||||
`bypassrls` nötig (Variante B). Variante A erledigt das.
|
||||
|
||||
**Nach den Fixes verifiziert (alles grün):**
|
||||
|
||||
| Test | Ergebnis |
|
||||
|---|---|
|
||||
| Schema-Bündel (mit 0000) auf leerer DB | 0 Fehler, 23 Tabellen |
|
||||
| `/api/health` | `ok:true`, `db.ok:true` |
|
||||
| Öffentliche Karte `/<slug>` | HTTP 200 |
|
||||
| vCard `/api/vcard/<slug>` | HTTP 200 |
|
||||
| anon-REST: aktive MA lesen | ✅ (RLS erlaubt) |
|
||||
| anon-REST: Geheim-Spalte `phone_mobile` | `permission denied` ✅ (0045 greift) |
|
||||
| service_role-REST | ✅ (nach Grants) |
|
||||
| Storage-Upload + öffentlicher Abruf | HTTP 200, Inhalt korrekt |
|
||||
|
||||
**Noch offen für „voll kundenreif":** ein Lauf von **Variante A** (offizieller
|
||||
Stack — bestätigt, dass Punkt 2+3 dort automatisch wegfallen) sowie ein Test
|
||||
des Admin-Logins + Portal-Magic-Links über die echten HTTPS-Domains (Caddy/TLS
|
||||
war im localhost-Test nicht aktiv).
|
||||
|
||||
## Dateien hier
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `docker-compose.override.yml` | TeamVis-App + Caddy über dem Supabase-Stack |
|
||||
| `.env.teamvis.example` | Zusatz-ENV für die Supabase-.env |
|
||||
| `Caddyfile.example` | TLS-Ingress (App- + Supabase-Domain) |
|
||||
| `gen-keys.mjs` | erzeugt JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY |
|
||||
| `bootstrap.sh` | Keys + Stack + Schema + Admin in einem Rutsch |
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# =====================================================================
|
||||
# TeamVis All-in-One — Bootstrap (Referenz-Helfer)
|
||||
# =====================================================================
|
||||
# Bringt eine frische All-in-One-Instanz hoch:
|
||||
# 1. Keys erzeugen (falls noch Demo-Defaults in der Supabase-.env)
|
||||
# 2. Stack starten (Supabase + TeamVis + Caddy)
|
||||
# 3. TeamVis-Schema einspielen (Migrations-Bündel → Postgres)
|
||||
# 4. ersten Admin anlegen
|
||||
#
|
||||
# NICHT blind ausführen — Schritt für Schritt lesen. Erwartet:
|
||||
# - dieses Skript liegt im supabase/docker-Verzeichnis (neben der
|
||||
# docker-compose.yml + .env + dem Override)
|
||||
# - das TeamVis-Repo ist unter $TEAMVIS_REPO erreichbar (für die
|
||||
# Skripte bundle-migrations.mjs / create-admin.mjs)
|
||||
# - Node 20+, docker compose, openssl auf dem Host
|
||||
#
|
||||
# Aufruf: TEAMVIS_REPO=/pfad/zum/repo ADMIN_EMAIL=chef@kunde.de ./bootstrap.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${TEAMVIS_REPO:?Bitte TEAMVIS_REPO=/pfad/zum/team-stwhas-repo setzen}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:?Bitte ADMIN_EMAIL=… setzen}"
|
||||
ENV_FILE="./.env"
|
||||
|
||||
# --- 1. Keys ---------------------------------------------------------
|
||||
if grep -q "your-super-secret-jwt-token" "$ENV_FILE" 2>/dev/null; then
|
||||
echo "→ Erzeuge JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY …"
|
||||
KEYS=$(node "$REPO/deploy/selfhost/gen-keys.mjs")
|
||||
# Demo-Zeilen ersetzen (macOS/BSD-sed-kompatibel über awk).
|
||||
for k in JWT_SECRET ANON_KEY SERVICE_ROLE_KEY; do
|
||||
val=$(printf '%s\n' "$KEYS" | sed -n "s/^$k=//p")
|
||||
awk -v K="$k" -v V="$val" 'BEGIN{done=0}
|
||||
$0 ~ "^"K"=" && !done {print K"="V; done=1; next} {print}' "$ENV_FILE" > "$ENV_FILE.tmp"
|
||||
mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
done
|
||||
echo " ok — Keys in $ENV_FILE gesetzt."
|
||||
else
|
||||
echo "→ Keys schon gesetzt (keine Demo-Defaults gefunden) — überspringe."
|
||||
fi
|
||||
|
||||
# --- 2. Stack starten ------------------------------------------------
|
||||
echo "→ Starte Stack …"
|
||||
docker compose up -d
|
||||
echo "→ Warte auf gesunde Datenbank …"
|
||||
until docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1; do sleep 2; done
|
||||
# Storage initialisiert sein storage-Schema beim ersten Start — kurz warten.
|
||||
sleep 8
|
||||
|
||||
# --- 3. Schema einspielen -------------------------------------------
|
||||
echo "→ Spiele TeamVis-Schema ein (Migrations-Bündel) …"
|
||||
node "$REPO/scripts/bundle-migrations.mjs" > /tmp/teamvis-schema.sql
|
||||
docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < /tmp/teamvis-schema.sql
|
||||
echo " ok — Schema eingespielt."
|
||||
|
||||
# --- 4. Admin anlegen ------------------------------------------------
|
||||
echo "→ Lege Admin '$ADMIN_EMAIL' an (Passwort wird abgefragt) …"
|
||||
SUPABASE_PUBLIC_URL=$(sed -n 's/^SUPABASE_PUBLIC_URL=//p' "$ENV_FILE")
|
||||
SERVICE_ROLE_KEY=$(sed -n 's/^SERVICE_ROLE_KEY=//p' "$ENV_FILE")
|
||||
NEXT_PUBLIC_SUPABASE_URL="$SUPABASE_PUBLIC_URL" \
|
||||
SUPABASE_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
|
||||
node "$REPO/scripts/create-admin.mjs" "$ADMIN_EMAIL"
|
||||
|
||||
echo
|
||||
echo "Fertig. App: \$SITE_URL · Supabase: \$SUPABASE_PUBLIC_URL"
|
||||
echo "Danach unter Admin → Funktionen die Module gestaffelt freischalten."
|
||||
@@ -0,0 +1,77 @@
|
||||
# =====================================================================
|
||||
# TeamVis — All-in-One Self-Hosting (Override für den offiziellen
|
||||
# Supabase-Stack)
|
||||
# =====================================================================
|
||||
# Dieses Override legt zwei Dienste ÜBER den unveränderten
|
||||
# supabase/docker-Stack (github.com/supabase/supabase → docker/):
|
||||
# - teamvis : die App (gleiches Image wie SaaS, alles via Runtime-ENV)
|
||||
# - caddy : TLS-Ingress für App- und Supabase-Domain (Auto-HTTPS)
|
||||
#
|
||||
# Datei NEBEN die supabase/docker/docker-compose.yml legen (gleiches
|
||||
# Verzeichnis), dann startet `docker compose up -d` beide zusammen.
|
||||
# Service-Namen db/kong/storage stammen aus dem Supabase-Stack.
|
||||
#
|
||||
# WICHTIG zu SMTP: der Supabase-Stack belegt SMTP_* selbst (GoTrue-Mailer,
|
||||
# das TeamVis NICHT nutzt). Damit sich die beiden nicht in die Quere kommen,
|
||||
# liest die App ihre SMTP-Werte aus TEAMVIS_SMTP_*-Variablen — NICHT aus den
|
||||
# SMTP_*-Werten der Supabase-.env. Deshalb hier KEIN env_file, sondern
|
||||
# explizite environment-Map.
|
||||
|
||||
services:
|
||||
teamvis:
|
||||
image: git.zoesch.de/zfx-services/teamvis:${TEAMVIS_VERSION:-0.12.0}
|
||||
container_name: teamvis-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
kong:
|
||||
condition: service_started
|
||||
storage:
|
||||
condition: service_started
|
||||
environment:
|
||||
# Supabase-Anbindung — SUPABASE_PUBLIC_URL muss die ÖFFENTLICHE
|
||||
# HTTPS-URL sein (steht in den Foto-URLs der Karten, next/image
|
||||
# erlaubt nur https auf /storage/v1/object/public/**).
|
||||
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}
|
||||
# App-Session (>= 32 Zeichen, pro Instanz EINMALIG).
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
# Öffentliche Basis-URL der Karten/QR/vCard.
|
||||
NEXT_PUBLIC_SITE_URL: ${SITE_URL}
|
||||
# SMTP der App (eigene Variablen, s.o.). Fehlt es, landen
|
||||
# Magic-Links nur im Container-Log.
|
||||
SMTP_HOST: ${TEAMVIS_SMTP_HOST:-}
|
||||
SMTP_PORT: ${TEAMVIS_SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${TEAMVIS_SMTP_SECURE:-}
|
||||
SMTP_USER: ${TEAMVIS_SMTP_USER:-}
|
||||
SMTP_PASS: ${TEAMVIS_SMTP_PASS:-}
|
||||
SMTP_FROM_EMAIL: ${TEAMVIS_SMTP_FROM_EMAIL:-noreply@example.com}
|
||||
SMTP_FROM_NAME: ${TEAMVIS_SMTP_FROM_NAME:-TeamVis}
|
||||
# Optional zusätzliche next/image-Hosts (i.d.R. nicht nötig, da
|
||||
# next.config bereits jeden https-Host auf dem public-Storage-Pfad
|
||||
# erlaubt).
|
||||
SUPABASE_IMAGE_HOSTS: ${SUPABASE_IMAGE_HOSTS:-}
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: teamvis-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- teamvis
|
||||
- kong
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// =====================================================================
|
||||
// Erzeugt die Schlüssel für eine self-gehostete Supabase-Instanz:
|
||||
// - JWT_SECRET (zufällig, >= 40 Zeichen)
|
||||
// - ANON_KEY (HS256-JWT, role=anon, 10 Jahre gültig)
|
||||
// - SERVICE_ROLE_KEY (HS256-JWT, role=service_role, 10 Jahre gültig)
|
||||
//
|
||||
// anon/service-Key sind KEINE Passwörter, sondern JWTs, die mit dem
|
||||
// JWT_SECRET signiert sind — exakt wie bei Supabase Cloud. PostgREST und
|
||||
// Storage validieren sie lokal gegen das Secret (kein GoTrue nötig).
|
||||
//
|
||||
// Nutzung:
|
||||
// node gen-keys.mjs # neues Secret + passende Keys
|
||||
// node gen-keys.mjs <bestehendes-JWT_SECRET> # Keys zu vorhandenem Secret
|
||||
//
|
||||
// Ausgabe ist direkt in die Supabase-.env kopierbar.
|
||||
// Pure Node-crypto, keine Abhängigkeiten.
|
||||
// =====================================================================
|
||||
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
function b64url(input) {
|
||||
return Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function signJwt(payload, secret) {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const head = b64url(JSON.stringify(header));
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const data = `${head}.${body}`;
|
||||
const sig = createHmac("sha256", secret).update(data).digest("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
return `${data}.${sig}`;
|
||||
}
|
||||
|
||||
const secret = process.argv[2] || randomBytes(48).toString("base64url");
|
||||
if (secret.length < 32) {
|
||||
console.error("JWT_SECRET muss mindestens 32 Zeichen lang sein.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60 * 60 * 24 * 365 * 10; // 10 Jahre
|
||||
|
||||
const anon = signJwt({ role: "anon", iss: "supabase", iat, exp }, secret);
|
||||
const service = signJwt(
|
||||
{ role: "service_role", iss: "supabase", iat, exp },
|
||||
secret,
|
||||
);
|
||||
|
||||
console.log(`JWT_SECRET=${secret}`);
|
||||
console.log(`ANON_KEY=${anon}`);
|
||||
console.log(`SERVICE_ROLE_KEY=${service}`);
|
||||
Executable
+479
@@ -0,0 +1,479 @@
|
||||
#!/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)"
|
||||
|
||||
# ── 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: git.zoesch.de/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: git.zoesch.de/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 "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: git.zoesch.de/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/__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 git.zoesch.de' nachholen."
|
||||
Reference in New Issue
Block a user