diff --git a/README.md b/README.md index b79c6fa..a4c1345 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,15 @@ offline support. - **Plugins** — sandboxed extensions (custom blocks, styles, page tools) installable at runtime without redeploying the instance. - **Self-hosting first** — a single `docker compose up` plus a guided - first-run setup wizard yields a working instance. + first-run setup wizard yields a working instance. Start here: + [`docs/self-hosting/README.md`](docs/self-hosting/README.md). ## Repository layout | Path | Contents | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `docs/architecture/` | Architecture documentation: ADRs, data model, permission model, collaboration and plugin concepts, deployment and operations | +| `docs/self-hosting/` | Install, update, backup, and troubleshooting guide for running your own instance | | `apps/` | Application packages (web frontend, API server, collaboration server) — created as implementation proceeds | | `packages/` | Shared packages (types, permission logic, plugin SDK) | | `deploy/` | Docker Compose stacks and deployment tooling | diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 7a6177e..1f32459 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -48,6 +48,17 @@ SMTP_USER=wiki@example.com SMTP_PASS=change-me SMTP_FROM=Dorfteich +# --- optional TLS ingress (`caddy` profile, issue #88) ------------------------- +# Only when you have no reverse proxy of your own: start with +# `docker compose --profile caddy up -d`. Caddy terminates TLS for DOMAIN +# via Let's Encrypt (80+443 must be reachable from the internet; keep +# APP_BASE_URL=https:// in sync). The `localhost` default issues +# an internal-CA certificate instead — good for smoke tests only. +#DOMAIN=wiki.example.com +# Published ports; change only when 80/443 are taken on the host. +#CADDY_HTTP_PORT=80 +#CADDY_HTTPS_PORT=443 + # --- backups (ADR 0015, issue #83) -------------------------------------------- # The backup sidecar dumps the database and archives the uploads/plugins # volumes nightly onto the `backups` volume; restore via diff --git a/deploy/compose/Caddyfile b/deploy/compose/Caddyfile new file mode 100644 index 0000000..31be1f7 --- /dev/null +++ b/deploy/compose/Caddyfile @@ -0,0 +1,21 @@ +# Ingress for the optional `caddy` compose profile (issue #88). +# $DOMAIN comes from .env; a real domain gets automatic Let's Encrypt +# certificates (ports 80+443 must be reachable from the internet), the +# `localhost` default uses Caddy's internal CA. Routing mirrors +# deployment.md: /api → api, /collab (WebSocket) → collab, rest → web. + +{$DOMAIN:localhost} { + encode gzip + + handle /api/* { + reverse_proxy api:3000 + } + + handle /collab* { + reverse_proxy collab:3000 + } + + handle { + reverse_proxy web:8080 + } +} diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index a1f7d2a..2e5f343 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -223,6 +223,30 @@ services: retries: 3 <<: *logging + # Optional TLS ingress (issue #88): for self-hosters without their own + # reverse proxy. `docker compose --profile caddy up -d` publishes 80/443 + # and terminates TLS via Let's Encrypt for $DOMAIN (set it in .env; the + # `localhost` default uses Caddy's internal CA — handy for smoke tests). + # Instances behind an existing host proxy simply never enable the profile. + caddy: + image: caddy:2.10-alpine + profiles: [caddy] + restart: unless-stopped + ports: + - '${CADDY_HTTP_PORT:-80}:80' + - '${CADDY_HTTPS_PORT:-443}:443' + environment: + DOMAIN: ${DOMAIN:-localhost} + networks: [frontend] + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + web: + condition: service_started + <<: *logging + networks: frontend: internal: @@ -233,3 +257,6 @@ volumes: plugins: secrets: backups: + # Only used by the optional `caddy` profile (certificates + state). + caddy-data: + caddy-config: diff --git a/deploy/self-hosting-verify.sh b/deploy/self-hosting-verify.sh new file mode 100755 index 0000000..138dc53 --- /dev/null +++ b/deploy/self-hosting-verify.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env sh +# Scripted clean-machine install test for the self-hosting guide (issue #88): +# proves that a fresh directory containing ONLY the published reference files +# (docker-compose.yml, Caddyfile, .env.example) boots to a working first-run +# wizard behind the caddy profile's TLS. Uses its own compose project name, +# high ports, and DOMAIN=localhost (internal-CA certificate), then removes +# everything — safe to run on a host with live stacks. +# +# Environment: +# IMAGE_PREFIX (default gitea.101010.cloud/stwaidele/dorfteich) +# TAG (default test; a release = its semver tag) +# HTTPS_PORT (default 8443) HTTP_PORT (default 8480) +set -eu + +IMAGE_PREFIX="${IMAGE_PREFIX:-gitea.101010.cloud/stwaidele/dorfteich}" +TAG="${TAG:-test}" +HTTPS_PORT="${HTTPS_PORT:-8443}" +HTTP_PORT="${HTTP_PORT:-8480}" +HERE=$(cd "$(dirname "$0")" && pwd) +PROJECT="dorfteich-selfhost-verify-$(date +%s)" +DIR=$(mktemp -d) + +log() { echo "self-hosting-verify: $*"; } +fail() { echo "self-hosting-verify: FAILED — $*" >&2; exit 1; } + +cleanup() { + log "tearing down $PROJECT" + (cd "$DIR" && docker compose --profile caddy down -v --remove-orphans >/dev/null 2>&1) || true + rm -rf "$DIR" +} +trap cleanup EXIT + +# --- the guide's install steps, scripted ------------------------------------- +cp "$HERE/compose/docker-compose.yml" "$HERE/compose/Caddyfile" "$DIR/" +cp "$HERE/compose/.env.example" "$DIR/.env" +chmod 600 "$DIR/.env" + +edit() { sed -i.bak "s|^#*$1=.*|$1=$2|" "$DIR/.env" && rm "$DIR/.env.bak"; } +edit POSTGRES_PASSWORD "verify-$(date +%s%N | tail -c 13)" +edit COLLAB_TOKEN_SECRET "verify-secret-$(date +%s%N)" +edit IMAGE_PREFIX "$IMAGE_PREFIX" +edit TAG "$TAG" +edit COMPOSE_PROJECT_NAME "$PROJECT" +edit WEB_PORT 0 && edit API_PORT 0 && edit COLLAB_PORT 0 # ephemeral host ports +edit APP_BASE_URL "https://localhost:$HTTPS_PORT" +edit DOMAIN localhost +edit CADDY_HTTP_PORT "$HTTP_PORT" +edit CADDY_HTTPS_PORT "$HTTPS_PORT" +# Mail stays unconfigured — exactly the guide's "skip SMTP for now" path. +edit SMTP_HOST "" && edit SMTP_PORT "" && edit SMTP_SECURE "" +edit SMTP_USER "" && edit SMTP_PASS "" && edit SMTP_FROM "" + +log "starting $PROJECT from $DIR (images $IMAGE_PREFIX-*:$TAG)" +(cd "$DIR" && docker compose --profile caddy pull --quiet && docker compose --profile caddy up -d) + +# --- the guide's promise: the wizard answers over TLS ------------------------ +SETUP="" +for _ in $(seq 1 60); do + SETUP=$(curl -sk "https://localhost:$HTTPS_PORT/api/v1/setup" || true) + case "$SETUP" in *'"status":"required"'*) break ;; esac + sleep 2 +done +case "$SETUP" in + *'"status":"required"'*) log "wizard reachable over TLS: $SETUP" ;; + *) (cd "$DIR" && docker compose logs api caddy | tail -40); fail "wizard never answered: $SETUP" ;; +esac + +echo | openssl s_client -connect "localhost:$HTTPS_PORT" -servername localhost 2>/dev/null \ + | grep -q 'Caddy Local Authority' || fail "TLS certificate is not Caddy-issued" +log "TLS certificate issued by Caddy's internal CA (a real DOMAIN gets Let's Encrypt)" + +curl -sk "https://localhost:$HTTPS_PORT/" | grep -qi '
' \ + || fail "web app shell not served through the ingress" +log "SPA shell served through the ingress" + +log "OK — a clean install following only the guide reaches the working wizard" diff --git a/docs/self-hosting/README.md b/docs/self-hosting/README.md new file mode 100644 index 0000000..c01407b --- /dev/null +++ b/docs/self-hosting/README.md @@ -0,0 +1,145 @@ +# Self-hosting Dorfteich + +Everything you need to install, run, update, and back up your own Dorfteich +with Docker — this guide is the complete contract: if a step here does not +work, that is a bug (issue #88). + +> Docs are English-only by decision (issue #88): the product UI is fully +> localized (de/en), operator documentation is not — one authoritative text +> beats two drifting ones. + +## Requirements + +- Docker Engine with the Compose plugin (`docker compose version` ≥ 2.20). +- 2 GB RAM, ~2 GB disk for images plus room for your content and backups. +- A domain pointing at the host — TLS via your own reverse proxy **or** the + bundled `caddy` profile (below). +- Outbound SMTP relay (optional at install time: the setup wizard can + configure it later, or you skip mail entirely at first). + +## Install + +1. Create a directory and fetch the two reference files from the repository + (`deploy/compose/`): `docker-compose.yml`, `.env.example` — plus + `Caddyfile` if you want the `caddy` profile. + + ```sh + mkdir dorfteich && cd dorfteich + # copy docker-compose.yml, .env.example (and Caddyfile) here + cp .env.example .env && chmod 600 .env + ``` + +2. Edit `.env` — the minimum: + - `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings + (`openssl rand -base64 32`). + - `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: + releases are semver tags (`v1.2.3`); until the first public release, + `test` tracks the latest verified build. + - `APP_BASE_URL=https://wiki.example.com` — must be exactly what + browsers will use; e-mail links and the CSRF origin check derive + from it. + - Every other variable is documented inline in `.env.example` with its + default and effect; nothing outside that file configures the stack. + +3. Start: + + ```sh + docker compose up -d # behind your own reverse proxy + docker compose --profile caddy up -d # or with the bundled TLS ingress + ``` + + - **Own proxy:** route `/*` → `127.0.0.1:$WEB_PORT`, `/api/*` → + `$API_PORT`, `/collab*` → `$COLLAB_PORT` (**WebSocket upgrade + required** on `/collab`). + - **`caddy` profile:** set `DOMAIN=wiki.example.com` in `.env`; Caddy + publishes 80/443 and obtains Let's Encrypt certificates automatically + (both ports must be reachable from the internet). `DOMAIN=localhost` + issues an internal-CA certificate — for smoke tests only. + +4. Open `https://your-domain/` — a fresh instance shows the **first-run + setup wizard**. + +## First-run wizard + +Six steps, all in the browser (issue #80/#81): language → the Site-Admin +account (created verified, you are signed in immediately) → instance name +and default language → SMTP relay (**Save runs a live test** and sends a +test mail to you; _Skip_ leaves mail unconfigured — sign-up verification +will not work until an admin adds a relay) → registration mode +(open/closed) → summary + finish. The wizard locks itself permanently on +completion. Until it completes, the api answers everything but the wizard +and health endpoints with `503 setup_required` — that is not an error. + +Unattended installs skip the wizard by pre-seeding: set the +`SETUP_ADMIN_*` variables in `.env` before the first start (see +`.env.example`). + +## Updating + +```sh +# edit .env: TAG=v1.3.0 +docker compose pull && docker compose up -d +``` + +Database migrations run automatically at api start. Release notes flag +releases with a `migration` label and any manual steps. **Downgrade +window: one minor release** — `TAG` back + `pull` + `up -d` is supported +one step back; further back, restore the backup taken before the update +instead (the nightly sidecar gives you one at most 24 h old). + +## Backups & restore + +Enabled by default (ADR 0015): the `backup` sidecar dumps the database and +archives the uploads/plugins volumes nightly at `BACKUP_TIME` onto the +`backups` volume, prunes by `BACKUP_RETENTION_DAYS`, writes `status.json`, +and — with `BACKUP_MAIL_TO` set — mails you on failure. + +- On-demand backup: `docker compose run --rm -e BACKUP_RUN_ONCE=1 backup` +- List sets: `docker compose exec backup ls /backups` +- Restore: `./restore.sh ` (fetch `deploy/backup/restore.sh` + next to your compose file) — details in + `docs/operations/restore-runbook.md`. +- Copy the `backups` volume off the host regularly; a backup on the same + disk protects against mistakes, not against losing the host. + +## Health & troubleshooting + +- `GET /api/v1/readyz` is the instance's own diagnosis. HTTP 503 = + database/migrations broken (the instance cannot serve). HTTP 200 with + `"status":"degraded"` = a warning-level check: `converter`/`renderer` + down (import/export/PDF degrade, everything else works) or `backup` + stale (last success older than 26 h). Each check carries a `detail`. + Monitor set: `deploy/monitoring.md`. +- Logs: `docker compose logs api` (or `web`, `collab`, `backup`, `db`) — + structured JSON, rotated by Docker. +- **Proxy pitfalls:** editor never connects / "offline" although the page + loads → the proxy does not upgrade WebSockets on `/collab`. All + mutations fail with 403 `csrf_origin_mismatch` → `APP_BASE_URL` does not + match the URL in the browser (scheme and host must be identical). + E-mail links point at the wrong host → same variable. +- Wizard reappears after a restart → the database volume was not + persisted; never run without the `db-data` volume. +- `docker compose ps` shows `unhealthy` → that container's liveness check + fails; a _degraded_ readyz alone never marks containers unhealthy and + never restarts anything. + +## Building from source instead + +Clone the repository and build the images locally — the reference compose +carries the build contexts already: + +```sh +docker compose build && docker compose up -d +``` + +Same layout, same volumes; you trade the registry pull for a local +toolchain (Node 22 build stages run inside Docker, nothing else needed). + +## Verified install + +The guide is verified by a scripted clean-machine run +(`deploy/self-hosting-verify.sh`): fresh directory, reference compose + +`.env.example` only, `--profile caddy` with an internal-CA certificate, +asserting that the wizard answers over TLS. Run it yourself on any Docker +host — it uses its own compose project name and high ports, then removes +everything.