Overhaul deployment to Gitea Actions CI/CD, remove update.sh
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>
This commit is contained in:
+128
-89
@@ -1,48 +1,118 @@
|
||||
# ANOUMA — Deployment
|
||||
|
||||
Full Docker-based deployment guide. For local development without Docker (running `next dev` directly against a Dockerized Postgres), see `README.md` instead.
|
||||
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.
|
||||
|
||||
## Quick start
|
||||
```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
|
||||
```
|
||||
|
||||
That's it — `setup.sh` builds the images, starts Postgres, waits for it to be healthy, runs migrations, optionally seeds the original ANOUMA content, starts the app, and prints a status summary. Docker and Docker Compose are the only host requirements; Node.js/npm are **not** needed on the host — everything runs inside the `app` container.
|
||||
`setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It:
|
||||
|
||||
Run `./setup.sh --non-interactive` for sensible defaults with no prompts (useful for scripted/CI installs), or `--skip-seed` to skip content seeding.
|
||||
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.
|
||||
|
||||
`setup.sh` is safe to re-run on an existing installation — it never overwrites a secret that's already in `.env`, never deletes a volume, network or container, and never touches an existing Nginx Proxy Manager (or any other container) beyond optionally joining its network.
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
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.
|
||||
|
||||
```
|
||||
docker-compose.yml
|
||||
│
|
||||
├── postgres PostgreSQL 16, persisted in the "pgdata" volume
|
||||
│
|
||||
└── app Next.js + Payload CMS + WebRTC signaling (server.ts)
|
||||
— one process, one container. There is no separate
|
||||
"signaling" service: the WebSocket signaling server is
|
||||
attached to the same custom Node server that serves the
|
||||
website and admin panel (see server.ts), so it scales and
|
||||
deploys as a single unit.
|
||||
### 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
|
||||
```
|
||||
|
||||
Both containers join the internal `anouma-network` (name configurable via `DOCKER_NETWORK` in `.env`). The `app` container optionally also joins an external reverse-proxy network — see below.
|
||||
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` looks for a running Nginx Proxy Manager container (or a Docker network whose name looks like one) and, if found, asks whether to join its network. If you'd rather do this manually (or NPM wasn't running yet when you set up ANOUMA):
|
||||
`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 `.env`
|
||||
3. `cp docker-compose.override.yml.example docker-compose.override.yml`
|
||||
4. `docker compose up -d` — Compose picks up `docker-compose.override.yml` automatically
|
||||
|
||||
`docker-compose.override.yml` is machine-specific and gitignored on purpose.
|
||||
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:
|
||||
|
||||
@@ -50,30 +120,10 @@ In Nginx Proxy Manager, add a Proxy Host:
|
||||
| --- | --- |
|
||||
| Domain | your domain, e.g. `anouma.org` |
|
||||
| Scheme | `http` |
|
||||
| Forward Host | `app` (the Compose service name — reachable by name once on the same network) |
|
||||
| Forward Host | `app` (the Compose service name) |
|
||||
| Forward Port | `3000` |
|
||||
| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) |
|
||||
|
||||
Once a reverse proxy reaches the container directly over the shared network, you can remove the `ports:` mapping for `app` in `docker-compose.yml` so the app isn't also reachable directly on the host.
|
||||
|
||||
If no reverse proxy is configured, ANOUMA still works standalone — the app is published on `http://localhost:${APP_PORT:-3000}` (bound to `127.0.0.1` by default; open that up in `docker-compose.yml` if you need it reachable from outside without a proxy).
|
||||
|
||||
## Environment variables
|
||||
|
||||
See `.env.example` for the full list with inline explanations. Only variables the application actually reads are defined — highlights:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection — kept in sync automatically by `setup.sh` |
|
||||
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated on first run, never overwritten afterwards |
|
||||
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site (used for admin/media links and email links) |
|
||||
| `SMTP_*` | The existing ANOUMA mail system (booking/reminder emails) |
|
||||
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers — see below |
|
||||
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names (see above) |
|
||||
| `AUTO_UPDATE`, `BACKUP_RETENTION_DAYS` | See Updates below |
|
||||
|
||||
Secrets are only ever written to `.env` on your server, never committed (`.gitignore` excludes `.env*` except `.env.example`).
|
||||
|
||||
## WebRTC: signaling, STUN, TURN
|
||||
|
||||
```
|
||||
@@ -91,68 +141,57 @@ Client A ═══════════ P2P WebRTC ════════
|
||||
Audio / Video / Screen
|
||||
```
|
||||
|
||||
The signaling server never sees media. By default, clients use Google's public STUN server (`STUN_SERVER` in `.env`), which is enough for most networks. Behind strict NATs/corporate firewalls, P2P via STUN alone can fail — add a TURN server by setting `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` in `.env` and restarting the app container; no code changes are required. (This project doesn't run a TURN server itself — coturn is a common self-hosted option if you need one.)
|
||||
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.
|
||||
|
||||
## Updates
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
./update.sh # update to the latest release tag
|
||||
./update.sh v1.2.0 # update to a specific tag
|
||||
```
|
||||
See `.env.example` for the full, commented list. `setup.sh` writes the real file to `/opt/anouma/.env` for you.
|
||||
|
||||
Update flow: backup → fetch tags → checkout the release → rebuild → migrate → restart → healthcheck. If the healthcheck fails, the **code** is automatically rolled back to the previous version (database migrations are not reverted — write migrations to be forward-compatible; see Rollback below).
|
||||
|
||||
Only real release tags (`vX.Y.Z`) are deployed — `update.sh` deliberately never force-deploys whatever happens to be on `main`. If no tags exist yet in the repository, it does nothing and says so.
|
||||
|
||||
An update lock (`.update.lock`) prevents two updates from running concurrently.
|
||||
|
||||
### Automatic updates
|
||||
|
||||
Off by default (`AUTO_UPDATE=false`). Set `AUTO_UPDATE=true` in `.env` and re-run `./setup.sh` to install a cron job that checks for new releases every 30 minutes and updates automatically when one appears — otherwise it's a no-op. Disable again by setting `AUTO_UPDATE=false` and removing the `# anouma-auto-update` line from `crontab -e`.
|
||||
|
||||
### Backups
|
||||
|
||||
Every update creates `backups/database-YYYY-MM-DD-HHMM.sql.gz` before touching anything. 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. Uploaded media lives in the `./media` bind mount, which isn't touched by updates at all.
|
||||
|
||||
### Rollback
|
||||
|
||||
Automatic on a failed post-update healthcheck (see above). To roll back manually:
|
||||
|
||||
```bash
|
||||
git checkout vX.Y.Z
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If a migration from the failed release isn't backward-compatible, restore the pre-update backup instead:
|
||||
|
||||
```bash
|
||||
gunzip -c backups/database-2026-08-25-1430.sql.gz | docker compose exec -T postgres psql -U postgres anouma
|
||||
```
|
||||
| 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
|
||||
|
||||
Nothing about rebuilding or updating containers ever deletes data:
|
||||
Deployments never delete data:
|
||||
|
||||
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
||||
- **Uploaded media** — the `./media` bind mount, independent of the `app` container's lifecycle.
|
||||
- **Backups** — the `./backups` directory on the host.
|
||||
- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||
- **Backups** — `/opt/anouma/backups`.
|
||||
|
||||
`setup.sh` and `update.sh` never run `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v` — none of the scripts in this repo do.
|
||||
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 Docker image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
|
||||
- **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` shows both statuses.
|
||||
`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` — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
|
||||
| App container unhealthy | `docker compose logs app`, then `curl http://localhost:3000/api/health` from inside the network (`docker compose exec app wget -qO- http://127.0.0.1:3000/api/health`) |
|
||||
| `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 |
|
||||
| `update.sh` says "uncommittete Änderungen" | Someone edited files directly on the server outside of a release — `git status` to see what, then commit/stash before updating |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user