# =====================================================================
# Olivasal — self-hosted stack
# =====================================================================
#   docker compose up -d                  core stack (no AI)
#   docker compose --profile ai up -d     + Ollama and the ASR worker (needs GPU)
#
# Copy .env.example to .env first. install.sh (slice 4) will generate the
# secrets and drive the first certificate issuance; until then see the notes
# in .env.example and do it by hand.
#
# ---------------------------------------------------------------------
# WHY THE MEDIA SERVICES USE THE HOST NETWORK
# LiveKit needs UDP 50000-60000 for media. Publishing ten thousand ports
# through Docker's NAT is unusably slow, so media-server and egress run with
# network_mode: host. Consequences, which drive the rest of this file:
#   * They cannot resolve compose service names (no `api`, no `redis`).
#     Everything they talk to is therefore published on 127.0.0.1.
#   * nginx reaches them via host.docker.internal, mapped to host-gateway.
# ---------------------------------------------------------------------

name: olivasal

x-restart: &restart
  restart: unless-stopped

services:
  # ---------------------------------------------------------------
  # Edge: TLS termination and the reverse proxies. Carries no application
  # code — the SPA arrives in the web-assets volume, published by `web`.
  # Kept separate from the bundle so the proxy can later front more than one
  # api replica without being rebuilt.
  # ---------------------------------------------------------------
  nginx:
    <<: *restart
    build:
      context: .
      dockerfile: docker/nginx.Dockerfile
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-nginx:${OLIVASAL_VERSION:-stable}
    depends_on:
      api:
        condition: service_healthy
      # Must have finished copying, or nginx serves a half-written bundle.
      web:
        condition: service_completed_successfully
    environment:
      OLIVASAL_DOMAIN: ${OLIVASAL_DOMAIN:?set OLIVASAL_DOMAIN in .env}
      LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-}
      RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-}
    ports:
      - '80:80'
      - '443:443'
    volumes:
      # Read-write: 10-olivasal-config.envsh writes config.json in here at boot.
      - web-assets:/usr/share/nginx/html
      - certs:/etc/letsencrypt:ro
      - certbot-webroot:/var/www/certbot
    extra_hosts:
      # Lets nginx (bridge network) reach the host-network media server.
      - 'host.docker.internal:host-gateway'
    networks: [olivasal]

  # ---------------------------------------------------------------
  # The SPA bundle. Wipes the shared volume, copies this release in, exits.
  # Runs on every `up`, which is what keeps an upgraded stack from serving the
  # previous release's index.html against the new hashed chunks.
  # ---------------------------------------------------------------
  web:
    build:
      context: .
      dockerfile: docker/web.Dockerfile
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-web:${OLIVASAL_VERSION:-stable}
    restart: 'no'
    volumes:
      - web-assets:/srv/publish
    networks: [olivasal]

  # ---------------------------------------------------------------
  # Certificates. Checks twice a day; `certbot renew` is a no-op unless the
  # certificate is inside its 30-day renewal window, so this is cheap.
  # ---------------------------------------------------------------
  certbot:
    <<: *restart
    image: certbot/certbot:latest
    depends_on: [nginx]
    volumes:
      - certs:/etc/letsencrypt
      - certbot-webroot:/var/www/certbot
    entrypoint: /bin/sh
    command:
      - -c
      - |
        trap exit TERM
        while :; do
          certbot renew --webroot -w /var/www/certbot --quiet \
            --deploy-hook "touch /etc/letsencrypt/.renewed"
          sleep 12h & wait $${!}
        done
    networks: [olivasal]

  # ---------------------------------------------------------------
  # API. Published on loopback because egress and the media server run on the
  # host network and cannot use compose DNS to reach it.
  # ---------------------------------------------------------------
  api:
    <<: *restart
    build:
      context: .
      dockerfile: docker/api.Dockerfile
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-api:${OLIVASAL_VERSION:-stable}
    depends_on:
      mongo:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      NODE_ENV: production
      PORT: '3050'
      OLIVASAL_DOMAIN: ${OLIVASAL_DOMAIN}
      # What product this is — billing, licensing, branding, support routing.
      # Unset means `cloud`, so Megam's own deploy needs no change.
      OLIVASAL_EDITION: selfhost
      # Licensing. The public key is baked into the image; only the address is
      # configurable, so an install cannot be pointed at a licence server the
      # customer controls without also replacing the key.
      LICENCE_SERVER_URL: ${LICENCE_SERVER_URL:-https://olivasal.com}
      # Collected by install.sh, sent to Megam when this install registers.
      OLIVASAL_COMPANY_NAME: ${OLIVASAL_COMPANY_NAME:-}
      OLIVASAL_COMPANY_EMAIL: ${OLIVASAL_COMPANY_EMAIL:-}
      WEB_BASE_URL: https://${OLIVASAL_DOMAIN}
      MONGODB_URI: mongodb://${MONGO_APP_USER:-olivasal}:${MONGO_APP_PASSWORD:?set MONGO_APP_PASSWORD}@mongo:27017/${MONGO_DB:-olivasal}?authSource=${MONGO_DB:-olivasal}
      REDIS_URL: redis://redis:6379
      RECORDINGS_ROOT: /var/lib/olivasal/recordings
      # Egress runs on the host network, so it fetches the custom recording
      # template over loopback rather than by service name.
      EGRESS_TEMPLATE_URL: http://127.0.0.1:3050/api/egress/template
      # The media server is on the HOST network, so from inside this bridge
      # container 127.0.0.1 would be the api itself. host.docker.internal is
      # mapped to the host gateway in extra_hosts below.
      LIVEKIT_HOST: ws://host.docker.internal:7880
      LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY}
      LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET}
      JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET}
      JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET}
      JWT_ACCESS_EXPIRES_IN: ${JWT_ACCESS_EXPIRES_IN:-15m}
      JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-30d}
      INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY:?set INTEGRATIONS_ENCRYPTION_KEY}
      PLATFORM_OWNER_EMAILS: ${PLATFORM_OWNER_EMAILS:-}
      SYSTEM_ADMIN_EMAIL: ${SYSTEM_ADMIN_EMAIL:-}
      # Per-customer integrations. Blank = feature stays inert (see .env.example).
      SMTP_HOST: ${SMTP_HOST:-}
      SMTP_PORT: ${SMTP_PORT:-}
      SMTP_USER: ${SMTP_USER:-}
      SMTP_PASS: ${SMTP_PASS:-}
      SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
      SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
      RECAPTCHA_SECRET: ${RECAPTCHA_SECRET:-}
      GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
      GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
      GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-}
      # AI. Only reachable when the `ai` profile is running.
      OLLAMA_URL: ${OLLAMA_URL:-http://ollama:11434}
      SUMMARY_MODEL: ${SUMMARY_MODEL:-gemma4:31b}
      EMBED_MODEL: ${EMBED_MODEL:-bge-m3}
      # This image has no Python and no transcribe.py, so transcription is
      # delegated over HTTP. Megam's PM2 deploy leaves ASR_MODE unset and keeps
      # spawning transcribe.py locally.
      ASR_MODE: http
      ASR_URL: http://asr:8081
    ports:
      - '127.0.0.1:3050:3050'
    volumes:
      - ${RECORDINGS_PATH:-/var/lib/olivasal/recordings}:/var/lib/olivasal/recordings
      - uploads:/app/uploads
      - plugin-data:/app/data
    extra_hosts:
      # Required to reach the host-network media server (see LIVEKIT_HOST).
      - 'host.docker.internal:host-gateway'
    networks: [olivasal]

  # ---------------------------------------------------------------
  # Data. Neither is published; only the api container can reach them.
  # ---------------------------------------------------------------
  mongo:
    <<: *restart
    image: mongo:7
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-olivasal-admin}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?set MONGO_ROOT_PASSWORD}
      MONGO_INITDB_DATABASE: ${MONGO_DB:-olivasal}
      MONGO_APP_USER: ${MONGO_APP_USER:-olivasal}
      MONGO_APP_PASSWORD: ${MONGO_APP_PASSWORD}
      MONGO_DB: ${MONGO_DB:-olivasal}
    volumes:
      - mongo-data:/data/db
    configs:
      # Inline rather than a bind mount: a missing host file would be created by
      # Docker as an empty *directory*, and mongo's init step would then fail
      # with nothing pointing back at the cause. Inline also means the installer
      # has one file to fetch instead of two.
      - source: mongo-init
        target: /docker-entrypoint-initdb.d/mongo-init.js
    healthcheck:
      test: ['CMD', 'mongosh', '--quiet', '--eval', "db.adminCommand('ping').ok"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 30s
    networks: [olivasal]

  redis:
    <<: *restart
    image: redis:7-alpine
    command: ['redis-server', '--appendonly', 'yes']
    # LiveKit and egress run on the host network and need this on loopback.
    ports:
      - '127.0.0.1:6379:6379'
    volumes:
      - redis-data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 3s
      retries: 10
    networks: [olivasal]

  # ---------------------------------------------------------------
  # Media. Host network — see the header note. Config is passed inline rather
  # than mounted so there is no file to template on first boot.
  # ---------------------------------------------------------------
  media-server:
    <<: *restart
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-media-server:${MEDIA_SERVER_VERSION:-stable}
    network_mode: host
    depends_on:
      redis:
        condition: service_healthy
    environment:
      # Quoted: the value contains a colon and would otherwise break YAML.
      LIVEKIT_KEYS: '${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}'
      LIVEKIT_CONFIG: |
        port: 7880
        rtc:
          tcp_port: 7881
          port_range_start: 50000
          port_range_end: 60000
          # Cloud VMs sit behind NAT and must advertise their public address.
          use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP:-true}
        redis:
          address: 127.0.0.1:6379
        webhook:
          api_key: ${LIVEKIT_API_KEY}
          urls:
            - http://127.0.0.1:3050/api/livekit/webhook
        logging:
          level: info
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:7880/']
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 20s

  egress:
    <<: *restart
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-egress:${EGRESS_VERSION:-stable}
    network_mode: host
    depends_on:
      media-server:
        condition: service_started
    environment:
      EGRESS_CONFIG_BODY: |
        log_level: info
        api_key: ${LIVEKIT_API_KEY}
        api_secret: ${LIVEKIT_API_SECRET}
        ws_url: ws://127.0.0.1:7880
        redis:
          address: 127.0.0.1:6379
        insecure: true
    # Egress renders recordings in a headless Chrome; the default 64MB of
    # shared memory makes it crash on multi-participant rooms.
    shm_size: 2gb
    volumes:
      - ${RECORDINGS_PATH:-/var/lib/olivasal/recordings}:/var/lib/olivasal/recordings

  # ---------------------------------------------------------------
  # AI — profile `ai`, requires an NVIDIA GPU + nvidia-container-toolkit.
  # Without the profile these services do not exist and the product runs
  # without recap, which is a supported configuration.
  # ---------------------------------------------------------------
  ollama:
    <<: *restart
    profiles: ['ai']
    image: ollama/ollama:latest
    volumes:
      # Models are ~20GB and live in a volume, never baked into an image.
      - ollama-models:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    healthcheck:
      test: ['CMD', 'ollama', 'list']
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s
    networks: [olivasal]

  # Pulls the models once, then exits. Ollama does NOT fetch on demand — a
  # generate against a missing model just 404s — so without this the very first
  # recap of a fresh install fails with nothing explaining why. Re-running it is
  # free: `ollama pull` is a no-op when the model is already in the volume.
  ollama-init:
    profiles: ['ai']
    image: ollama/ollama:latest
    # Model names are substituted by compose, not by the shell — one less layer
    # of $-escaping to get wrong, and `config` shows exactly what will be pulled.
    entrypoint: ['/bin/sh', '-c']
    command:
      - >-
        set -e;
        ollama pull '${SUMMARY_MODEL:-gemma4:31b}';
        ollama pull '${EMBED_MODEL:-bge-m3}';
        echo "models ready"
    environment:
      OLLAMA_HOST: http://ollama:11434
    depends_on:
      ollama:
        condition: service_healthy
    restart: 'no'
    networks: [olivasal]

  # Downloads the ASR models into asr-models, then exits. Same reasoning as
  # ollama-init: the weights are several GB and are never baked into the image,
  # so without this the first meeting to finish waits on the download and looks
  # like a broken recap. Re-running is free once the cache is warm.
  asr-init:
    profiles: ['ai']
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-asr:${OLIVASAL_VERSION:-stable}
    command: ['python3', 'prefetch.py']
    environment:
      HF_HOME: /models
      WHISPER_MODEL: ${WHISPER_MODEL:-large-v3}
      ASR_ROUTING: ${ASR_ROUTING:-1}
      HF_TOKEN: ${HF_TOKEN:-}
    volumes:
      - asr-models:/models
    restart: 'no'
    networks: [olivasal]

  asr:
    <<: *restart
    profiles: ['ai']
    build:
      context: .
      dockerfile: docker/asr.Dockerfile
    image: ${REGISTRY:-containers.megamtech.com/megamtech-labs/mconnect}/olivasal-asr:${OLIVASAL_VERSION:-stable}
    depends_on:
      # Start only once the weights are on disk, so no recap can race them.
      asr-init:
        condition: service_completed_successfully
    environment:
      ASR_PORT: '8081'
      # Model cache lives in a volume, not the container layer — otherwise every
      # recreate or image upgrade re-downloads several GB.
      HF_HOME: /models
      WHISPER_MODEL: ${WHISPER_MODEL:-large-v3}
      ASR_ROUTING: ${ASR_ROUTING:-1}
      INDIC_CONFORMER_DEVICE: ${INDIC_CONFORMER_DEVICE:-cpu}
      # The IndicConformer repo is gated; without a token Tamil falls back to
      # Whisper instead of failing.
      HF_TOKEN: ${HF_TOKEN:-}
    volumes:
      - asr-models:/models
      # Same host path, same mount point as the api — so the media paths the api
      # sends in /transcribe resolve identically on this side.
      - ${RECORDINGS_PATH:-/var/lib/olivasal/recordings}:/var/lib/olivasal/recordings
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    healthcheck:
      test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:8081/health']
      interval: 30s
      timeout: 5s
      start_period: 120s
      retries: 3
    networks: [olivasal]

networks:
  olivasal:
    driver: bridge

configs:
  # Runs ONCE, only when the mongo data volume is empty. Creates the least
  # privileged user the API needs; the root account stays for administration.
  # Changing MONGO_APP_PASSWORD later will NOT re-run this — see .env.example
  # for how to rotate it on an existing install.
  #
  # `const db` would shadow mongosh's global `db` and read the binding it is
  # still defining (ReferenceError, TDZ) — hence appDb.
  mongo-init:
    content: |
      const appDb = db.getSiblingDB(process.env.MONGO_DB || 'olivasal');

      appDb.createUser({
        user: process.env.MONGO_APP_USER || 'olivasal',
        pwd: process.env.MONGO_APP_PASSWORD,
        roles: [{ role: 'readWrite', db: process.env.MONGO_DB || 'olivasal' }],
      });

      // Plain concatenation, not a template literal — compose interpolates this
      // block and rejects a JS expression inside dollar-brace.
      print('olivasal: created application user in db ' + (process.env.MONGO_DB || 'olivasal'));

volumes:
  mongo-data:
  redis-data:
  uploads:
  plugin-data:
  web-assets:
  certs:
  certbot-webroot:
  ollama-models:
  asr-models:
