Files
anouma/DEPLOYMENT.md
T

238 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ANOUMA — Deployment
Production deployment is fully CI/CD-driven via GitHub Actions. There is no `update.sh` and the production server never builds anything — it only ever pulls a tested image from GitHub Container Registry.
```text
Developer → git push main → GitHub Actions (CI)
→ Tests → Docker Build → GHCR Push (tagged by commit SHA)
→ Release (git tag vX.Y.Z) → promote that exact image to vX.Y.Z + latest
→ SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE
```
Building and testing happen exactly once, on `main` — tagging a release never
rebuilds or re-tests, it only promotes the already-verified image for that
commit. Tagging a commit that hasn't been pushed to `main` (or whose CI run
hasn't finished yet) fails the release with a clear error instead of quietly
building from source and skipping the checks.
## One-time server setup
```bash
git clone https://github.com/whitehai11/anouma.git
cd anouma
chmod +x setup.sh
./setup.sh
```
`setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It:
1. Checks/installs Docker, Docker Compose, git, openssl, curl, ssh.
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted).
3. Detects an existing Nginx Proxy Manager network and optionally joins it.
4. Writes `/opt/anouma/.env` with every secret the app needs, freshly generated (`openssl rand -hex 32`) and **never regenerated** on a later run.
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before.
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` — never an interactive shell.
7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`.
8. Starts Postgres (so it's ready for the first deploy).
9. Prints the exact values to paste into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**, including the deploy private key — shown only once, the first time the key is generated.
Run `./setup.sh --non-interactive` for a scripted install; any value that has no safe default and wasn't exported as an environment variable beforehand aborts with a clear message naming the missing variable, instead of silently guessing.
Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are — only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data.
### After setup: add the GitHub secrets
Copy the deployment values `setup.sh` printed into **GitHub → whitehai11/anouma → Settings → Secrets and variables → Actions**:
| Secret | Value |
| --- | --- |
| `DEPLOY_HOST` | the server's IP/hostname |
| `DEPLOY_PORT` | `22` (or your SSH port) |
| `DEPLOY_USER` | `anouma-deploy` |
| `DEPLOY_SSH_KEY` | the private key setup.sh printed |
| `DEPLOY_HOST_KEY` | optional — pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | **not needed in GitHub Actions**; these belong in `/opt/anouma/.env` on the server for pulling the private GHCR image |
### First deploy
Nothing runs on the server yet at this point — no release has been built. Push the first tag:
```bash
git tag v1.0.0
git push github v1.0.0 # GitHub Actions
```
The GitHub workflow takes it from there. Once it's live, seed the original content once: `ssh anouma-deploy@<host>` won't work (the key is restricted — see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`.
## GitHub Actions
The GitHub repository is `whitehai11/anouma`; the workflows live in
`.github/workflows/ci.yml` and `.github/workflows/release.yml`. In GitHub go to
**Settings → Secrets and variables → Actions → Repository secrets** and create:
| Secret | Required | Value |
| --- | --- | --- |
| `DEPLOY_HOST` | yes | Public IP or hostname of the production server |
| `DEPLOY_PORT` | yes | SSH port, normally `22` |
| `DEPLOY_USER` | yes | `anouma-deploy` |
| `DEPLOY_SSH_KEY` | yes | Private key generated by `setup.sh` for the restricted deploy user |
| `DEPLOY_HOST_KEY` | no | Complete SSH host-key line from a trusted `ssh-keyscan`; safer than first-connect trust |
GitHub Actions authentifiziert sich mit dem automatisch bereitgestellten
`GITHUB_TOKEN`; dafür musst du keine Registry-Secrets anlegen. Registry und
Image sind im Workflow fest auf `ghcr.io/whitehai11/anouma` gesetzt. Runtime-Secrets
wie `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `LIVEKIT_API_SECRET` und
`DATABASE_URI` gehören ausschließlich in `/opt/anouma/.env` auf dem Server und
nicht in GitHub Actions.
Wichtig: Für Releases nur GitHub Actions verwenden. Gitea kann als reiner
Repository-Spiegel bestehen bleiben, darf aber keine eigenen Build- oder
Release-Workflows ausführen.
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
Not a manual tool — it's invoked exclusively by GitHub Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`:
```text
Lock (refuse a second concurrent deploy)
→ Backup the database
→ Set ANOUMA_IMAGE in .env to the new tag
→ Registry login (if credentials are configured)
→ docker compose pull
→ docker compose run --rm app npm run migrate
→ docker compose up -d
→ Healthcheck
→ success, or: automatic rollback to the previous version
```
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 — GitHub Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing.
A lock file (`/opt/anouma/.deploy.lock`) prevents two deploys from running at once; a stale lock from a killed process is detected and ignored automatically.
## Backups
Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. 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.
Restore manually if needed:
```bash
cd /opt/anouma
gunzip -c backups/database-2026-08-25-1430.sql.gz | docker compose exec -T postgres psql -U postgres anouma
```
## Nginx Proxy Manager (or any reverse proxy)
`setup.sh` detects a running NPM container and, if you confirm, generates `/opt/anouma/docker-compose.override.yml` joining its network automatically. To do it manually instead:
1. Find its network: `docker network ls`
2. Set `NPM_NETWORK=<that-name>` in `/opt/anouma/.env`
3. `cp docker-compose.override.yml.example docker-compose.override.yml` (in `/opt/anouma`), replacing `${NPM_NETWORK}`
4. `docker compose up -d`
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) |
| Forward Port | `3000` |
| **Websockets Support** | **enabled** — required for the LiveKit signal connection |
Für LiveKit legst du zusätzlich einen zweiten Proxy Host an:
| Field | Value |
| --- | --- |
| Domain | z. B. `livekit.anouma.org` |
| Scheme | `http` |
| Forward Host | der Server, auf dem LiveKit läuft |
| Forward Port | `7880` |
| **Websockets Support** | **enabled** |
In DNS muss die LiveKit-Domain auf denselben Server zeigen. Das Zertifikat
muss von einer öffentlichen CA stammen; selbst signierte Zertifikate werden
von Browsern für LiveKit nicht akzeptiert.
## WebRTC: LiveKit SFU und eingebettetes TURN
```
Client A ── WebRTC ──┐
├── LiveKit SFU ── WebRTC ── Client B
Client C ── WebRTC ──┘ │
└── embedded TURN fallback
```
`docker-compose.yml` startet LiveKit mit Host-Networking, dem UDP-Medienbereich
`50000–60000`, dem TCP-Fallback `7881` und eingebettetem TURN/UDP auf `3478`.
Die API-Keys werden von `setup.sh` generiert und nur an den LiveKit-Container
und die serverseitige Token-Route gegeben. Für den Browser ist ausschließlich
`LIVEKIT_URL` als `wss://`-Endpoint sichtbar.
Firewall/NAT-Freigaben auf dem Server:
```text
TCP 443 LiveKit über den TLS-Reverse-Proxy
TCP 7881 LiveKit-TCP-Fallback
UDP 3478 eingebettetes TURN
UDP 50000-60000 LiveKit-Medien
```
Für genau einen parallelen kleinen Call ist kein Redis und keine GPU nötig.
Bei mehreren LiveKit-Instanzen oder deutlich höherer Last kommt Redis bzw. ein
Load-Balancer dazu.
## Environment variables
See `.env.example` for the full, commented list. `setup.sh` writes the real file to `/opt/anouma/.env` for you.
| Variable | Purpose |
| --- | --- |
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
| `SMTP_*` | The existing ANOUMA mail system |
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
| `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from |
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy — don't edit by hand |
## Persistent data
Deployments never delete data:
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
- **Backups** — `/opt/anouma/backups`.
Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v`. Neither touches a container or network it doesn't own (an existing NPM install is never modified beyond optionally joining its network).
## Healthchecks
- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
- **postgres**: `pg_isready`.
`docker compose ps` (from `/opt/anouma`) shows the app, Postgres and LiveKit services.
## Security
- `anouma-deploy` has **no sudo rights at all** and **no password** — the only way in is its dedicated SSH key.
- That SSH key is restricted with `restrict,command="/opt/anouma/deploy.sh"` in `authorized_keys`: whatever command an SSH client requests is ignored and `deploy.sh` runs instead (the client's actual request only reaches it via `$SSH_ORIGINAL_COMMAND`, which `deploy.sh` validates against a strict `deploy vX.Y.Z` pattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks.
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it — and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here.
- `.env` and the deploy SSH private key are `chmod 600`.
- Secrets are never committed, never logged, and never printed more than once (`setup.sh` shows the private key only the first time it's generated; on a later run it prints "Existing deployment key found." instead).
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automatic `GITHUB_TOKEN`.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag |
| Release workflow fails at "Deploy to production" | Check the GitHub repository secrets are all set correctly, and that the server's SSH port is reachable from the runner |
| App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` |
| Video calls connect but no audio/video | Check LiveKit logs, the UDP 3478/50000–60000 firewall rules and the public `LIVEKIT_URL` |
| Browser cannot connect to LiveKit | Check the LiveKit proxy host, trusted TLS certificate and that Websockets Support is enabled |
| Need to redeploy the same version | `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`, or push a new patch tag |