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 # No published host port — devices reach ntfy through the public reverse proxy # on its own hostname; the backend publisher reaches it over the private # compose network. Never publish this directly. 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: