All checks were successful
whatever the operator called their instance, `instance.name` was never rendered in the running app at all, and there was no favicon anywhere — `index.html` had no `<link rel="icon">` and `public/` held only fonts and theme-init.js. Where the line is drawn, and why: - **The api never decodes an image.** Cropping, scaling and the conversion to PNG happen on a canvas in the browser; the api checks the PNG signature, reads the IHDR dimensions at their fixed offsets and enforces the caps. An image library would put a decoder in front of attacker-supplied bytes AND would have to be carried through the `--network none` offline build. Reading two big-endian integers is not decoding. - **SVG is refused**, with its own error message rather than a generic "not a PNG": it can carry script, and serving it from our own origin would be a cross-site-scripting vector. An operator who tried one should learn that it is deliberate. - **The crop is driven by number inputs, not by dragging.** A drag-only cropper excludes keyboard and switch users outright; a number input is arrow-key operable and screen-reader readable without any custom aria. The resulting pixel size is stated in text, not only drawn as a frame. - **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has already resolved `data-theme` before first paint, so the correct logo is the one painted rather than the one that appears after a flash. Without a dark variant the LIGHT logo carries both themes — the operator's own asset shown unchanged beats one they did not choose (the rule #307 extends to ponds). The settings screen warns; it never blocks. - **The favicon link is static, its resource dynamic.** index.html stays a static file and the api answers with the uploaded icon or a shipped default — that route must never 404, or the browser keeps its generic icon for good. The default is generated by a script from Node's own zlib (`gen-default-favicon.mjs`), for the same offline-build reason. - Both favicon sizes are uploaded together: one source, one crop, so the tab icon and the home-screen icon can never disagree. - Branding is served WITHOUT a session, because the login screen carries it and the browser fetches the favicon before anyone signs in. The admin screen says so — an operator may not expect their logo to be public. - The metadata is not writable through the settings endpoint: it describes bytes on disk, and hand-writing it would claim an asset that is not there. `./data/branding` follows the three-step rule #303 paid for: env default + `data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the `mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and closes the hole that made #303's variant invisible: the nightly archive skips a missing directory WORDLESSLY, so the fence now demands that every `*_DIR` the backup env declares actually travels in the archive. Verified against the real defect — removing the line fails it by name. Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start so #307 is the same event with a different scope, not a second id. Verified: api suite 103 files green (a lone `public-api` ECONNRESET under local parallel load, green in isolation — the documented local flake); branding suite 12 tests against a real directory; crop arithmetic unit tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the new section (overflow 0); and the whole flow walked in the browser: upload → crop 780×180 → stored as 512×118 → logo in the sidebar linking home with the instance name as its accessible name → topbar wordmark following `instance.name` → light logo still shown under `data-theme="dark"`.
294 lines
11 KiB
YAML
294 lines
11 KiB
YAML
# Production Compose stack — one file for every stage and for self-hosters.
|
|
# Configuration comes from .env (see .env.example); the host reverse proxy
|
|
# routes to the two published localhost ports (deployment.md §Compose).
|
|
#
|
|
# Networks: `frontend` is what the reverse proxy reaches (via published
|
|
# ports); `internal` connects api/collab to db and (later) the converter
|
|
# sidecars, which are never exposed.
|
|
|
|
name: ${COMPOSE_PROJECT_NAME:-dorfteich}
|
|
|
|
x-logging: &logging
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: '10m'
|
|
max-file: '5'
|
|
|
|
services:
|
|
web:
|
|
image: ${IMAGE_PREFIX:-dorfteich}-web:${TAG:-latest}
|
|
build:
|
|
context: ../..
|
|
dockerfile: apps/web/Dockerfile
|
|
args:
|
|
APP_VERSION: ${TAG:-latest}
|
|
restart: unless-stopped
|
|
ports:
|
|
- '127.0.0.1:${WEB_PORT:-8100}:8080'
|
|
networks: [frontend]
|
|
depends_on:
|
|
api:
|
|
condition: service_started
|
|
<<: *logging
|
|
|
|
api:
|
|
image: ${IMAGE_PREFIX:-dorfteich}-api:${TAG:-latest}
|
|
build:
|
|
context: ../..
|
|
dockerfile: apps/api/Dockerfile
|
|
args:
|
|
APP_VERSION: ${TAG:-latest}
|
|
restart: unless-stopped
|
|
environment:
|
|
NODE_ENV: production
|
|
PORT: '3000'
|
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
|
|
# Signs the short-lived collaboration tokens; the collab service below
|
|
# verifies them, so both MUST carry the same value (issue #34).
|
|
COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:?set in .env}
|
|
# Public URL of this stage — e-mail links and the CSRF origin check
|
|
# depend on it matching what browsers actually use.
|
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173}
|
|
# Session bounds in hours (issue #190); empty = application defaults
|
|
# (absolute 168 h, idle 72 h). Hardened deployments set them lower.
|
|
SESSION_ABSOLUTE_HOURS: ${SESSION_ABSOLUTE_HOURS:-}
|
|
SESSION_IDLE_HOURS: ${SESSION_IDLE_HOURS:-}
|
|
# Must match the backup service's value — the api validates admin
|
|
# backup settings against the same allowlist (issue #192).
|
|
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
|
|
# VS-NfD hardening-profile mode (issue #243, ADR 0027):
|
|
# off | marked | hidden | enforced. Empty = off — no marking anywhere.
|
|
VS_NFD_MODE: ${VS_NFD_MODE:-}
|
|
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
|
|
# the relay to the secret store on the `secrets` volume (issue #80);
|
|
# values set here in the stage .env always win over the store.
|
|
SMTP_HOST: ${SMTP_HOST:-}
|
|
SMTP_PORT: ${SMTP_PORT:-}
|
|
SMTP_SECURE: ${SMTP_SECURE:-}
|
|
SMTP_USER: ${SMTP_USER:-}
|
|
SMTP_PASS: ${SMTP_PASS:-}
|
|
SMTP_FROM: ${SMTP_FROM:-}
|
|
# Env-backed secret store on the `secrets` volume mount below
|
|
# (security.md §Secrets, issue #80).
|
|
SECRETS_FILE: /data/secrets/secrets.env
|
|
# Optional first-run pre-seeding (issue #80): with all three
|
|
# SETUP_ADMIN_* values set, a fresh database skips the browser wizard.
|
|
SETUP_ADMIN_USERNAME: ${SETUP_ADMIN_USERNAME:-}
|
|
SETUP_ADMIN_EMAIL: ${SETUP_ADMIN_EMAIL:-}
|
|
SETUP_ADMIN_PASSWORD: ${SETUP_ADMIN_PASSWORD:-}
|
|
SETUP_ADMIN_DISPLAY_NAME: ${SETUP_ADMIN_DISPLAY_NAME:-}
|
|
SETUP_INSTANCE_NAME: ${SETUP_INSTANCE_NAME:-}
|
|
SETUP_DEFAULT_LOCALE: ${SETUP_DEFAULT_LOCALE:-}
|
|
SETUP_REGISTRATION_MODE: ${SETUP_REGISTRATION_MODE:-}
|
|
# Matches the `uploads` volume mount below (ADR 0011).
|
|
UPLOADS_DIR: /data/uploads
|
|
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
|
|
# Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them.
|
|
PLUGINS_DIR: /data/plugins
|
|
# Operator-uploaded fonts (issue #303, ADR 0016 §#303). A sibling of
|
|
# uploads/plugins so all three travel in one restore set — NOT inside
|
|
# the image-baked font catalog, which a deploy would overwrite.
|
|
CUSTOM_FONTS_DIR: /data/fonts
|
|
# Instance and pond branding assets (issues #306/#307) — same reasoning
|
|
# as the fonts above: operator data, so its own volume in the restore set.
|
|
BRANDING_DIR: /data/branding
|
|
# Read-only view of the backup sidecar's volume — the api only consumes
|
|
# its status.json (readyz freshness #85, admin backup card #86).
|
|
BACKUPS_DIR: /data/backups
|
|
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
|
|
PANDOC_URL: http://pandoc:3030
|
|
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
|
|
GOTENBERG_URL: http://gotenberg:3000
|
|
ports:
|
|
- '127.0.0.1:${API_PORT:-8101}:3000'
|
|
networks: [frontend, internal]
|
|
volumes:
|
|
- uploads:/data/uploads
|
|
- plugins:/data/plugins
|
|
- customfonts:/data/fonts
|
|
- branding:/data/branding
|
|
- secrets:/data/secrets
|
|
- backups:/data/backups:ro
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
pandoc:
|
|
condition: service_healthy
|
|
gotenberg:
|
|
condition: service_healthy
|
|
<<: *logging
|
|
|
|
collab:
|
|
image: ${IMAGE_PREFIX:-dorfteich}-collab:${TAG:-latest}
|
|
build:
|
|
context: ../..
|
|
dockerfile: apps/collab/Dockerfile
|
|
args:
|
|
APP_VERSION: ${TAG:-latest}
|
|
restart: unless-stopped
|
|
environment:
|
|
NODE_ENV: production
|
|
PORT: '3000'
|
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
|
|
# Must match the api's value — this service verifies the tokens it signs.
|
|
COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:?set in .env}
|
|
ports:
|
|
# The host reverse proxy routes /collab here with WebSocket upgrade
|
|
# (deployment.md, deploy/stages.md).
|
|
- '127.0.0.1:${COLLAB_PORT:-8102}:3000'
|
|
networks: [frontend, internal]
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1']
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
<<: *logging
|
|
|
|
# Backup sidecar (ADR 0015, issue #83): nightly `pg_dump -Fc` + one tar of
|
|
# the uploads/plugins volumes as a consistent restore set on the `backups`
|
|
# volume, prune by retention, `status.json`, failure mail directly via SMTP
|
|
# (the api may be the broken part). Restore runs through
|
|
# deploy/backup/restore.sh, which drives this same image.
|
|
backup:
|
|
image: ${IMAGE_PREFIX:-dorfteich}-backup:${TAG:-latest}
|
|
build:
|
|
context: ../..
|
|
dockerfile: apps/backup/Dockerfile
|
|
args:
|
|
APP_VERSION: ${TAG:-latest}
|
|
restart: unless-stopped
|
|
environment:
|
|
NODE_ENV: production
|
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
|
|
# Daily run time (HH:MM) in TZ; retention 30 d default, 7 d on Test/Int.
|
|
TZ: ${TZ:-}
|
|
BACKUP_TIME: ${BACKUP_TIME:-}
|
|
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-}
|
|
# Failure-alert recipient; empty disables the mail (logged instead).
|
|
BACKUP_MAIL_TO: ${BACKUP_MAIL_TO:-}
|
|
BACKUP_MAIL_LOCALE: ${BACKUP_MAIL_LOCALE:-}
|
|
BACKUP_INSTANCE_LABEL: ${BACKUP_INSTANCE_LABEL:-${COMPOSE_PROJECT_NAME:-dorfteich}}
|
|
# Optional rsync mirror to a private host (issue #84); the SSH key
|
|
# lives on the secrets volume (see deploy/backup-basel.md).
|
|
BACKUP_MIRROR_TARGET: ${BACKUP_MIRROR_TARGET:-}
|
|
BACKUP_MIRROR_SSH_KEY: ${BACKUP_MIRROR_SSH_KEY:-}
|
|
BACKUP_MIRROR_SSH_PORT: ${BACKUP_MIRROR_SSH_PORT:-}
|
|
# Deploy-level allowlist of backup destination hosts (issue #192,
|
|
# ADR 0026). Empty disables ALL remote targets (WebDAV + mirror).
|
|
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
|
|
# Same SMTP resolution as the api: explicit env wins, the wizard-written
|
|
# secret store fills the gaps (issue #80).
|
|
SMTP_HOST: ${SMTP_HOST:-}
|
|
SMTP_PORT: ${SMTP_PORT:-}
|
|
SMTP_SECURE: ${SMTP_SECURE:-}
|
|
SMTP_USER: ${SMTP_USER:-}
|
|
SMTP_PASS: ${SMTP_PASS:-}
|
|
SMTP_FROM: ${SMTP_FROM:-}
|
|
networks: [internal]
|
|
volumes:
|
|
# Write access to the data mounts is for the restore path only; the
|
|
# nightly run just reads them into the archive.
|
|
- uploads:/data/uploads
|
|
- plugins:/data/plugins
|
|
- customfonts:/data/fonts
|
|
- branding:/data/branding
|
|
- secrets:/data/secrets:ro
|
|
- backups:/backups
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
<<: *logging
|
|
|
|
db:
|
|
image: ${REGISTRY_PREFIX:-}postgres:17.5-alpine@sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: dorfteich
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
|
|
POSTGRES_DB: dorfteich
|
|
networks: [internal]
|
|
volumes:
|
|
- db-data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'pg_isready -U dorfteich -d dorfteich']
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 12
|
|
<<: *logging
|
|
|
|
# Import/export converter (ADR 0009, issue #62): pandoc in HTTP server mode
|
|
# on the internal network only — never exposed. Pinned image; the api reaches
|
|
# it at http://pandoc:3030. `wget` ships in the (busybox-based) image.
|
|
pandoc:
|
|
image: ${REGISTRY_PREFIX:-}pandoc/core:3.6@sha256:5b8a29d9b70d5d8ca766e5d1dcfc41916b23ab79276a80527be70516110f4c1e
|
|
command: ['server']
|
|
restart: unless-stopped
|
|
networks: [internal]
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3030/version || exit 1']
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
<<: *logging
|
|
|
|
# PDF export renderer (ADR 0009, issue #67): Gotenberg wraps headless Chromium
|
|
# on the internal network only — never exposed. Pinned image; the api reaches
|
|
# it at http://gotenberg:3000 and posts export HTML to its Chromium route.
|
|
gotenberg:
|
|
image: ${REGISTRY_PREFIX:-}gotenberg/gotenberg:8@sha256:67097317623a503ba2a6a7e9ae8db6929a1f7e1bbd88077bacf2d325fbdab923
|
|
restart: unless-stopped
|
|
networks: [internal]
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'curl -sf http://127.0.0.1:3000/health || exit 1']
|
|
interval: 30s
|
|
timeout: 5s
|
|
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: ${REGISTRY_PREFIX:-}caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
|
|
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:
|
|
|
|
volumes:
|
|
db-data:
|
|
uploads:
|
|
plugins:
|
|
customfonts:
|
|
branding:
|
|
secrets:
|
|
backups:
|
|
# Only used by the optional `caddy` profile (certificates + state).
|
|
caddy-data:
|
|
caddy-config:
|