Some checks failed
CD / Build and push images (push) Successful in 3m16s
CI / Lint, typecheck, test (push) Successful in 3m5s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Failing after 3m35s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Build container images (push) Has been skipped
When the api runs against a database without the setup.completedAt marker, a global SetupGuard answers every non-exempt route with 503 setup_required; only /setup/*, health probes, and the session routes stay reachable. The wizard steps (POST /setup/admin|instance|smtp| registration|complete) write straight to their production homes; the Site Admin step signs its creator in, later steps require that session. Completing sets the marker and locks every step permanently (410, also across restarts, and not reopenable via PATCH /admin/settings). SMTP entered in the wizard is verified with a live delivery test first (failure blocks the step with the transport error as detail) and then persisted to the new env-backed secret store: a mode-600 dotenv file on the new `secrets` volume (SECRETS_FILE). Explicit container env always wins over the store; empty compose-passed strings count as unset. The mail transport now resolves lazily through SmtpConfigService so wizard changes apply without a restart. SETUP_ADMIN_* env pre-seeds the whole wizard at boot for automated deploys; a backfill migration marks instances that already have a Site Admin as completed, and seed/vitest global-setup do the same for fixture databases. The setup e2e suite provisions its own fresh database (CREATE DATABASE + migrate deploy) per run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
185 lines
6.2 KiB
YAML
185 lines
6.2 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. 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
|
|
# 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
|
|
- secrets:/data/secrets
|
|
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:
|
|
plugins:
|
|
secrets:
|