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>
198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# 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.
|
|
|
|
```text
|
|
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
|
|
|
|
```bash
|
|
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:
|
|
|
|
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 **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:
|
|
|
|
```bash
|
|
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`):
|
|
|
|
1. Re-runs the same quality gates as CI.
|
|
2. Builds the Docker image and pushes `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`.
|
|
3. SSHes into the production server as `anouma-deploy` and runs `deploy.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`:
|
|
|
|
```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 — 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:
|
|
|
|
```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 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 `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 both statuses.
|
|
|
|
## 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 — 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 |
|