Files
dezky/infrastructure/docker-compose/docker-compose.yml
T
Ronni Baslund 9435baa09d feat(audit): hash-chain tamper evidence + signed checkpoints (Phase 3)
The audit log now carries cryptographic chain-of-custody. Every chained
event references the previous event's sha256, and periodic checkpoints
sign the head with HMAC-SHA-256. An attacker who modifies a historical
row must also forge every checkpoint signature past it — which requires
the AUDIT_SIGNING_KEY, kept outside Mongo.

Schema (services/platform-api/src/schemas/):
  - audit-event.schema.ts: new `seq` (monotonic) + `chained` (Phase-3-or-
    later flag) + `prevHash` + `hash`. Compound unique index on seq with
    partial filter so pre-Phase-3 rows don't collide on null.
  - audit-counter.schema.ts: single doc `_id='audit_seq'`, incremented
    atomically by findOneAndUpdate($inc).
  - audit-checkpoint.schema.ts: { at, headSeq, headHash, signature,
    sigAlg, reason }. Reason ∈ {startup, interval, threshold, manual}.

Audit module (services/platform-api/src/audit/):
  - canonical.ts: stable JSON form + hashCanonical (sha256) +
    checkpointSignature (HMAC-SHA-256) + verifyCheckpointSignature
    (timingSafeEqual). Single source of truth for hash inputs — schema
    additions land here at the same time as the field.
  - audit.service.ts: record() now allocates seq → looks up lastHash() →
    computes hash → inserts. Per-process write mutex serializes the
    allocate+lookup so concurrent writers don't both chain off the same
    predecessor. Documented multi-instance caveat (needs Mongo replica
    set + transactions OR a distributed lock).
  - checkpoint.service.ts: scheduler triggers on startup + every 5min
    + threshold of 100 events accumulated. Skips when no new chained
    events since the last anchor.
  - verifier.service.ts: walks chain in seq order, recomputes each
    hash, validates checkpoint signatures. Returns a precise break:
    'event-hash-mismatch' (in-place modification), 'event-prev-hash-
    mismatch' (insertion/deletion), or 'checkpoint-signature-mismatch'.
  - audit.controller.ts: GET /audit/verify, GET /audit/checkpoint/latest,
    POST /audit/checkpoint (manual force).

Operator UI (apps/operator/):
  - 3 new proxies under /api/audit/{verify, checkpoint/latest, checkpoint}.
  - pages/audit.vue: new "Tamper evidence" card with "Force checkpoint"
    + "Verify chain" buttons. Header shows live head seq; result line
    shows verified count or a precise break (kind + seq + expected vs
    actual hash). Background tinted green/red on ok/broken.

Env (.env + docker-compose.yml):
  - new AUDIT_SIGNING_KEY (32-byte hex HMAC secret). Prod swaps this for
    ed25519 from an HSM/KMS; verifier code stays the same because sigAlg
    is on the checkpoint doc.

Smoke-tested all three break paths against a clean chain of 5 events:
  - normal verify: ok=true, 5/5 events verified, 1 checkpoint signed
  - modified seq=3 in Mongo directly: verify returns ok=false with
    break = { kind: 'event-hash-mismatch', seq: 3, expected, actual }
  - restored, nuked checkpoint signature: break = { kind:
    'checkpoint-signature-mismatch', headSeq: 5 }
  - operator UI's verify panel reflects all three states correctly.

Legacy data: pre-Phase-3 events stay `chained: false` and are excluded
from the chain walk. Retroactive chaining of historical entries is a
one-off migration script we can run if we ever care to.

Out of scope (Phase 4 etc.):
  - TTL + cold-storage archival to Hetzner Object Storage
  - GDPR right-to-erasure tooling
  - ed25519 / HSM signing (swap is well-defined; sigAlg field is ready)
  - Multi-instance write coordination (Mongo transaction OR distributed
    lock when we scale platform-api beyond 1 replica)
2026-05-24 20:43:54 +02:00

503 lines
24 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:
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
# ─────────────────────────────────────────────────────────────────
# 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}
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