4d9e906ec1
Final piece of the audit work. Events older than the hot retention window
move to S3-compatible object storage with signed manifests. Production uses
Hetzner Object Storage; dev uses a MinIO container with the same API.
Infra (infrastructure/docker-compose):
- New `minio` service exposing the S3 API at minio:9000 + admin console at
minio.dezky.local. Healthchecked. Bucket-init sidecar runs `mc mb` once
to create `dezky-audit`; safe to re-run.
- .env adds MINIO_ROOT_USER + MINIO_ROOT_PASSWORD.
- platform-api env: AUDIT_COLD_{ENDPOINT,REGION,BUCKET,ACCESS_KEY,SECRET_KEY}
+ AUDIT_HOT_RETENTION_DAYS=90 + ARCHIVE_ENABLED=false (dormant in dev;
operator UI's "Run archive now" bypasses this gate). AUDIT_COLD_SSE
opts into SSE-S3 — left unset in dev because MinIO without a KMS rejects
AES256 PUTs with "KMS is not configured".
Platform-api (services/platform-api/src/cold/):
- cold-storage.client.ts: thin @aws-sdk/client-s3 wrapper — put/head/list.
forcePathStyle=true so MinIO and Hetzner both work; same code, env-swap.
- archive.service.ts: runOnce() selects chained events with at < cutoff →
serializes to JSONL → gzip → sha256s → uploads JSONL + signed manifest
→ HEAD-confirms both objects exist → records an ArchiveBatch doc → only
then deletes from hot Mongo. Crash-safe: a failed upload leaves events
in hot. Manifest uses the Phase 3 AUDIT_SIGNING_KEY (HMAC-SHA-256), so
archives + checkpoints share trust chain. Bypassable via { override:
true } for the operator's UI force-run.
- archive.worker.ts: hourly tick guarded by configured run-hour-UTC
(default 03:00) + day-guard so the same UTC day doesn't archive twice.
Disabled until ARCHIVE_ENABLED=true.
- archive-batch.schema.ts: { archivedAt, startSeq, endSeq, eventCount,
manifestSha256, jsonlKey, manifestKey, bytesUncompressed }. The
manifest sha256 stored in Mongo lets us detect manifest tampering
without downloading the actual manifest.
Audit module additions:
- audit.controller.ts: GET /audit/archives, POST /audit/archive/run,
/audit/verify now reports { oldestHotSeq, highestArchivedSeq } so the
UI shows the tier boundary.
Operator UI (apps/operator):
- 2 new proxies: /api/audit/archives + /api/audit/archive/run (force
override=true). Both behind operator auth via the existing platformApi
helper.
- audit.vue: new "Cold storage" card with batch table (archived-at, seq
range, event count, size, truncated manifest sha256), "Run archive
now" button + per-run result line.
Smoke-tested end-to-end:
- 7 chained events in hot. /api/audit/archive/run → ok=true, batchId
returned. JSONL + manifest both exist in MinIO (verified via mc ls +
mc cat). Mongo's chained set went 7 → 0. Verify reports
highestArchivedSeq=1446 (since we burn-allocate seqs on Authentik
dup-key rejections). Operator /audit panel shows the batch with
manifest hash 1d8263…
- First attempt with SSE-S3 enabled failed cleanly (MinIO KMS not
configured) — archive service correctly left events in hot Mongo.
Made SSE opt-in via AUDIT_COLD_SSE=true; prod turns it on.
Out of scope (each could be its own session):
- Restore-to-hot endpoint (today: download from S3 + offline query)
- Client-side encryption (today: SSE-S3 in prod, none in dev)
- Multi-region replication
- Soft TTL safety net (defense-in-depth on top of app-managed deletion)
This completes the four-phase audit log work:
1. platform-api as audit hub
2. External system ingest (Authentik / Stalwart / OCIS)
3. Hash-chain + signed checkpoints (tamper evidence)
4. Cold-storage archival (retention without unbounded Mongo growth)
564 lines
26 KiB
YAML
564 lines
26 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:
|
|
ocis_audit_log:
|
|
portal_node_modules:
|
|
platform_api_node_modules:
|
|
operator_node_modules:
|
|
# MinIO data (S3-compatible cold storage for audit archives). Production
|
|
# swaps the endpoint to Hetzner Object Storage and this volume goes away.
|
|
minio_data:
|
|
|
|
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
|
|
- operator.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
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
# MinIO — S3-compatible cold storage for audit archives (Phase 4).
|
|
# Production swaps endpoint to Hetzner Object Storage; same protocol.
|
|
# ─────────────────────────────────────────────────────────────────
|
|
minio:
|
|
image: minio/minio:latest
|
|
container_name: dezky-minio
|
|
restart: unless-stopped
|
|
command: server /data --console-address ":9001"
|
|
environment:
|
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
|
volumes:
|
|
- minio_data:/data
|
|
networks: [dezky]
|
|
healthcheck:
|
|
test: ["CMD", "mc", "ready", "local"]
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 5
|
|
labels:
|
|
- traefik.enable=true
|
|
# Optional: expose MinIO admin UI behind Traefik. Dev only — production
|
|
# uses Hetzner's console.
|
|
- traefik.http.routers.minio.rule=Host(`minio.dezky.local`)
|
|
- traefik.http.routers.minio.tls=true
|
|
- traefik.http.services.minio.loadbalancer.server.port=9001
|
|
|
|
# One-shot init container that creates the audit bucket if it doesn't
|
|
# exist. Idempotent — re-running is a no-op. Exits cleanly so docker
|
|
# doesn't restart it.
|
|
minio-init:
|
|
image: minio/mc:latest
|
|
container_name: dezky-minio-init
|
|
depends_on:
|
|
minio:
|
|
condition: service_healthy
|
|
networks: [dezky]
|
|
entrypoint: >
|
|
sh -c "
|
|
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
|
|
mc mb --ignore-existing local/dezky-audit &&
|
|
echo 'MinIO bucket dezky-audit ready'
|
|
"
|
|
restart: "no"
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
# 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}
|
|
# Shared HMAC secret for the audit webhook POSTed to platform-api.
|
|
# config.toml references this via %{env:STALWART_WEBHOOK_SECRET}%.
|
|
STALWART_WEBHOOK_SECRET: ${STALWART_WEBHOOK_SECRET}
|
|
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
|
|
# Audit service — JSON Lines to a shared volume that platform-api also
|
|
# mounts read-only. Used by the OCIS ingest worker to fold file/share
|
|
# mutations into the global audit timeline. The audit microservice is
|
|
# NOT part of the default `ocis server` set so we opt in explicitly.
|
|
OCIS_ADD_RUN_SERVICES: audit
|
|
AUDIT_LOG_FILE_PATH: /var/log/ocis/audit.log
|
|
AUDIT_LOG_FORMAT: json
|
|
volumes:
|
|
- ocis_config:/etc/ocis
|
|
- ocis_data:/var/lib/ocis
|
|
- ocis_audit_log:/var/log/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://platform-api:3001
|
|
PLATFORM_API_INTERNAL_URL: http://platform-api: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
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
# Operator portal — internal admin app at operator.dezky.local.
|
|
# Separate from the customer portal: own OAuth client (dezky-operator),
|
|
# own session secrets, own cookie domain. Audience-gated mutations on
|
|
# platform-api require the token this app mints.
|
|
# ─────────────────────────────────────────────────────────────────
|
|
operator:
|
|
image: node:20-alpine
|
|
container_name: dezky-operator
|
|
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
|
|
# OIDC — dezky-operator OAuth client (separate from dezky-portal)
|
|
NUXT_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
|
|
NUXT_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
|
|
NUXT_OIDC_ISSUER: ${OPERATOR_OIDC_ISSUER}
|
|
NUXT_OIDC_REDIRECT_URI: https://operator.dezky.local/auth/oidc/callback
|
|
# Session encryption — distinct from portal so the two surfaces can't
|
|
# decrypt each other's session cookies
|
|
NUXT_OIDC_TOKEN_KEY: ${OPERATOR_NUXT_OIDC_TOKEN_KEY}
|
|
NUXT_OIDC_SESSION_SECRET: ${OPERATOR_NUXT_OIDC_SESSION_SECRET}
|
|
NUXT_OIDC_AUTH_SESSION_SECRET: ${OPERATOR_NUXT_OIDC_AUTH_SESSION_SECRET}
|
|
# Reach platform-api internally for the server-side token-forwarding proxy
|
|
PLATFORM_API_INTERNAL_URL: http://platform-api:3001
|
|
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
|
|
volumes:
|
|
- ../../apps/operator:/app
|
|
- operator_node_modules:/app/node_modules
|
|
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
|
|
networks: [dezky]
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.operator.rule=Host(`operator.dezky.local`)
|
|
- traefik.http.routers.operator.tls=true
|
|
- traefik.http.services.operator.loadbalancer.server.port=3000
|
|
|
|
# ─────────────────────────────────────────────────────────────────
|
|
# platform-api — NestJS service. Owns tenants, partners, users,
|
|
# subscriptions, and provisioning orchestration.
|
|
# ─────────────────────────────────────────────────────────────────
|
|
platform-api:
|
|
image: node:20-alpine
|
|
container_name: dezky-platform-api
|
|
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}
|
|
# HMAC secret Stalwart signs its webhook POSTs with; we verify on
|
|
# /ingest/stalwart/webhook. Both ends read the same env var.
|
|
STALWART_WEBHOOK_SECRET: ${STALWART_WEBHOOK_SECRET}
|
|
OCIS_API_URL: https://files.dezky.local
|
|
# JWT validation against Authentik for portal-issued access tokens.
|
|
# Issuers are comma-separated — each Authentik OAuth provider issues tokens
|
|
# with its own per-app issuer URL, so we accept both portal and operator.
|
|
AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/,https://auth.dezky.local/application/o/dezky-operator/
|
|
# Comma-separated list of accepted JWT audiences. Tokens issued for either
|
|
# the customer portal or the operator portal are valid against this service;
|
|
# per-endpoint guards further restrict operator-only mutations.
|
|
AUTHENTIK_AUDIENCE: dezky-portal,dezky-operator
|
|
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
|
|
# Path to the OCIS audit log inside this container. The same shared
|
|
# volume is mounted on the OCIS service writeable; here it's read-only.
|
|
OCIS_AUDIT_LOG_PATH: /var/log/ocis/audit.log
|
|
# Tamper-evidence signing key for the audit hash chain. Rotation closes
|
|
# out the current segment with a key-rotation checkpoint (not in scope
|
|
# for Phase 3). Prod swaps HMAC for ed25519 from an HSM.
|
|
AUDIT_SIGNING_KEY: ${AUDIT_SIGNING_KEY}
|
|
# Cold storage (Phase 4). Dev uses MinIO on the docker network; prod
|
|
# swaps endpoint to Hetzner Object Storage and provides real IAM keys.
|
|
# ARCHIVE_ENABLED defaults to false in dev so the worker doesn't move
|
|
# data we still want to query while building. The UI "Run archive now"
|
|
# button bypasses this gate.
|
|
AUDIT_COLD_ENDPOINT: http://minio:9000
|
|
AUDIT_COLD_REGION: us-east-1
|
|
AUDIT_COLD_BUCKET: dezky-audit
|
|
AUDIT_COLD_ACCESS_KEY: ${MINIO_ROOT_USER}
|
|
AUDIT_COLD_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
|
AUDIT_HOT_RETENTION_DAYS: "90"
|
|
ARCHIVE_ENABLED: "false"
|
|
volumes:
|
|
- ../../services/platform-api:/app
|
|
- platform_api_node_modules:/app/node_modules
|
|
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
|
|
- ocis_audit_log:/var/log/ocis: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
|
|
|