28766b80c2
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.
430 lines
19 KiB
YAML
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
|
|
|