services:
  db:
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: ${DB_NAME}
      MARIADB_USER: ${DB_USER}
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - dbdata:/var/lib/mysql
      - ./server/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 10
    # No host port published by default — only the app needs the DB, over the
    # private compose network. Uncomment to inspect from the host:
    # ports:
    #   - "3306:3306"

  app:
    # Prebuilt image from the Gitea registry (published by
    # .gitea/workflows/build-images.yml on every merge to main). This file is
    # production-shaped — image only, NO build: — so a production host can only
    # ever pull, never accidentally build. IMAGE_TAG defaults to `latest`; pin a
    # specific build for a reproducible deploy / rollback, e.g.
    # IMAGE_TAG=sha-042a151 (see .env / .env.example). To build locally instead,
    # overlay docker-compose.dev.yml (see README).
    image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
    restart: unless-stopped
    env_file: .env
    environment:
      DB_HOST: db
      UPLOAD_DIR: /app/uploads
      LOG_DIR: /app/logs
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - uploads:/app/uploads
      # Bind-mount logs to the host so app.log is directly readable at ./logs/
      - ./logs:/app/logs
      # Instance branding assets (logo/hero/favicon), served at /brand when
      # BRAND_LOGO/HERO/FAVICON point there. Optional — defaults are baked into
      # the image, so this mount only matters for custom brand images. Create
      # ./brand/ on the host and drop assets in; read-only in the container.
      - ./brand:/app/brand:ro
    # Only the PUBLIC API port (3000) is published. The internal server<->bot
    # port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
    # stays reachable only over the private compose network — Pangolin/the public
    # reverse proxy can never forward to it. See issue #33.
    # Binds 0.0.0.0 (no 127.0.0.1 prefix) so Pangolin can reach the container.
    ports:
      - "3000:3000"

  ntfy:
    # Self-hosted UnifiedPush relay for the app's opt-in push notifications
    # (docs/android/PLAN.md §11). Pinned upstream image — fits this file's
    # pull-only, never-build model. All config is declarative (./ntfy/server.yml
    # + the NTFY_BASE_URL override below), so bringing the stack up provisions a
    # working relay with NO interactive steps (no `ntfy user add`, no accounts).
    # The backend treats ntfy as an untrusted relay and publishes only
    # content-free tickles, so anonymous read-write to unguessable topics is safe.
    image: binwiederhier/ntfy:v2.11.0
    restart: unless-stopped
    command: ["serve"]
    environment:
      # Public URL devices reach it at (behind the reverse proxy). MUST match the
      # origin of the endpoints the app registers — the backend's SSRF allow-set
      # (NTFY_BASE_URL / NTFY_ALLOWED_ORIGINS on the app) is derived from it.
      NTFY_BASE_URL: ${NTFY_BASE_URL:-https://ntfy.localhost}
    volumes:
      - ntfydata:/var/lib/ntfy
      - ./ntfy/server.yml:/etc/ntfy/server.yml:ro
    # Published so the PUBLIC reverse proxy (Pangolin) can forward the
    # notification subdomain here. Pangolin lives OUTSIDE the compose network and
    # reaches every service through a published host port — never by joining the
    # internal network — exactly like `app` above (3000). So ntfy must publish a
    # port too: the reverse proxy maps notify.<host> -> host:NTFY_HOST_PORT ->
    # ntfy:80. Unlike INTERNAL_PORT / the bot, ntfy is DEVICE-facing, so it is
    # SUPPOSED to be reachable through the proxy. Binds 0.0.0.0 (no 127.0.0.1
    # prefix) so Pangolin can reach the container. Both the app (SSE subscribe) and
    # the backend (POSTing content-free tickles to each device's registered
    # endpoint) reach ntfy on this same public origin — NTFY_ALLOWED_ORIGINS pins
    # it — so all ntfy traffic flows through the proxy; there is no separate
    # internal publish port.
    ports:
      - "${NTFY_HOST_PORT:-2586}:80"

  bot:
    # Same as app: prebuilt bot image, pulled in production. Build locally via
    # docker-compose.dev.yml.
    image: gitea.whitlocktech.com/runicgateway/website-bot:${IMAGE_TAG:-latest}
    restart: unless-stopped
    env_file: .env
    environment:
      DB_HOST: db
      # Pin the bot's own listen port. Both services share env_file: .env, so
      # without this the site's PORT=3000 leaks in and the bot binds 3000 instead
      # of 4100 — then the server's BOT_INTERNAL_URL (http://bot:4100) can't reach
      # it ("failed to fetch" in the admin panel). Must match that URL's port.
      PORT: 4100
      # Likewise override the log filename so the bot doesn't inherit the site's
      # LOG_FILE and write into app.log — keep the bot's log distinct.
      LOG_FILE: bot.log
      # Internal config fetch goes to the app's UNPUBLISHED internal port (3001),
      # not the public 3000. Keep the port in sync with the app's INTERNAL_PORT.
      SITE_INTERNAL_URL: http://app:3001/internal/bot-config
      SITE_PUBLIC_URL: http://app:3000/api/v1/public
      LOG_DIR: /app/bot/logs
    depends_on:
      db:
        condition: service_healthy
      app:
        condition: service_started
    volumes:
      - ./bot/logs:/app/bot/logs
    # No published port — the bot's internal API (/internal/*) is reached only
    # by `app` over the private compose network, and must NEVER be exposed
    # through Pangolin/the public reverse proxy.

volumes:
  dbdata:
  uploads:
  ntfydata:
