Add full Docker deployment: setup.sh, update.sh, healthcheck, TURN support

- setup.sh: interactive/non-interactive one-shot installer (build, DB
  healthcheck, migrate, seed, start), idempotent secret generation, NPM
  reverse-proxy network auto-detection and optional join, optional
  AUTO_UPDATE cron install.
- update.sh: release-tag-gated updates only (never bare main), DB backup
  with retention before every update, lock file against concurrent runs,
  automatic code rollback on failed post-update healthcheck.
- Dockerfile: multi-stage build, non-root user, built-in HEALTHCHECK against
  the new /api/health route, wholesale COPY so new source dirs (e.g.
  scripts/) never silently go missing at runtime.
- docker-compose.yml: internal anouma-network (configurable), named volume
  for Postgres, app depends_on postgres healthy, no unnecessary published
  ports; docker-compose.override.yml.example documents joining an existing
  NPM network without ever touching NPM itself.
- Fix host-detection: isHost was Boolean(user), wrongly granting host
  privileges to logged-in customers; now checks user.collection === "users".
- Wire configurable STUN/TURN servers through to the WebRTC client
  (lib/meeting/iceServers.ts) so a TURN server can be added later via env
  vars only, no code changes.
- DEPLOYMENT.md, updated README.md and .env.example documenting the whole
  flow: NPM integration, env vars, WebRTC, updates, backups, rollback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 21:50:28 +02:00
co-authored by Claude Sonnet 5
parent d3d53e68a9
commit 50c39a70e0
16 changed files with 886 additions and 68 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
.git
.env
.env.*
!.env.example
media
backups
*.log
*.tsbuildinfo
*.rpm
*.deb
*.AppImage
README.md
DEPLOYMENT.md
+56 -16
View File
@@ -1,28 +1,68 @@
# PostgreSQL connection string used by Payload (see docker-compose.yml for a # ── Database ────────────────────────────────────────────────────────────
# local database, or use a hosted Postgres such as Neon/Supabase). # PostgreSQL connection string used by Payload. setup.sh fills this in
DATABASE_URI=postgresql://postgres:postgres@127.0.0.1:5432/anouma # automatically (host "postgres" when running the full stack via Docker
# Compose, matching POSTGRES_USER/PASSWORD/DB below). For a host-only Next
# dev server against the Dockerized Postgres, use "127.0.0.1" instead.
DATABASE_URI=postgresql://postgres:postgres@postgres:5432/anouma
# Long random string used to sign Payload's auth tokens/cookies. # Only used by the "postgres" service in docker-compose.yml — keep these in
# Generate one with: openssl rand -base64 48 # sync with DATABASE_URI above (setup.sh does this for you).
POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-a-long-random-secret
POSTGRES_DB=anouma
# ── Core secrets ────────────────────────────────────────────────────────
# Signs Payload's auth tokens/cookies. Generate with: openssl rand -base64 48
PAYLOAD_SECRET=replace-with-a-long-random-secret PAYLOAD_SECRET=replace-with-a-long-random-secret
# Public URL of this site — used by Payload for absolute admin/media links, # Signs meeting join tokens (kept separate from PAYLOAD_SECRET on purpose).
# and to build the meeting join link inside reminder emails. # Generate with: openssl rand -base64 48
# Set this to the real domain in production (e.g. https://anouma.org).
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Long random string used to sign meeting join tokens (separate from
# PAYLOAD_SECRET on purpose). Generate one with: openssl rand -base64 48
MEETING_SESSION_SECRET=replace-with-a-long-random-secret MEETING_SESSION_SECRET=replace-with-a-long-random-secret
# Secret the external cron job must send as "Authorization: Bearer <value>" # Sent as "Authorization: Bearer <value>" by the external cron job that
# to trigger /api/cron/event-reminders. Generate with: openssl rand -hex 32 # triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
CRON_SECRET=replace-with-a-long-random-secret CRON_SECRET=replace-with-a-long-random-secret
# SMTP settings for the existing ANOUMA mail system (reminder emails). # ── Public URL ──────────────────────────────────────────────────────────
# No external newsletter service — plain SMTP via nodemailer. # Used by Payload for absolute admin/media links, and to build meeting join
# links inside emails. Set this to the real domain in production.
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Port the app container publishes on the host (behind a reverse proxy this
# usually doesn't need to be reachable directly — see DEPLOYMENT.md).
APP_PORT=3000
# ── Email (SMTP) ────────────────────────────────────────────────────────
# The existing ANOUMA mail system — no external newsletter service.
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM="ANOUMA <support@anouma.org>" SMTP_FROM="ANOUMA <support@anouma.org>"
# ── WebRTC (STUN/TURN) ──────────────────────────────────────────────────
# P2P works with just STUN for most networks. Add a TURN server later for
# restrictive NATs/firewalls — no code changes needed, just set these.
STUN_SERVER=stun:stun.l.google.com:19302
TURN_SERVER=
TURN_USERNAME=
TURN_PASSWORD=
# ── Docker networking ───────────────────────────────────────────────────
# Internal network name for ANOUMA's own containers.
DOCKER_NETWORK=anouma-network
# Name of an existing external Docker network to join (e.g. Nginx Proxy
# Manager's network) so a reverse proxy can reach the app container
# directly. Leave empty if you don't use one — setup.sh auto-detects this.
NPM_NETWORK=
# ── Updates & backups (used by update.sh) ──────────────────────────────
# When true, setup.sh installs a cron entry that checks for new releases
# and updates automatically. Off by default — update.sh can always be run
# manually regardless of this setting.
AUTO_UPDATE=false
# How long (days) to keep database backups created by update.sh before
# deleting them.
BACKUP_RETENTION_DAYS=14
+6
View File
@@ -49,3 +49,9 @@ next-env.d.ts
*.deb *.deb
*.AppImage *.AppImage
# docker / deployment (machine-specific, generated by setup.sh)
docker-compose.override.yml
/backups
.update.lock
.installed-version
+158
View File
@@ -0,0 +1,158 @@
# ANOUMA — Deployment
Full Docker-based deployment guide. For local development without Docker (running `next dev` directly against a Dockerized Postgres), see `README.md` instead.
## Quick start
```bash
git clone https://git.maro.run/maro/anouma.git
cd anouma
./setup.sh
```
That's it — `setup.sh` builds the images, starts Postgres, waits for it to be healthy, runs migrations, optionally seeds the original ANOUMA content, starts the app, and prints a status summary. Docker and Docker Compose are the only host requirements; Node.js/npm are **not** needed on the host — everything runs inside the `app` container.
Run `./setup.sh --non-interactive` for sensible defaults with no prompts (useful for scripted/CI installs), or `--skip-seed` to skip content seeding.
`setup.sh` is safe to re-run on an existing installation — it never overwrites a secret that's already in `.env`, never deletes a volume, network or container, and never touches an existing Nginx Proxy Manager (or any other container) beyond optionally joining its network.
## Architecture
```
docker-compose.yml
├── postgres PostgreSQL 16, persisted in the "pgdata" volume
└── app Next.js + Payload CMS + WebRTC signaling (server.ts)
— one process, one container. There is no separate
"signaling" service: the WebSocket signaling server is
attached to the same custom Node server that serves the
website and admin panel (see server.ts), so it scales and
deploys as a single unit.
```
Both containers join the internal `anouma-network` (name configurable via `DOCKER_NETWORK` in `.env`). The `app` container optionally also joins an external reverse-proxy network — see below.
## Nginx Proxy Manager (or any reverse proxy)
`setup.sh` looks for a running Nginx Proxy Manager container (or a Docker network whose name looks like one) and, if found, asks whether to join its network. If you'd rather do this manually (or NPM wasn't running yet when you set up ANOUMA):
1. Find its network: `docker network ls`
2. Set `NPM_NETWORK=<that-name>` in `.env`
3. `cp docker-compose.override.yml.example docker-compose.override.yml`
4. `docker compose up -d` — Compose picks up `docker-compose.override.yml` automatically
`docker-compose.override.yml` is machine-specific and gitignored on purpose.
In Nginx Proxy Manager, add a Proxy Host:
| Field | Value |
| --- | --- |
| Domain | your domain, e.g. `anouma.org` |
| Scheme | `http` |
| Forward Host | `app` (the Compose service name — reachable by name once on the same network) |
| Forward Port | `3000` |
| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) |
Once a reverse proxy reaches the container directly over the shared network, you can remove the `ports:` mapping for `app` in `docker-compose.yml` so the app isn't also reachable directly on the host.
If no reverse proxy is configured, ANOUMA still works standalone — the app is published on `http://localhost:${APP_PORT:-3000}` (bound to `127.0.0.1` by default; open that up in `docker-compose.yml` if you need it reachable from outside without a proxy).
## Environment variables
See `.env.example` for the full list with inline explanations. Only variables the application actually reads are defined — highlights:
| Variable | Purpose |
| --- | --- |
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection — kept in sync automatically by `setup.sh` |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated on first run, never overwritten afterwards |
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site (used for admin/media links and email links) |
| `SMTP_*` | The existing ANOUMA mail system (booking/reminder emails) |
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers — see below |
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names (see above) |
| `AUTO_UPDATE`, `BACKUP_RETENTION_DAYS` | See Updates below |
Secrets are only ever written to `.env` on your server, never committed (`.gitignore` excludes `.env*` except `.env.example`).
## WebRTC: signaling, STUN, TURN
```
Client A Client B
│ │
│ WebSocket (wss://…/ws/signaling) │
▼ ▼
Signaling server (in the "app" container)
relays only small JSON offer/answer/ICE
messages — never touches audio/video/screen
Client A ═══════════ P2P WebRTC ═══════════ Client B
Audio / Video / Screen
```
The signaling server never sees media. By default, clients use Google's public STUN server (`STUN_SERVER` in `.env`), which is enough for most networks. Behind strict NATs/corporate firewalls, P2P via STUN alone can fail — add a TURN server by setting `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` in `.env` and restarting the app container; no code changes are required. (This project doesn't run a TURN server itself — coturn is a common self-hosted option if you need one.)
## Updates
```bash
./update.sh # update to the latest release tag
./update.sh v1.2.0 # update to a specific tag
```
Update flow: backup → fetch tags → checkout the release → rebuild → migrate → restart → healthcheck. If the healthcheck fails, the **code** is automatically rolled back to the previous version (database migrations are not reverted — write migrations to be forward-compatible; see Rollback below).
Only real release tags (`vX.Y.Z`) are deployed — `update.sh` deliberately never force-deploys whatever happens to be on `main`. If no tags exist yet in the repository, it does nothing and says so.
An update lock (`.update.lock`) prevents two updates from running concurrently.
### Automatic updates
Off by default (`AUTO_UPDATE=false`). Set `AUTO_UPDATE=true` in `.env` and re-run `./setup.sh` to install a cron job that checks for new releases every 30 minutes and updates automatically when one appears — otherwise it's a no-op. Disable again by setting `AUTO_UPDATE=false` and removing the `# anouma-auto-update` line from `crontab -e`.
### Backups
Every update creates `backups/database-YYYY-MM-DD-HHMM.sql.gz` before touching anything. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically — the backup just created is never deleted, even if retention is set very low. Uploaded media lives in the `./media` bind mount, which isn't touched by updates at all.
### Rollback
Automatic on a failed post-update healthcheck (see above). To roll back manually:
```bash
git checkout vX.Y.Z
docker compose build
docker compose up -d
```
If a migration from the failed release isn't backward-compatible, restore the pre-update backup instead:
```bash
gunzip -c backups/database-2026-08-25-1430.sql.gz | docker compose exec -T postgres psql -U postgres anouma
```
## Persistent data
Nothing about rebuilding or updating containers ever deletes data:
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
- **Uploaded media** — the `./media` bind mount, independent of the `app` container's lifecycle.
- **Backups** — the `./backups` directory on the host.
`setup.sh` and `update.sh` never run `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v` — none of the scripts in this repo do.
## Healthchecks
- **app**: `GET /api/health` (built into the Docker image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
- **postgres**: `pg_isready`.
`docker compose ps` shows both statuses.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
| App container unhealthy | `docker compose logs app`, then `curl http://localhost:3000/api/health` from inside the network (`docker compose exec app wget -qO- http://127.0.0.1:3000/api/health`) |
| Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) |
| Reverse proxy shows a 502/connection reset on the call page | Websockets Support isn't enabled on the Nginx Proxy Manager proxy host |
| `update.sh` says "uncommittete Änderungen" | Someone edited files directly on the server outside of a release — `git status` to see what, then commit/stash before updating |
+21 -19
View File
@@ -1,16 +1,19 @@
# Runs a custom Node server (server.ts) for the WebRTC signaling WebSocket, # Multi-stage build for the ANOUMA app (Next.js + Payload CMS + WebRTC
# so this can't use Next's "standalone" output — we ship the full app + # signaling, all served by the custom server.ts — see next.config.ts for why
# node_modules instead. DATABASE_URI/PAYLOAD_SECRET etc. are only needed at # this can't use Next's "standalone" output). DATABASE_URI/PAYLOAD_SECRET/etc.
# runtime, not at build time (see docker-compose.yml and .env.example). # are only needed at *runtime*, not at build time (see docker-compose.yml and
# .env.example) — this image builds without a database connection.
FROM node:22-alpine AS base FROM node:22-alpine AS base
# ---- deps: install once, cached as its own layer -------------------------
FROM base AS deps FROM base AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
# ---- builder: full source + production build ------------------------------
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
@@ -18,6 +21,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build
# ---- runner: the actual runtime image --------------------------------------
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -26,21 +30,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules # Copied wholesale (not a hand-picked list of directories) so newly added
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next # source folders (collections, scripts, lib/*, components/*, ...) are never
COPY --from=builder --chown=nextjs:nodejs /app/public ./public # silently missing at runtime — .dockerignore already excludes what doesn't
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts # belong in the image (node_modules is re-added explicitly below, .next is
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib # the build output we do want).
COPY --from=builder --chown=nextjs:nodejs /app/payload.config.ts ./payload.config.ts COPY --from=builder --chown=nextjs:nodejs /app /app
COPY --from=builder --chown=nextjs:nodejs /app/collections ./collections
COPY --from=builder --chown=nextjs:nodejs /app/globals ./globals # Writable at runtime, independent of the image — actual data lives in the
COPY --from=builder --chown=nextjs:nodejs /app/access ./access # Docker volumes mounted over these paths (see docker-compose.yml).
COPY --from=builder --chown=nextjs:nodejs /app/fields ./fields RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
COPY --from=builder --chown=nextjs:nodejs /app/components ./components
COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./next.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
RUN mkdir -p media && chown nextjs:nodejs media
USER nextjs USER nextjs
@@ -48,4 +47,7 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["npm", "start"] CMD ["npm", "start"]
+21 -5
View File
@@ -38,17 +38,33 @@ curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-r
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt. Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt.
## Setup ## Setup (Produktion / Deployment)
Der empfohlene Weg ist vollständig dockerisiert und braucht auf dem Host weder Node noch npm:
```bash
git clone https://git.maro.run/maro/anouma.git
cd anouma
./setup.sh
```
`setup.sh` fragt interaktiv nach Domain, ob eine bestehende Nginx Proxy Manager-Instanz eingebunden werden soll usw., generiert fehlende Secrets automatisch und überschreibt nie bereits gesetzte. Baut Images, startet Postgres, wartet auf dessen Healthcheck, migriert, seedet optional die Inhalte und startet die App. `./setup.sh --non-interactive` läuft ohne Rückfragen mit sinnvollen Defaults.
Updates auf ein neues Release: `./update.sh` (nur echte Release-Tags, nie ungetaggte `main`-Commits; Backup vor jedem Update, automatischer Rollback bei fehlgeschlagenem Healthcheck).
Für alle Details (NPM-Reverse-Proxy-Einrichtung, Environment-Variablen, WebRTC/STUN/TURN, Backups, Rollback, Auto-Updates, Troubleshooting) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
## Lokale Entwicklung (ohne Docker für die App)
```bash ```bash
npm install npm install
cp .env.example .env cp .env.example .env
# .env ausfüllen: DATABASE_URI, PAYLOAD_SECRET (z. B. mit `openssl rand -base64 48`) # .env ausfüllen: DATABASE_URI auf 127.0.0.1 statt "postgres" setzen, PAYLOAD_SECRET (z. B. mit `openssl rand -base64 48`)
``` ```
### Datenbank ### Datenbank
Lokal per Docker: Nur Postgres per Docker, die App läuft direkt auf dem Host:
```bash ```bash
docker compose up -d postgres docker compose up -d postgres
@@ -73,7 +89,7 @@ npm run seed
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen. Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
### Produktion ### Produktions-Build ohne Docker
```bash ```bash
npm run build npm run build
@@ -81,7 +97,7 @@ npm run migrate # wendet Datenbank-Migrationen an
npm run start npm run start
``` ```
Mit Docker: `docker build -t anouma .` (baut die App und startet sie über den eigenen `server.ts`-Server statt `next start`, wegen des WebSocket-Signalings), `.env` per `--env-file` oder `docker-compose.yml` (Service `app`, aktuell auskommentiert) bereitstellen. Für einen vollständig dockerisierten Produktionsbetrieb (App + Datenbank, Healthchecks, Reverse-Proxy-Integration) siehe oben bzw. **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
## Weitere Skripte ## Weitere Skripte
+3 -1
View File
@@ -39,7 +39,9 @@ export default async function EventDetailPage({ params }: Args) {
if (event.isOnline) { if (event.isOnline) {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers() }); const { user } = await payload.auth({ headers: await headers() });
const isHost = Boolean(user); // Must check the collection, not just presence — a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users";
const settings = await payload.findGlobal({ slug: "meeting-settings" }); const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime); const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime const end = event.endTime
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { sql } from "drizzle-orm";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
/**
* Used by the Docker healthcheck (see docker-compose.yml) and by
* setup.sh/update.sh to confirm the app can actually reach Postgres before
* being considered "up" — not just that the Node process is listening.
*/
export async function GET() {
try {
const payload = await getPayload({ config });
await payload.db.drizzle.execute(sql`SELECT 1`);
return NextResponse.json({ status: "ok", database: "connected" });
} catch (error) {
console.error("healthcheck failed", error);
return NextResponse.json(
{ status: "error", database: "unreachable" },
{ status: 503 },
);
}
}
@@ -4,6 +4,7 @@ import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token"; import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status"; import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
import { getIceServers } from "@/lib/meeting/iceServers";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -46,7 +47,9 @@ export async function POST(request: Request, { params }: Args) {
} }
const { user } = await payload.auth({ headers: request.headers }); const { user } = await payload.auth({ headers: request.headers });
const isHost = Boolean(user); // Must check the collection, not just presence — a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users";
if (!isHost) { if (!isHost) {
if (!password) { if (!password) {
@@ -89,6 +92,7 @@ export async function POST(request: Request, { params }: Args) {
participantId, participantId,
role: isHost ? "host" : "participant", role: isHost ? "host" : "participant",
eventTitle: event.title, eventTitle: event.title,
iceServers: getIceServers(),
}); });
} catch (error) { } catch (error) {
console.error("meeting join failed", error); console.error("meeting join failed", error);
+4 -2
View File
@@ -6,7 +6,7 @@ import { VideoTile } from "./VideoTile";
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm"; import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol"; import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }]; const DEFAULT_ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null }; type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
@@ -48,7 +48,9 @@ export function CallRoom({ slug }: { slug: string }) {
const createPeerConnection = useCallback( const createPeerConnection = useCallback(
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => { (participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); const pc = new RTCPeerConnection({
iceServers: sessionRef.current?.iceServers?.length ? sessionRef.current.iceServers : DEFAULT_ICE_SERVERS,
});
pcsRef.current.set(participantId, pc); pcsRef.current.set(participantId, pc);
localStreamRef.current?.getTracks().forEach((track) => { localStreamRef.current?.getTracks().forEach((track) => {
+2
View File
@@ -15,6 +15,7 @@ export type StoredMeetingSession = {
name: string; name: string;
eventTitle: string; eventTitle: string;
eventSlug: string; eventSlug: string;
iceServers: RTCIceServer[];
}; };
export function JoinForm({ slug }: { slug: string }) { export function JoinForm({ slug }: { slug: string }) {
@@ -49,6 +50,7 @@ export function JoinForm({ slug }: { slug: string }) {
name, name,
eventTitle: data.eventTitle, eventTitle: data.eventTitle,
eventSlug: slug, eventSlug: slug,
iceServers: data.iceServers,
}; };
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session)); sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
router.push(`/termine/${slug}/call`); router.push(`/termine/${slug}/call`);
+25
View File
@@ -0,0 +1,25 @@
# Example override for running ANOUMA behind an existing reverse proxy (e.g.
# Nginx Proxy Manager) that has its own Docker network.
#
# setup.sh generates docker-compose.override.yml automatically when it
# detects such a network — this file is just the committed reference/manual
# fallback. Docker Compose picks up docker-compose.override.yml automatically
# alongside docker-compose.yml, no extra -f flags needed.
#
# To use manually: copy this to docker-compose.override.yml and set
# NPM_NETWORK in .env to your proxy's actual network name (find it with
# `docker network ls`).
networks:
npm-network:
name: ${NPM_NETWORK}
external: true
services:
app:
networks:
- anouma-network
- npm-network
# No need to publish the port to the host when a reverse proxy reaches
# the container directly over the shared network — remove/comment the
# "ports" mapping in docker-compose.yml if so.
+43 -24
View File
@@ -1,36 +1,55 @@
networks:
anouma-network:
name: ${DOCKER_NETWORK:-anouma-network}
volumes:
pgdata:
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
networks:
- anouma-network
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: anouma POSTGRES_DB: ${POSTGRES_DB:-anouma}
ports:
- "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
# Not published to the host by default — only reachable from other
# containers on anouma-network. Uncomment to reach it from the host too
# (e.g. with a GUI DB client) during development:
# ports:
# - "127.0.0.1:5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 20
# Optional: run the Next.js app in Docker too. For local development it's
# usually simpler to run `npm run dev` on the host against the Postgres
# container above (DATABASE_URI host: 127.0.0.1, as in .env.example).
# To run the whole stack in Docker instead, uncomment this service and set
# DATABASE_URI's host to `postgres` (the service name) in your .env file.
# app:
# build: .
# restart: unless-stopped
# ports:
# - "3000:3000"
# env_file:
# - .env
# depends_on:
# postgres:
# condition: service_healthy
app:
build: .
restart: unless-stopped
networks:
- anouma-network
env_file:
- .env
# DATABASE_URI in .env must point at the "postgres" service name, e.g.
# postgresql://postgres:<password>@postgres:5432/anouma — setup.sh sets
# this up for you automatically.
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
volumes: volumes:
pgdata: - ./media:/app/media
depends_on:
postgres:
condition: service_healthy
# Container-level healthcheck is inherited from the Dockerfile's
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
# connectivity) — nothing to duplicate here.
# Optional external network to a reverse proxy (e.g. Nginx Proxy Manager)
# is attached via docker-compose.override.yml, generated by setup.sh only
# when such a network is actually detected — see DEPLOYMENT.md. Nothing
# here assumes NPM (or any reverse proxy) exists.
+22
View File
@@ -0,0 +1,22 @@
/**
* Builds the WebRTC ICE server list from environment variables so a TURN
* server can be added later without any code changes (see DEPLOYMENT.md).
* P2P works fine with just STUN for most networks; TURN becomes necessary
* behind restrictive NATs/firewalls.
*/
export function getIceServers(): RTCIceServer[] {
const servers: RTCIceServer[] = [
{ urls: process.env.STUN_SERVER || "stun:stun.l.google.com:19302" },
];
const turnServer = process.env.TURN_SERVER;
if (turnServer) {
servers.push({
urls: turnServer,
username: process.env.TURN_USERNAME || undefined,
credential: process.env.TURN_PASSWORD || undefined,
});
}
return servers;
}
Executable
+338
View File
@@ -0,0 +1,338 @@
#!/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 ""
# ---------------------------------------------------------------------------
# 14: 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
# ---------------------------------------------------------------------------
# 56: 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
# ---------------------------------------------------------------------------
# 710: .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
# ---------------------------------------------------------------------------
# 911: 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
# ---------------------------------------------------------------------------
# 1415: 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 ""
Executable
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# ANOUMA — safe, release-based update.
#
# ./update.sh interactive update to the latest release tag
# ./update.sh --auto used by the AUTO_UPDATE cron job — silently
# does nothing if already on the latest release
# ./update.sh vX.Y.Z update to a specific tag
#
# Never deletes volumes. Never force-deploys an untagged commit from main —
# only real release tags (vX.Y.Z) are deployed. Backs up the database before
# touching anything, and rolls the *code* back (not the database schema —
# migrations should be forward-compatible, see DEPLOYMENT.md) if the
# post-update healthcheck fails.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
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"; exit 1; }
command -v git >/dev/null 2>&1 || fail "Git wird für release-basierte Updates benötigt."
command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden."
COMPOSE="docker compose"; docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose"
AUTO_MODE=false
TARGET_VERSION=""
for arg in "$@"; do
case "$arg" in
--auto) AUTO_MODE=true ;;
v*) TARGET_VERSION="$arg" ;;
esac
done
# ---------------------------------------------------------------------------
# Update lock — prevents two updates (e.g. a manual one and the cron job)
# from running at the same time.
# ---------------------------------------------------------------------------
LOCK_FILE="$(pwd)/.update.lock"
if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then
log_warn "Update already running."
exit 0
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
[ -f .env ] || fail ".env nicht gefunden — bitte zuerst ./setup.sh ausführen."
[ -d .git ] || fail "Kein Git-Repository — Updates funktionieren nur in einem Checkout von https://git.maro.run/maro/anouma.git."
if [ -n "$(git status --porcelain)" ]; then
fail "Das Arbeitsverzeichnis hat uncommittete Änderungen — Update abgebrochen, um nichts zu überschreiben."
fi
CURRENT_REF="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
log_info "Aktuelle Version: $CURRENT_REF"
log_info "Prüfe auf neue Releases …"
git fetch --tags --quiet origin
if [ -n "$TARGET_VERSION" ]; then
NEW_VERSION="$TARGET_VERSION"
else
# Highest vX.Y.Z tag, sorted as real version numbers (not alphabetically).
NEW_VERSION="$(git tag -l 'v*' | sort -t. -k1.2,1n -k2,2n -k3,3n | tail -n1)"
fi
if [ -z "$NEW_VERSION" ]; then
log_info "Keine Release-Tags im Repository gefunden — nichts zu deployen (main wird bewusst nicht automatisch deployt)."
exit 0
fi
if [ "$NEW_VERSION" = "$CURRENT_REF" ]; then
log_info "Bereits auf dem neuesten Release ($CURRENT_REF)."
exit 0
fi
if [ "$AUTO_MODE" = true ]; then
log_info "Neues Release gefunden: $NEW_VERSION (aktuell: $CURRENT_REF) — automatisches Update wird gestartet."
fi
log_info "Update: $CURRENT_REF$NEW_VERSION"
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
mkdir -p backups
BACKUP_FILE="backups/database-$(date +%Y-%m-%d-%H%M).sql.gz"
log_info "Erstelle Datenbank-Backup: $BACKUP_FILE"
set -a; source .env; set +a
if $COMPOSE ps -q postgres >/dev/null 2>&1 && [ -n "$($COMPOSE ps -q postgres)" ]; then
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$BACKUP_FILE"
log_success "Backup erstellt ($(du -h "$BACKUP_FILE" | cut -f1))."
else
log_warn "Datenbank-Container läuft nicht — Backup übersprungen."
fi
# Retention: delete backups older than BACKUP_RETENTION_DAYS, but never the
# one we just created.
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
find backups -name 'database-*.sql.gz' -mtime "+${RETENTION_DAYS}" -not -name "$(basename "$BACKUP_FILE")" -delete 2>/dev/null || true
# ---------------------------------------------------------------------------
# Deploy
# ---------------------------------------------------------------------------
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
}
deploy_ref() {
local ref="$1"
git checkout --quiet "$ref"
$COMPOSE build
$COMPOSE run --rm app npm run migrate
$COMPOSE up -d
}
log_info "Checke $NEW_VERSION aus und baue neu …"
if deploy_ref "$NEW_VERSION" && wait_healthy app 60; then
log_success "Update auf $NEW_VERSION erfolgreich."
echo "$NEW_VERSION" > .installed-version
exit 0
fi
log_error "Healthcheck nach Update auf $NEW_VERSION fehlgeschlagen — rolle Code auf $CURRENT_REF zurück."
log_warn "Datenbank-Migrationen werden dabei NICHT rückgängig gemacht (siehe DEPLOYMENT.md) — falls $NEW_VERSION eine nicht abwärtskompatible Migration enthielt, stelle das Backup manuell wieder her: $BACKUP_FILE"
if deploy_ref "$CURRENT_REF" && wait_healthy app 60; then
log_warn "Rollback auf $CURRENT_REF erfolgreich — die Anwendung läuft wieder auf der vorherigen Version."
exit 1
fi
fail "Rollback ebenfalls fehlgeschlagen — bitte manuell prüfen: $COMPOSE logs app"