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:
@@ -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
@@ -1,28 +1,68 @@
|
||||
# PostgreSQL connection string used by Payload (see docker-compose.yml for a
|
||||
# local database, or use a hosted Postgres such as Neon/Supabase).
|
||||
DATABASE_URI=postgresql://postgres:postgres@127.0.0.1:5432/anouma
|
||||
# ── Database ────────────────────────────────────────────────────────────
|
||||
# PostgreSQL connection string used by Payload. setup.sh fills this in
|
||||
# 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.
|
||||
# Generate one with: openssl rand -base64 48
|
||||
# Only used by the "postgres" service in docker-compose.yml — keep these in
|
||||
# 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
|
||||
|
||||
# Public URL of this site — used by Payload for absolute admin/media links,
|
||||
# and to build the meeting join link inside reminder emails.
|
||||
# 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
|
||||
# Signs meeting join tokens (kept separate from PAYLOAD_SECRET on purpose).
|
||||
# Generate with: openssl rand -base64 48
|
||||
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# Secret the external cron job must send as "Authorization: Bearer <value>"
|
||||
# to trigger /api/cron/event-reminders. Generate with: openssl rand -hex 32
|
||||
# Sent as "Authorization: Bearer <value>" by the external cron job that
|
||||
# triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
# SMTP settings for the existing ANOUMA mail system (reminder emails).
|
||||
# No external newsletter service — plain SMTP via nodemailer.
|
||||
# ── Public URL ──────────────────────────────────────────────────────────
|
||||
# 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_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
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
|
||||
|
||||
@@ -49,3 +49,9 @@ next-env.d.ts
|
||||
*.deb
|
||||
*.AppImage
|
||||
|
||||
# docker / deployment (machine-specific, generated by setup.sh)
|
||||
docker-compose.override.yml
|
||||
/backups
|
||||
.update.lock
|
||||
.installed-version
|
||||
|
||||
|
||||
+158
@@ -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
@@ -1,16 +1,19 @@
|
||||
# Runs a custom Node server (server.ts) for the WebRTC signaling WebSocket,
|
||||
# so this can't use Next's "standalone" output — we ship the full app +
|
||||
# node_modules instead. DATABASE_URI/PAYLOAD_SECRET etc. are only needed at
|
||||
# runtime, not at build time (see docker-compose.yml and .env.example).
|
||||
# Multi-stage build for the ANOUMA app (Next.js + Payload CMS + WebRTC
|
||||
# signaling, all served by the custom server.ts — see next.config.ts for why
|
||||
# this can't use Next's "standalone" output). DATABASE_URI/PAYLOAD_SECRET/etc.
|
||||
# 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
|
||||
|
||||
# ---- deps: install once, cached as its own layer -------------------------
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ---- builder: full source + production build ------------------------------
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -18,6 +21,7 @@ COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# ---- runner: the actual runtime image --------------------------------------
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
@@ -26,21 +30,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/payload.config.ts ./payload.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/collections ./collections
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/globals ./globals
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/access ./access
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/fields ./fields
|
||||
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
|
||||
# Copied wholesale (not a hand-picked list of directories) so newly added
|
||||
# source folders (collections, scripts, lib/*, components/*, ...) are never
|
||||
# silently missing at runtime — .dockerignore already excludes what doesn't
|
||||
# belong in the image (node_modules is re-added explicitly below, .next is
|
||||
# the build output we do want).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app /app
|
||||
|
||||
# Writable at runtime, independent of the image — actual data lives in the
|
||||
# Docker volumes mounted over these paths (see docker-compose.yml).
|
||||
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
||||
|
||||
USER nextjs
|
||||
|
||||
@@ -48,4 +47,7 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
npm install
|
||||
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
|
||||
|
||||
Lokal per Docker:
|
||||
Nur Postgres per Docker, die App läuft direkt auf dem Host:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
### Produktion
|
||||
### Produktions-Build ohne Docker
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -81,7 +97,7 @@ npm run migrate # wendet Datenbank-Migrationen an
|
||||
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
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ export default async function EventDetailPage({ params }: Args) {
|
||||
if (event.isOnline) {
|
||||
const payload = await getPayload({ config });
|
||||
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 start = combineDateAndTime(event.date, event.startTime);
|
||||
const end = event.endTime
|
||||
|
||||
@@ -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 { createMeetingToken } from "@/lib/meeting/token";
|
||||
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
|
||||
import { getIceServers } from "@/lib/meeting/iceServers";
|
||||
|
||||
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 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 (!password) {
|
||||
@@ -89,6 +92,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
participantId,
|
||||
role: isHost ? "host" : "participant",
|
||||
eventTitle: event.title,
|
||||
iceServers: getIceServers(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("meeting join failed", error);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VideoTile } from "./VideoTile";
|
||||
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
|
||||
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 };
|
||||
|
||||
@@ -48,7 +48,9 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
|
||||
const createPeerConnection = useCallback(
|
||||
(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);
|
||||
|
||||
localStreamRef.current?.getTracks().forEach((track) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type StoredMeetingSession = {
|
||||
name: string;
|
||||
eventTitle: string;
|
||||
eventSlug: string;
|
||||
iceServers: RTCIceServer[];
|
||||
};
|
||||
|
||||
export function JoinForm({ slug }: { slug: string }) {
|
||||
@@ -49,6 +50,7 @@ export function JoinForm({ slug }: { slug: string }) {
|
||||
name,
|
||||
eventTitle: data.eventTitle,
|
||||
eventSlug: slug,
|
||||
iceServers: data.iceServers,
|
||||
};
|
||||
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
|
||||
router.push(`/termine/${slug}/call`);
|
||||
|
||||
@@ -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
@@ -1,36 +1,55 @@
|
||||
networks:
|
||||
anouma-network:
|
||||
name: ${DOCKER_NETWORK:-anouma-network}
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- anouma-network
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: anouma
|
||||
ports:
|
||||
- "5432:5432"
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-anouma}
|
||||
volumes:
|
||||
- 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:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# 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
|
||||
retries: 20
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1–4: detect tooling
|
||||
# ---------------------------------------------------------------------------
|
||||
command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden." "Installiere Docker: https://docs.docker.com/engine/install/"
|
||||
log_success "Docker erkannt: $(docker --version)"
|
||||
|
||||
docker info >/dev/null 2>&1 || fail "Der Docker-Daemon läuft nicht (oder fehlende Berechtigung)." "Starte Docker bzw. führe dieses Skript mit einem Nutzer aus, der Docker verwenden darf."
|
||||
|
||||
COMPOSE=""
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE="docker-compose"
|
||||
fi
|
||||
[ -n "$COMPOSE" ] || fail "Docker Compose wurde nicht gefunden." "Installiere das Compose-Plugin: https://docs.docker.com/compose/install/"
|
||||
log_success "Docker Compose erkannt: $($COMPOSE version | head -n1)"
|
||||
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
log_success "Git erkannt: $(git --version)"
|
||||
else
|
||||
log_warn "Git wurde nicht gefunden — für update.sh (Release-Erkennung) empfohlen, aber für den Betrieb selbst nicht zwingend nötig."
|
||||
fi
|
||||
|
||||
# Node/npm are NOT required on the host — everything runs inside the app
|
||||
# container. This is purely informational.
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
log_info "Node.js auf dem Host erkannt: $(node --version) (wird nicht benötigt — ANOUMA läuft in Docker)"
|
||||
else
|
||||
log_info "Node.js nicht auf dem Host installiert — kein Problem, ANOUMA läuft vollständig in Docker."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5–6: detect existing Docker networks, look for a reverse proxy (e.g. NPM)
|
||||
# ---------------------------------------------------------------------------
|
||||
log_info "Prüfe vorhandene Docker-Netzwerke …"
|
||||
EXISTING_NETWORKS="$(docker network ls --format '{{.Name}}' 2>/dev/null || true)"
|
||||
|
||||
detect_npm_network() {
|
||||
# 1) Look for a running container that looks like Nginx Proxy Manager and
|
||||
# read the network it's actually attached to.
|
||||
local npm_container
|
||||
npm_container="$(docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'nginx-proxy-manager|jc21/nginx-proxy-manager' | head -n1 | cut -f1 || true)"
|
||||
if [ -n "$npm_container" ]; then
|
||||
docker inspect "$npm_container" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | grep -v '^bridge$' | head -n1
|
||||
return
|
||||
fi
|
||||
# 2) Fall back to a name heuristic among existing networks.
|
||||
echo "$EXISTING_NETWORKS" | grep -iE 'nginx-proxy-manager|^npm' | head -n1 || true
|
||||
}
|
||||
|
||||
DETECTED_NPM_NETWORK="$(detect_npm_network || true)"
|
||||
NPM_DETECTED=false
|
||||
if [ -n "$DETECTED_NPM_NETWORK" ]; then
|
||||
NPM_DETECTED=true
|
||||
log_success "Nginx Proxy Manager erkannt (Netzwerk: $DETECTED_NPM_NETWORK)"
|
||||
else
|
||||
log_info "Nginx Proxy Manager wurde nicht erkannt — ANOUMA läuft eigenständig, das ist kein Fehler."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7–10: .env — create if missing, fill in only missing keys otherwise,
|
||||
# generate secrets, never overwrite existing ones
|
||||
# ---------------------------------------------------------------------------
|
||||
gen_secret() { openssl rand -hex 32; }
|
||||
|
||||
FRESH_ENV=false
|
||||
if [ ! -f .env ]; then
|
||||
FRESH_ENV=true
|
||||
cp .env.example .env
|
||||
log_info ".env aus .env.example erstellt."
|
||||
fi
|
||||
|
||||
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
||||
env_set() {
|
||||
# env_set KEY VALUE — replaces an existing (possibly empty) line, or
|
||||
# appends the key if it's missing entirely. Never touches other keys.
|
||||
local key="$1" value="$2"
|
||||
if grep -qE "^$key=" .env; then
|
||||
local tmp; tmp="$(mktemp)"
|
||||
awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> .env
|
||||
fi
|
||||
}
|
||||
# Only fills the key in if it is currently missing or blank — this is what
|
||||
# makes secret generation safe to re-run (existing secrets survive updates).
|
||||
env_ensure() {
|
||||
local key="$1" value="$2"
|
||||
local current; current="$(env_get "$key")"
|
||||
if [ -z "$current" ]; then
|
||||
env_set "$key" "$value"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$FRESH_ENV" = true ]; then
|
||||
log_info "Domain für diese Installation (z. B. https://anouma.org):"
|
||||
APP_URL="$(ask "Domain" "http://localhost:3000")"
|
||||
env_set NEXT_PUBLIC_SERVER_URL "$APP_URL"
|
||||
fi
|
||||
|
||||
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
|
||||
env_ensure MEETING_SESSION_SECRET "$(gen_secret)" && log_info "MEETING_SESSION_SECRET generiert." || log_info "MEETING_SESSION_SECRET bereits vorhanden — unverändert."
|
||||
env_ensure CRON_SECRET "$(gen_secret)" && log_info "CRON_SECRET generiert." || log_info "CRON_SECRET bereits vorhanden — unverändert."
|
||||
|
||||
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
|
||||
log_info "POSTGRES_PASSWORD generiert."
|
||||
# Keep DATABASE_URI in sync with the freshly generated password so the
|
||||
# app can actually connect — only rewritten when we just generated the
|
||||
# password ourselves, never when POSTGRES_PASSWORD already existed.
|
||||
PG_USER="$(env_get POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}"
|
||||
PG_DB="$(env_get POSTGRES_DB)"; PG_DB="${PG_DB:-anouma}"
|
||||
PG_PASSWORD="$(env_get POSTGRES_PASSWORD)"
|
||||
env_set DATABASE_URI "postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}"
|
||||
else
|
||||
log_info "POSTGRES_PASSWORD bereits vorhanden — unverändert."
|
||||
fi
|
||||
|
||||
if [ "$FRESH_ENV" = true ]; then
|
||||
echo ""
|
||||
SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")"
|
||||
if [ "$SETUP_SMTP" = true ]; then
|
||||
env_set SMTP_HOST "$(ask "SMTP-Host" "")"
|
||||
env_set SMTP_PORT "$(ask "SMTP-Port" "587")"
|
||||
env_set SMTP_USER "$(ask "SMTP-Benutzer" "")"
|
||||
read -r -s -p "SMTP-Passwort: " smtp_pw || true; echo ""
|
||||
env_set SMTP_PASSWORD "$smtp_pw"
|
||||
fi
|
||||
|
||||
if [ "$NPM_DETECTED" = true ]; then
|
||||
USE_NPM="$(ask_yn "Nginx Proxy Manager verwenden (Netzwerk beitreten)?" "Y")"
|
||||
if [ "$USE_NPM" = true ]; then
|
||||
NPM_NETWORK_NAME="$(ask "NPM-Docker-Netzwerk" "$DETECTED_NPM_NETWORK")"
|
||||
env_set NPM_NETWORK "$NPM_NETWORK_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
AUTO_UPDATE_ANSWER="$(ask_yn "Automatische Updates aktivieren?" "N")"
|
||||
env_set AUTO_UPDATE "$AUTO_UPDATE_ANSWER"
|
||||
fi
|
||||
|
||||
chmod 600 .env || true
|
||||
log_success ".env ist bereit."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse-proxy network override — generated only when configured/detected,
|
||||
# never required. See docker-compose.override.yml.example for the manual
|
||||
# equivalent.
|
||||
# ---------------------------------------------------------------------------
|
||||
NPM_NETWORK_VALUE="$(env_get NPM_NETWORK)"
|
||||
if [ -n "$NPM_NETWORK_VALUE" ]; then
|
||||
if ! echo "$EXISTING_NETWORKS" | grep -qx "$NPM_NETWORK_VALUE"; then
|
||||
log_warn "NPM_NETWORK=$NPM_NETWORK_VALUE ist in .env gesetzt, aber kein Docker-Netzwerk mit diesem Namen existiert — das Override wird trotzdem geschrieben, `docker compose up` wird aber fehlschlagen, bis das Netzwerk existiert."
|
||||
fi
|
||||
sed "s/\${NPM_NETWORK}/$NPM_NETWORK_VALUE/g" docker-compose.override.yml.example > docker-compose.override.yml
|
||||
log_success "docker-compose.override.yml für Reverse-Proxy-Netzwerk '$NPM_NETWORK_VALUE' erstellt."
|
||||
elif [ -f docker-compose.override.yml ]; then
|
||||
log_info "NPM_NETWORK ist leer, aber docker-compose.override.yml existiert bereits — wird unverändert gelassen (nicht automatisch gelöscht)."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9–11: build, start database, wait for healthy, migrate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Waits for a compose service's container healthcheck to report "healthy".
|
||||
# Uses `docker inspect` on the container ID (via `compose ps -q`) rather than
|
||||
# `compose ps --format`, since Go-template support in `ps --format` varies
|
||||
# across Compose versions while `docker inspect` is stable everywhere.
|
||||
wait_healthy() {
|
||||
local service="$1" timeout_iterations="$2" id status
|
||||
for _ in $(seq 1 "$timeout_iterations"); do
|
||||
id="$($COMPOSE ps -q "$service" 2>/dev/null || true)"
|
||||
if [ -n "$id" ]; then
|
||||
status="$(docker inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)"
|
||||
[ "$status" = "healthy" ] && return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
log_info "Baue Docker-Images (das kann beim ersten Mal einige Minuten dauern) …"
|
||||
$COMPOSE build
|
||||
|
||||
log_info "Starte Datenbank …"
|
||||
$COMPOSE up -d postgres
|
||||
|
||||
log_info "Warte auf Datenbank-Healthcheck …"
|
||||
DB_READY=false
|
||||
wait_healthy postgres 60 && DB_READY=true
|
||||
[ "$DB_READY" = true ] || fail "Datenbank wurde nicht rechtzeitig healthy." "Prüfe die Logs: $COMPOSE logs postgres"
|
||||
log_success "Datenbank ist bereit."
|
||||
|
||||
log_info "Führe Datenbank-Migrationen aus …"
|
||||
$COMPOSE run --rm app npm run migrate || fail "Migration fehlgeschlagen." "Prüfe die Ausgabe oben. Die Datenbank wurde nicht verändert, wenn die Migration atomar fehlgeschlagen ist."
|
||||
log_success "Migrationen angewendet."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13: seed
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$SKIP_SEED" = false ]; then
|
||||
DO_SEED="$(ask_yn "Vorhandene ANOUMA-Inhalte (Angebote, Seitentexte) jetzt einspielen?" "Y")"
|
||||
if [ "$DO_SEED" = true ]; then
|
||||
log_info "Spiele Seed-Daten ein …"
|
||||
$COMPOSE run --rm app npm run seed || log_warn "Seed ist fehlgeschlagen — die Installation läuft trotzdem weiter, Inhalte können später im Admin-Bereich gepflegt werden."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14–15: start app, healthcheck
|
||||
# ---------------------------------------------------------------------------
|
||||
log_info "Starte Anwendung …"
|
||||
$COMPOSE up -d app
|
||||
|
||||
log_info "Warte auf Healthcheck der Anwendung …"
|
||||
APP_READY=false
|
||||
wait_healthy app 60 && APP_READY=true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AUTO_UPDATE cron
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$(env_get AUTO_UPDATE)" = "true" ] && command -v crontab >/dev/null 2>&1; then
|
||||
CRON_MARKER="# anouma-auto-update"
|
||||
if ! (crontab -l 2>/dev/null | grep -qF "$CRON_MARKER"); then
|
||||
( crontab -l 2>/dev/null; echo "*/30 * * * * cd $(pwd) && ./update.sh --auto >> $(pwd)/update.log 2>&1 $CRON_MARKER" ) | crontab -
|
||||
log_success "Cronjob für automatische Updates eingerichtet (alle 30 Minuten)."
|
||||
else
|
||||
log_info "Cronjob für automatische Updates ist bereits eingerichtet."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" > .installed-version 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 16: result
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "ANOUMA installation complete"
|
||||
echo ""
|
||||
echo "Version:"
|
||||
echo " $(cat .installed-version 2>/dev/null || echo unknown)"
|
||||
echo ""
|
||||
echo "Application:"
|
||||
echo " $(env_get NEXT_PUBLIC_SERVER_URL)"
|
||||
echo ""
|
||||
echo "Docker:"
|
||||
echo " ✓ Running"
|
||||
echo ""
|
||||
echo "Database:"
|
||||
if [ "$DB_READY" = true ]; then echo " ✓ Healthy"; else echo " ✗ Not healthy — check: $COMPOSE logs postgres"; fi
|
||||
echo ""
|
||||
echo "Application healthcheck:"
|
||||
if [ "$APP_READY" = true ]; then echo " ✓ Running"; else echo " ✗ Not healthy yet — check: $COMPOSE logs app"; fi
|
||||
echo ""
|
||||
echo "Nginx Proxy Manager:"
|
||||
if [ "$NPM_DETECTED" = true ]; then echo " ✓ Detected ($DETECTED_NPM_NETWORK)"; else echo " - Not detected"; fi
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Visit ${NPM_DETECTED:+your configured domain, or }http://localhost:${APP_PORT:-3000}/admin to create the first admin account."
|
||||
echo " 2. See DEPLOYMENT.md for reverse-proxy setup, backups, updates and troubleshooting."
|
||||
echo ""
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user