Production no longer builds from source or self-updates via cron/git pull:
setup.sh now only provisions the server once (Docker, /opt/anouma, a
restricted `anouma-deploy` SSH user whose key can only ever run
deploy.sh, generated secrets). All future deployments run through
.gitea/workflows/ci.yml (lint/typecheck/test/build on every push) and
release.yml (on a vX.Y.Z tag: build the image, push it to the Gitea
registry, then SSH-trigger deploy.sh on the server), which pulls,
migrates, restarts, healthchecks, backs up the database first, and
automatically rolls back the code on a failed healthcheck.
docker-compose.yml's app service now runs a registry image
(${ANOUMA_IMAGE}) instead of building locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
ANOUMA — Deployment
Production deployment is fully CI/CD-driven via Gitea Actions. There is no update.sh and the production server never builds anything — it only ever pulls a tested image from the registry.
Developer → git push → Gitea → Gitea Actions
→ Tests → Docker Build → Registry Push
→ Release (git tag vX.Y.Z) → SSH → deploy.sh
→ docker compose pull → migrate → up -d → Healthcheck → LIVE
One-time server setup
git clone https://git.maro.run/maro/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 Gitea → maro/anouma → Settings → Secrets → 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 Gitea secrets
Copy the block setup.sh printed into Gitea → maro/anouma → Settings → Secrets → 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 |
registry push credentials (a Gitea access token as the password, not your account password) |
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 origin v1.0.0
Gitea Actions takes it from there (see below). 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.
Gitea Actions
CI (.gitea/workflows/ci.yml)
Runs on every push and PR: npm ci → lint → typecheck → test → build, on the ubuntu-latest-labelled runner. No database is required — every Payload-backed route in this app is force-dynamic, so a production build never touches Postgres. (There's no automated test suite yet — npm test is an honest placeholder; add a real one, e.g. vitest, whenever the project needs it, and it'll run here automatically.)
Release (.gitea/workflows/release.yml)
Triggered by pushing a tag matching v* (e.g. v1.2.0):
- Re-runs the same quality gates as CI.
- Builds the Docker image and pushes
git.maro.run/maro/anouma:vX.Y.Zand:latest. - SSHes into the production server as
anouma-deployand runsdeploy.sh deploy vX.Y.Z.
Production always runs a specific version tag — latest is pushed for convenience/reference only, deploy.sh never uses it.
deploy.sh (server-side, /opt/anouma/deploy.sh)
Not a manual tool — it's invoked exclusively by Gitea 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 — Gitea 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 video-call signaling (/ws/signaling) |
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
STUN is on by default (STUN_SERVER in .env). Add a TURN server for restrictive NATs/firewalls by setting TURN_SERVER/TURN_USERNAME/TURN_PASSWORD and redeploying — no code changes needed.
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, CRON_SECRET |
Auto-generated, never overwritten afterwards |
NEXT_PUBLIC_SERVER_URL |
Public URL of the site |
SMTP_* |
The existing ANOUMA mail system |
STUN_SERVER, TURN_SERVER, TURN_USERNAME, TURN_PASSWORD |
WebRTC ICE servers |
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 both statuses.
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 — only the one-time Gitea-secrets block shows real values, since that block exists specifically for you to copy them into Gitea.
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 "Deploy to production" | Check the Gitea 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 | 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 |
| 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 |