Files
dezky/infrastructure/docker-compose/docker-compose.yml
T
Ronni Baslund 28766b80c2 feat(provisioning): orchestrate Authentik/Stalwart/OCIS on tenant create
Phase 4 from docs/NEXT-STEPS.md. POST /tenants now writes Mongo AND drives
external service provisioning. A new POST /tenants/:slug/reconcile endpoint
retries the orchestration — useful when an upstream was down at create time
or external state drifted out of band.

Integration clients (services/provisioning/src/integrations/):
- AuthentikClient: real implementation. ensureGroup() is idempotent — looks
  up the group by name, creates if missing, returns either way. Group
  attributes record the tenant slug + Mongo id so we can trace back
- StalwartClient: stubbed. v0.16 removed the REST management API in favor
  of JMAP, which is significantly more work to wrap. TODO comment points
  to https://stalw.art/docs/api/management/overview for the follow-up
- OcisClient: stubbed. Needs libregraph /drives endpoint with service-to-
  service auth via OIDC client_credentials

Orchestration (provisioning.service.ts):
- Each step runs independently; one failure doesn't roll back the others
- Per-step state recorded on Tenant.provisioningStatus (ok/skipped/error/
  pending) plus error message on Tenant.provisioningErrors
- Steps return their own terminal state — 'skipped' for stubs, void
  defaults to 'ok' for real integrations
- Mongoose markModified() required for nested subdoc mutations to persist
- Tenant auto-flips status: pending → active when all steps are ok|skipped

Portal proxy routes (apps/portal/server/api/tenants/):
- POST /api/tenants and POST /api/tenants/:slug/reconcile forward the
  signed-in user's access token to the provisioning service. Lets the
  browser drive provisioning without minting tokens by hand. Will be
  replaced by a real "create workspace" flow with UI later

docker-compose: AUTHENTIK_API_URL/STALWART_API_URL/OCIS_API_URL now point
at the public Traefik-routed hostnames (with mkcert CA mounted into the
provisioning container so Node fetch trusts them). Previously these
pointed at internal Docker hostnames which doesn't work for Authentik
because of TLS issuer mismatch against the JWT.
2026-05-24 00:06:40 +02:00

430 lines
19 KiB
YAML

# Dezky — Local Development Stack
#
# Start: docker compose up -d
# Logs: docker compose logs -f [service]
# Stop: docker compose down
# Reset: docker compose down -v (WARNING: deletes all data)
#
# Prerequisites:
# 1. mkcert root CA installed (mkcert -install)
# 2. Wildcard cert generated in ./certs/dezky.local.pem
# 3. /etc/hosts entries added (run scripts/setup-hosts.sh)
# 4. .env file created from .env.example
name: dezky
networks:
dezky:
name: dezky
driver: bridge
volumes:
postgres_data:
mongo_data:
redis_data:
authentik_media:
authentik_certs:
authentik_templates:
stalwart_data:
ocis_config:
ocis_data:
portal_node_modules:
provisioning_node_modules:
services:
# ─────────────────────────────────────────────────────────────────
# Traefik — Reverse proxy with TLS termination via mkcert
# ─────────────────────────────────────────────────────────────────
traefik:
image: traefik:v3.7
container_name: dezky-traefik
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "8443:8080" # Dashboard
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
- ./configs/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./configs/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
- ./certs:/certs:ro
networks:
dezky:
aliases:
- traefik.dezky.local
- auth.dezky.local
- app.dezky.local
- api.dezky.local
- files.dezky.local
- mail.dezky.local
- office.dezky.local
- collaboration.dezky.local
labels:
- traefik.enable=true
- traefik.http.routers.dashboard.rule=Host(`traefik.dezky.local`)
- traefik.http.routers.dashboard.service=api@internal
- traefik.http.routers.dashboard.tls=true
# ─────────────────────────────────────────────────────────────────
# PostgreSQL — Shared RDBMS (Authentik, OCIS)
# ─────────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: dezky-postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD}
POSTGRES_DB: postgres
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
OCIS_DB_PASSWORD: ${OCIS_DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./configs/postgres/init.sh:/docker-entrypoint-initdb.d/init.sh:ro
networks: [dezky]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# ─────────────────────────────────────────────────────────────────
# MongoDB — Portal application data
# ─────────────────────────────────────────────────────────────────
mongo:
image: mongo:7
container_name: dezky-mongo
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: dezky
volumes:
- mongo_data:/data/db
networks: [dezky]
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# ─────────────────────────────────────────────────────────────────
# Redis — Cache + session store
# ─────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: dezky-redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks: [dezky]
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ─────────────────────────────────────────────────────────────────
# Authentik — Identity provider (OIDC/SAML SSO)
# ─────────────────────────────────────────────────────────────────
authentik-server:
image: ghcr.io/goauthentik/server:2025.10
container_name: dezky-authentik
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
AUTHENTIK_BOOTSTRAP_EMAIL: admin@dezky.local
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
volumes:
- authentik_media:/media
- authentik_certs:/certs
- authentik_templates:/templates
- ./configs/authentik/blueprints:/blueprints/custom:ro
networks: [dezky]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.authentik.rule=Host(`auth.dezky.local`)
- traefik.http.routers.authentik.tls=true
- traefik.http.services.authentik.loadbalancer.server.port=9000
authentik-worker:
image: ghcr.io/goauthentik/server:2025.10
container_name: dezky-authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
volumes:
- authentik_media:/media
- authentik_certs:/certs
- authentik_templates:/templates
networks: [dezky]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# ─────────────────────────────────────────────────────────────────
# Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
# ─────────────────────────────────────────────────────────────────
stalwart:
image: stalwartlabs/stalwart:v0.16
container_name: dezky-stalwart
restart: unless-stopped
ports:
- "25:25" # SMTP
- "465:465" # SMTPS
- "587:587" # Submission
- "143:143" # IMAP
- "993:993" # IMAPS
- "4190:4190" # ManageSieve
environment:
STALWART_FQDN: mail.dezky.local
# Pin the recovery admin so it survives restarts. Without this, Stalwart
# generates a one-time-shown password at first boot and discards it after
# initial setup.
STALWART_RECOVERY_ADMIN: admin:${STALWART_ADMIN_PASSWORD}
volumes:
- stalwart_data:/opt/stalwart
- ./configs/stalwart/config.toml:/opt/stalwart/etc/config.toml:ro
networks: [dezky]
labels:
- traefik.enable=true
- traefik.http.routers.stalwart.rule=Host(`mail.dezky.local`)
- traefik.http.routers.stalwart.tls=true
- traefik.http.services.stalwart.loadbalancer.server.port=8080
# ─────────────────────────────────────────────────────────────────
# OCIS — File storage with S3-compatible backend
# ─────────────────────────────────────────────────────────────────
ocis:
image: owncloud/ocis:7.0
container_name: dezky-ocis
restart: unless-stopped
entrypoint: /bin/sh
command: ["-c", "ocis init --insecure true || true && ocis server"]
environment:
OCIS_URL: https://files.dezky.local
OCIS_LOG_LEVEL: warn
OCIS_INSECURE: "true" # dev only — self-signed certs
PROXY_HTTP_ADDR: 0.0.0.0:9200
PROXY_TLS: "false" # Traefik terminates TLS; OCIS speaks plain HTTP internally
OCIS_OIDC_ISSUER: https://auth.dezky.local/application/o/ocis/
WEB_OIDC_CLIENT_ID: ocis-web
PROXY_AUTOPROVISION_ACCOUNTS: "true"
PROXY_USER_OIDC_CLAIM: preferred_username
PROXY_USER_CS3_CLAIM: username
OCIS_ADMIN_USER_ID: ""
IDM_CREATE_DEMO_USERS: "false"
IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD}
STORAGE_USERS_DRIVER: ocis # Local filesystem in dev
STORAGE_SYSTEM_DRIVER: ocis
OCIS_CONFIG_DIR: /etc/ocis
OCIS_BASE_DATA_PATH: /var/lib/ocis
# Extend CSP so the OCIS web SPA can reach Authentik for OIDC metadata.
# Enforced by the OCIS proxy service, not web — env var is PROXY_-prefixed.
PROXY_CSP_CONFIG_FILE_LOCATION: /etc/ocis/csp.yaml
# Expose the embedded NATS service registry and gRPC services on the Docker
# network so the collaboration container can talk to OCIS internals.
NATS_NATS_HOST: 0.0.0.0
NATS_NATS_PORT: 9233
GATEWAY_GRPC_ADDR: 0.0.0.0:9142
MICRO_GRPC_CLIENT_DNS_CACHE_TIMEOUT: 10s
volumes:
- ocis_config:/etc/ocis
- ocis_data:/var/lib/ocis
- ./configs/ocis/csp.yaml:/etc/ocis/csp.yaml:ro
networks: [dezky]
depends_on:
- authentik-server
labels:
- traefik.enable=true
- traefik.http.routers.ocis.rule=Host(`files.dezky.local`)
- traefik.http.routers.ocis.tls=true
- traefik.http.services.ocis.loadbalancer.server.port=9200
# ─────────────────────────────────────────────────────────────────
# Collabora — Office document editor (loaded inside an iframe by OCIS)
# ─────────────────────────────────────────────────────────────────
collabora:
image: collabora/code:latest
container_name: dezky-collabora
restart: unless-stopped
cap_add:
- MKNOD
environment:
# Allow the WOPI host (OCIS collaboration service) to talk to Collabora
aliasgroup1: https://collaboration.dezky.local:443
DONT_GEN_SSL_CERT: "true"
# frame_ancestors: which hosts may embed Collabora in an iframe.
# Without this set, browsers block the iframe with a Danish "Indhold blokeret".
# home_mode.enable=true disables the "Explore The New" welcome popup AND the
# feedback prompt; caps at 20 connections / 10 documents (fine for dev).
extra_params: --o:ssl.enable=false --o:ssl.termination=true --o:ssl.ssl_verification=false --o:home_mode.enable=true --o:feedback.show=false --o:net.frame_ancestors=files.dezky.local
username: admin
password: ${COLLABORA_ADMIN_PASSWORD}
# Generate a fresh WOPI proof key on every start so the public key Collabora
# advertises matches the private key it signs with. Without this, the OCIS
# collaboration service rejects WOPI calls with "ProofKeys verification failed".
entrypoint: ["/bin/bash", "-c"]
command: ["coolconfig generate-proof-key && /start-collabora-online.sh"]
networks: [dezky]
labels:
- traefik.enable=true
- traefik.http.routers.collabora.rule=Host(`office.dezky.local`)
- traefik.http.routers.collabora.tls=true
- traefik.http.services.collabora.loadbalancer.server.port=9980
# ─────────────────────────────────────────────────────────────────
# OCIS Collaboration — WOPI bridge between OCIS storage and Collabora.
# Shares the ocis_config volume so it picks up the same JWT/transfer secrets
# that ocis init generated. Without this, opening a .docx just downloads.
# ─────────────────────────────────────────────────────────────────
collaboration:
image: owncloud/ocis:7.0
container_name: dezky-collaboration
restart: unless-stopped
entrypoint: ["/bin/sh", "-c"]
command: ["ocis collaboration server"]
environment:
OCIS_URL: https://files.dezky.local
OCIS_LOG_LEVEL: warn
OCIS_INSECURE: "true"
OCIS_CONFIG_DIR: /etc/ocis
OCIS_BASE_DATA_PATH: /var/lib/ocis
COLLABORATION_HTTP_ADDR: 0.0.0.0:9300
COLLABORATION_GRPC_ADDR: 0.0.0.0:9301
COLLABORATION_WOPI_SRC: https://collaboration.dezky.local
COLLABORATION_APP_NAME: Collabora
COLLABORATION_APP_PRODUCT: Collabora
COLLABORATION_APP_DESCRIPTION: Collabora Online
# APP_ADDR is sent to the browser as the iframe src — must be public, not the
# Docker-internal hostname. Collaboration → Collabora traffic goes via Traefik.
COLLABORATION_APP_ADDR: https://office.dezky.local
COLLABORATION_APP_INSECURE: "true"
COLLABORATION_CS3API_DATAGATEWAY_INSECURE: "true"
# Match OCIS JWT/secrets via the shared config volume.
# Service registry: connect to OCIS's embedded NATS (not localhost).
MICRO_REGISTRY: nats-js-kv
MICRO_REGISTRY_ADDRESS: ocis:9233
# Direct gRPC pointer to OCIS's gateway (collaboration can't rely on registry
# lookups returning the right hostname for cross-container traffic).
REVA_GATEWAY: ocis:9142
volumes:
- ocis_config:/etc/ocis
- ocis_data:/var/lib/ocis
networks: [dezky]
depends_on:
- ocis
- collabora
labels:
- traefik.enable=true
- traefik.http.routers.collaboration.rule=Host(`collaboration.dezky.local`)
- traefik.http.routers.collaboration.tls=true
- traefik.http.services.collaboration.loadbalancer.server.port=9300
# ─────────────────────────────────────────────────────────────────
# Portal — Nuxt 3 customer portal (development mode with HMR)
# ─────────────────────────────────────────────────────────────────
portal:
image: node:20-alpine
container_name: dezky-portal
restart: unless-stopped
working_dir: /app
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
environment:
NODE_ENV: development
NUXT_HOST: 0.0.0.0
NUXT_PORT: 3000
NUXT_PUBLIC_AUTH_URL: https://auth.dezky.local
NUXT_PUBLIC_PORTAL_URL: https://app.dezky.local
NUXT_API_BASE: http://provisioning:3001
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
# OIDC (confidential client) — used by Nuxt server middleware
NUXT_OIDC_CLIENT_ID: ${PORTAL_OIDC_CLIENT_ID}
NUXT_OIDC_CLIENT_SECRET: ${PORTAL_OIDC_CLIENT_SECRET}
NUXT_OIDC_ISSUER: ${PORTAL_OIDC_ISSUER}
NUXT_OIDC_REDIRECT_URI: https://app.dezky.local/auth/oidc/callback
# Session encryption (required by nuxt-oidc-auth)
NUXT_OIDC_TOKEN_KEY: ${NUXT_OIDC_TOKEN_KEY}
NUXT_OIDC_SESSION_SECRET: ${NUXT_OIDC_SESSION_SECRET}
NUXT_OIDC_AUTH_SESSION_SECRET: ${NUXT_OIDC_AUTH_SESSION_SECRET}
# Trust mkcert root CA for Node fetch (dev only)
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
volumes:
- ../../apps/portal:/app
- portal_node_modules:/app/node_modules
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
networks: [dezky]
depends_on:
mongo:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.portal.rule=Host(`app.dezky.local`) || Host(`dezky.local`)
- traefik.http.routers.portal.tls=true
- traefik.http.services.portal.loadbalancer.server.port=3000
# ─────────────────────────────────────────────────────────────────
# Provisioning service — NestJS worker for tenant lifecycle
# ─────────────────────────────────────────────────────────────────
provisioning:
image: node:20-alpine
container_name: dezky-provisioning
restart: unless-stopped
working_dir: /app
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm start:dev"
environment:
NODE_ENV: development
PORT: 3001
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
AUTHENTIK_API_URL: https://auth.dezky.local/api/v3
AUTHENTIK_API_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
STALWART_API_URL: https://mail.dezky.local
STALWART_ADMIN_USER: admin
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
OCIS_API_URL: https://files.dezky.local
# JWT validation against Authentik for portal-issued access tokens
AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/
AUTHENTIK_AUDIENCE: dezky-portal
AUTHENTIK_JWKS_URI: https://auth.dezky.local/application/o/dezky-portal/jwks/
# Trust mkcert root CA for Node fetch (dev only)
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
volumes:
- ../../services/provisioning:/app
- provisioning_node_modules:/app/node_modules
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
networks: [dezky]
depends_on:
mongo:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.dezky.local`)
- traefik.http.routers.api.tls=true
- traefik.http.services.api.loadbalancer.server.port=3001