Files
anouma/DEPLOYMENT.md
T

241 lines
14 KiB
Markdown

# 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 |
| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key |
| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository |
| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) |
| `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 |