Files
anouma/DEPLOYMENT.md
T
maro ad353190f3
CI / test (push) Canceled after 0s
Release / promote-and-deploy (push) Canceled after 0s
CI / build-image (push) Canceled after 0s
Fix SMTP proxy certificates and speed up CI
2026-09-18 11:59:33 +02:00

14 KiB

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.

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

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:

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:

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:

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:

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. SMTP_TLS_REJECT_UNAUTHORIZED stays true unless the configured SMTP proxy intentionally uses a private/self-signed certificate; only then set it to false.
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