dorfteich/deploy/compose/docker-compose.yml
Claude Opus 4.8 8a68ef68e7
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
Add PDF export via Gotenberg (#67)
Server-side PDF export for reading/sharing (ADR 0009), rendered by a new
internal Gotenberg (headless Chromium) sidecar.

- Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned,
  healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at
  warning-level (mirrors the converter) so PDF export degrades gracefully when
  Gotenberg is down without failing readyz.
- Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome)
  — the page's content with images inlined as data URIs, the pond's fonts
  inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print
  CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's
  footer. Plugin-block fallbacks are a marked TODO(#79) for M7.
- Fonts in the api image: the api Dockerfile now bakes the font catalog in
  (`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen
  WOFF2 and inline them; a missing file falls back to the system stack.
- Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read
  permission checked by the guard) and enqueues an `export_pdf` job on the #62
  queue with the HTML as input; the worker branches `to === 'pdf'` to the
  `GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable
  sidecar and failing a refused render (`renderer_unavailable`/`render_failed`,
  de+en). The client polls and downloads `GET /jobs/:id/result`.
- Frontend: the page-menu PDF button is now a real export (PDF added to
  EXPORT_FORMATS; the disabled placeholder removed).
- Tests: export.service.db PDF cases (HTML has title/font-variable/inlined
  image; renderer-down fails with `render_failed`); e2e PDF export self-skips
  without a Gotenberg sidecar (like the .docx case). Verified locally against
  real Gotenberg — a valid PDF with the pond font embedded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 12:11:12 +02:00

164 lines
5.1 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}
# SMTP relay; defaults are only useful with the dev Mailpit overlay.
SMTP_HOST: ${SMTP_HOST:-localhost}
SMTP_PORT: ${SMTP_PORT:-1025}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
# Matches the `uploads` volume mount below (ADR 0011).
UPLOADS_DIR: /data/uploads
# 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
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
db:
image: postgres:17.5-alpine
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: pandoc/core:3.6
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: gotenberg/gotenberg:8
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
networks:
frontend:
internal:
volumes:
db-data:
uploads: