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:
- Checks/installs Docker, Docker Compose, git, openssl, curl, ssh.
- Creates
/opt/anouma(refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted). - Detects an existing Nginx Proxy Manager network and optionally joins it.
- Writes
/opt/anouma/.envwith every secret the app needs, freshly generated (openssl rand -hex 32) and never regenerated on a later run. - Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before.
- Creates a restricted
anouma-deploysystem user, generates a dedicated SSH keypair for it, and installs the public key with a forced command so that key can only ever rundeploy.sh— never an interactive shell. - Copies
docker-compose.ymlanddeploy.shinto/opt/anouma. - Starts Postgres (so it's ready for the first deploy).
- 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:
- Find its network:
docker network ls - Set
NPM_NETWORK=<that-name>in/opt/anouma/.env cp docker-compose.override.yml.example docker-compose.override.yml(in/opt/anouma), replacing${NPM_NETWORK}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 |
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
pgdatanamed volume, independent of thepostgrescontainer's lifecycle. - Uploaded media — the
/opt/anouma/mediabind mount, independent of theappcontainer'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'sHEALTHCHECK) — 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-deployhas 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"inauthorized_keys: whatever command an SSH client requests is ignored anddeploy.shruns instead (the client's actual request only reaches it via$SSH_ORIGINAL_COMMAND, whichdeploy.shvalidates against a strictdeploy vX.Y.Zpattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks. anouma-deployis a member of thedockergroup, because runningdocker composerequires 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 fixeddeploy.shlogic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here..envand the deploy SSH private key arechmod 600.- Secrets are never committed, never logged, and never printed more than once (
setup.shshows 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 (********) insetup.sh's configuration summary. The GHCR token is needed only on the production server for pulling the private image; GitHub Actions uses its automaticGITHUB_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 |