# 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 # Override the image's entrypoint to run rebrand-web.sh first, then # chain to dumb-init -- ak (the image's normal entrypoint). The script # patches "Powered by authentik" → "Powered by Dezky" in the served # web bundles on every container start, since those filenames are # version-stamped and can't be safely bind-mounted across upgrades. # Runs as root (user "0") so the sed can write to /web/dist; the script # drops privileges back to uid 1000 before exec'ing ak. user: "0" entrypoint: ["/lifecycle/rebrand-web.sh"] 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} # Consumed by the operator-application blueprint's !Env references when # it create-provisions the dezky-operator OAuth provider on a fresh # environment. On an existing environment the provider already exists and # the blueprint's state:created leaves it untouched, so these are unused. OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID} OPERATOR_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET} volumes: - authentik_media:/media - authentik_certs:/certs - authentik_templates:/templates - ./configs/authentik/blueprints:/blueprints/custom:ro - ./configs/authentik/rebrand-web.sh:/lifecycle/rebrand-web.sh: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 # Hide Authentik's end-user dashboard. Anything hitting # auth.dezky.local/ (root) or /if/user/* gets bounced to # app.dezky.local. Login flow paths (/flows/, /application/o/, # /api/, /static/, /if/flow/) and the admin UI (/if/admin/) keep # working. After OIDC sign-in the relying party callback fires # first, so users never land on /if/user/ during a normal flow — # this only matters for users who type auth.dezky.local directly. - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.regex=^https://auth\\.dezky\\.local(/|/if/user(/.*)?)$" - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.replacement=https://app.dezky.local/" - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.permanent=false" - traefik.http.routers.authentik.middlewares=authentik-hide-dashboard 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" # The worker is what discovers and applies blueprints, so it needs both # the blueprints mount (below) and the !Env values the operator-application # blueprint references when provisioning the provider on a fresh env. OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID} OPERATOR_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET} 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 # ───────────────────────────────────────────────────────────────── # 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 # Grant the OCIS admin role to the platform-api service user (autoprovisioned # OCIS account of svc-platform-api). Admin is required for libregraph # ListAllDrives, which powers the customer-admin Storage page. The UUID is # the svc user's OCIS account id; stable as long as the OCIS data volume # persists. Empty in fresh setups until the OCIS bootstrap has run. OCIS_ADMIN_USER_ID: ${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 # Read-only mount of the monorepo's shared packages so Nuxt's # components.dirs entry (/shared-packages/ui/components) resolves # inside this container. Same mount is added to the operator service. - ../../packages:/shared-packages:ro - 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 # Shared packages — see portal service for explanation. - ../../packages:/shared-packages:ro - 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} # Internal hostname (NOT https://mail.dezky.local — that's Traefik + a # mkcert cert Node's fetch rejects). Stalwart's HTTP/JMAP listener is :8080. STALWART_API_URL: http://stalwart:8080 STALWART_ADMIN_USER: admin STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD} # Gates real domain provisioning (x:Domain/set via JMAP). Off → domain # steps record 'skipped' and the Domains page works without a live Stalwart. STALWART_PROVISIONING_ENABLED: ${STALWART_PROVISIONING_ENABLED:-false} # 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 # Service-user auth for libregraph read calls (drive quotas powering the # customer-admin Storage page). OCIS has no backend service-account grant # and trusts a single issuer, so we run an OIDC password grant against the # SAME provider OCIS trusts (client `ocis-web`) as a dedicated service user # that holds the OCIS admin role. See docs/NEXT-STEPS.md. OCIS_OIDC_TOKEN_URL: https://auth.dezky.local/application/o/token/ OCIS_OIDC_CLIENT_ID: ocis-web OCIS_SVC_USERNAME: ${OCIS_SVC_USERNAME} OCIS_SVC_PASSWORD: ${OCIS_SVC_PASSWORD} # 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" # Stripe billing. Dark-launched: when BILLING_STRIPE_ENABLED != "true" or # the secret is empty, billing runs on derived data (Subscription + Price + # marginPct). Values come from the gitignored root .env. Webhook secret is # only needed once the signature-verified /stripe/webhook path goes live. STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} # Publishable key is safe to expose to the browser — the portal fetches it # from platform-api (via the setup-intent response) to mount Stripe Elements. STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} BILLING_STRIPE_ENABLED: ${BILLING_STRIPE_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