Previously release.yml fully rebuilt and re-ran lint/typecheck/test/build from source on every tag push, duplicating what ci.yml had already just verified on main. Now ci.yml builds and pushes the image (tagged by commit SHA) once, right after its checks pass, but only for pushes to main. release.yml no longer builds anything: it pulls that exact image, retags it as the release version + latest, and deploys it. This guarantees the deployed image is byte-for-byte what CI verified, and turns "tag a commit that was never pushed to main or hasn't finished CI" into a loud, immediate failure (no image to promote) instead of a rebuild that silently skips the checks.
13 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 main → Gitea Actions (CI)
→ Tests → Docker Build → Registry 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://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.)
On top of that, every push to main that passes the checks above also builds the Docker image and pushes it to the registry tagged by commit SHA (git.maro.run/maro/anouma:<sha>) — this is the one and only build of that commit; nothing later rebuilds it.
Release (.gitea/workflows/release.yml)
Triggered by pushing a tag matching v* (e.g. v1.2.0):
- Pulls the image CI already built and pushed for that commit's SHA — fails immediately with a clear error if it isn't there (the tagged commit was never on
main, or CI for it hasn't finished yet). - Retags that same image as
git.maro.run/maro/anouma:vX.Y.Zand:latest, and pushes both. - 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 (and always points at the most recently released build, not just the newest commit on main), 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, 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 |
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 "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 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 |