Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# DataBounty Community stack -- Postgres + API (incl. background work) + web + admin + landing.
#
# `docker compose up --build` brings up all six long-running containers plus
# three one-shots. Startup order (enforced below, see the "why" comments on
# each depends_on):
#
#   postgres (healthy)
#     -> api-migrate  (npx prisma migrate deploy, one-shot)
#       -> api-seed   (npx tsx prisma/seed-catalog.ts, one-shot, idempotent)
#         -> api      (node dist/server.js)
#           -> web, admin, landing (wait for api's /health, then serve)
#         -> api-seed-admin (npx tsx prisma/seed-admin.ts, one-shot,
#                            idempotent; no-ops unless SUPER_ADMIN_PASSWORD is
#                            set -- nothing depends on it, see its own comment)
#
# Community only: no paid/wallet/payment services or env vars are wired here.
#
# --- Config source of truth -------------------------------------------------
# Every var name/default below was checked against real source, not guessed:
#   - apps/api/src/config.ts               (which env vars the API reads, and
#                                            which ones it silently falls back
#                                            on with `??` vs leaves undefined)
#   - apps/api/.env.example                (the docs agent's own list, already
#                                            in this repo -- read but NOT
#                                            edited by this file, see CLAUDE.md
#                                            frozen-paths / parallel-agent note)
#   - apps/{web,admin,landing}/next.config.ts + grep of process.env.NEXT_PUBLIC_*
#
# --- .env override --------------------------------------------------------
# Compose auto-loads a `.env` file sitting next to this docker-compose.yml
# (i.e. community/.env, if you create one) for every ${VAR} substitution
# below. No such file is created by this change -- see the report for why.
#
# *** IMPORTANT gotcha if you copy apps/api/.env.example's DATABASE_URL verbatim
# *** into a top-level .env: that file's DATABASE_URL points at
# *** "localhost:5432" because it's written for running the API directly on
# *** the host. Inside this compose network the Postgres host is the service
# *** name "postgres", not "localhost" -- the default below already uses
# *** "postgres". If you set DATABASE_URL yourself, point it at "postgres".
#
# SESSION_SECRET / ADMIN_SESSION_SECRET / POOL_SAMPLING_HMAC_SECRET /
# PROFILE_SOURCE_SECRET / ENTERPRISE_ASSERTION_SECRET / ENTERPRISE_SERVICE_SECRET
# below are given the exact same dev-only literal fallbacks apps/api/src/config.ts
# already falls back to when the var is unset (`process.env.X ?? "dev_..."`).
# They are set explicitly here -- rather than left unset to let config.ts's own
# fallback apply -- only so this file is self-documenting about what the app
# will actually use; the effective value is identical either way. Generate real
# secrets with `openssl rand -hex 32` and put them in a top-level `.env` before
# using this compose file for anything but local smoke-testing.

services:
  postgres:
    # Pinned to the SAME major as CI (.github/workflows/ci.yml uses postgres:17)
    # so self-hosters run the version the test suite actually exercises. The
    # `-alpine` variant is the only difference and is not version-behavioural.
    #
    # UPGRADING FROM 16: a `postgres_data` volume initialised by 16 will NOT
    # start under 17 -- Postgres refuses a data directory from a different
    # major ("database files are incompatible with server") and the container
    # exits, taking every dependent service with it. See DEPLOYMENT.md
    # ("Upgrading an existing stack from Postgres 16") for the dump/restore
    # and the destructive reset.
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
      POSTGRES_DB: ${POSTGRES_DB:-community}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    # Not published to the host by default -- nothing outside this network
    # needs to reach it, and the host machine's own local Postgres (see
    # project CLAUDE.md) already listens on 5432. Uncomment to inspect with a
    # host GUI client; pick a free host port if 5432 is already taken.
    # ports:
    #   - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-community}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s
    networks:
      - community

  # One-shot: applies every committed Prisma migration. `restart: "no"` and a
  # `condition: service_completed_successfully` dependency below is the
  # standard docker-compose pattern for "run once, block dependents on its
  # exit code" -- preferred here over folding this into the api Dockerfile's
  # CMD because the image is shared with the one-shot jobs and either starting first
  # would race to apply migrations concurrently; a single dedicated one-shot
  # avoids that race entirely.
  api-migrate:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: build
      secrets:
        - ca_cert
    image: databounty-community-api-build:latest
    environment:
      DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-community}?schema=public}
    command: ["npx", "prisma", "migrate", "deploy"]
    restart: "no"
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - community

  # One-shot: idempotent dataset-type catalog seed (DEPLOYMENT.md calls this
  # out as required for a real deployment -- upserts by id, safe to re-run).
  api-seed:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: build
      secrets:
        - ca_cert
    image: databounty-community-api-build:latest
    environment:
      DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-community}?schema=public}
    command: ["npx", "tsx", "prisma/seed-catalog.ts"]
    restart: "no"
    depends_on:
      api-migrate:
        condition: service_completed_successfully
    networks:
      - community

  api: &api-env
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: runner
      secrets:
        - ca_cert
    image: databounty-community-api:latest
    environment: &api-environment
      # `development`, matching apps/api/.env.example and every localhost
      # default in this file. NOT a stylistic choice: apps/api/src/config.ts
      # refuses to boot when NODE_ENV is production-like AND ADMIN_URL is
      # still http://localhost:3002 (the default below) -- that guard is
      # correct (a stale ADMIN_URL misroutes admin session cookies) and must
      # not be weakened, so the localhost-shaped default that contradicts it
      # is the thing that moves. A real deployment overrides BOTH: set
      # NODE_ENV=production together with a real ADMIN_URL and real secrets.
      NODE_ENV: ${NODE_ENV:-development}
      PORT: "4000"
      HOST: "0.0.0.0"
      DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-community}?schema=public}
      # URLs -- must match the ports actually published to the host below,
      # since browsers (not containers) are what hit these.
      APP_URL: ${APP_URL:-http://localhost:53000}
      ADMIN_URL: ${ADMIN_URL:-http://localhost:3002}
      CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:53000,http://localhost:3002,http://localhost:3001}
      # Session/HMAC signing secrets -- see the file-level comment above.
      SESSION_SECRET: ${SESSION_SECRET:-dev_session_secret_databounty_community_32bytes_long}
      ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-dev_admin_session_secret_databounty_community_32bytes}
      POOL_SAMPLING_HMAC_SECRET: ${POOL_SAMPLING_HMAC_SECRET:-dev_pool_sampling_hmac_secret_databounty_community}
      PROFILE_SOURCE_SECRET: ${PROFILE_SOURCE_SECRET:-dev_profile_source_secret_databounty_community_32b}
      ENTERPRISE_ASSERTION_SECRET: ${ENTERPRISE_ASSERTION_SECRET:-dev_enterprise_assertion_secret_databounty_shared_32b}
      ENTERPRISE_SERVICE_SECRET: ${ENTERPRISE_SERVICE_SECRET:-dev_enterprise_service_secret_databounty_shared_32b}
      # Optional integrations -- blank is a real, supported "not configured"
      # state (every one fails closed, see DEPLOYMENT.md's integration
      # table), not a placeholder that needs filling before this works.
      GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
      OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
      E2B_API_KEY: ${E2B_API_KEY:-}
      E2B_TEMPLATE: ${E2B_TEMPLATE:-databounty-verify}
      SMTP_HOST: ${SMTP_HOST:-}
      SMTP_PORT: ${SMTP_PORT:-587}
      SMTP_SECURE: ${SMTP_SECURE:-false}
      SMTP_USER: ${SMTP_USER:-}
      SMTP_PASS: ${SMTP_PASS:-}
      EMAIL_FROM: ${EMAIL_FROM:-}
      GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
      GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
      ORCID_OAUTH_CLIENT_ID: ${ORCID_OAUTH_CLIENT_ID:-}
      ORCID_OAUTH_CLIENT_SECRET: ${ORCID_OAUTH_CLIENT_SECRET:-}
      SLACK_CLIENT_ID: ${SLACK_CLIENT_ID:-}
      SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET:-}
      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
      TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
    ports:
      - "4000:4000"
    # STORAGE_DRIVER is unset, so it defaults to "local" (apps/api/src/config.ts)
    # and every uploaded artifact is written to ./data/artifacts inside the
    # container. Without this volume that state dies with the container, and
    # `api` and the one-shot containers -- which share the image but not a
    # filesystem -- would each see only their own half of it, so a job would fail to read an
    # artifact the API had just accepted. Both services get the same volume via
    # the `*api-env` merge below.
    volumes:
      - api_artifacts:/app/data/artifacts
    depends_on:
      postgres:
        condition: service_healthy
      api-seed:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4000/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - community

  # NOTE: there is no `worker` service any more (removed 2026-09-02). Background
  # work — the job-queue consumer, the Telegram listener, notification dispatch
  # and every time-based sweep — runs inside the `api` container itself; see
  # apps/api/src/worker.ts's startBackgroundWorkers(). The split existed here
  # but was never deployed outside compose (`infra/` defines only
  # databounty-api.service), so the real deployment had nothing draining the
  # queue at all. If you ever scale `api` beyond one replica, split the worker
  # back out first — the sweeps are not partitioned across instances.

  # One-shot: bootstraps the FIRST admin account. `admin` is not
  # self-assignable (apps/admin's sign-in page says so outright) and
  # apps/api/prisma/seed-admin.ts is the only code path in this repo that
  # grants the role -- without this service a `docker compose up` stack comes
  # up with zero admins and no way to create one.
  #
  # OPT-IN, and the opt-in is SUPER_ADMIN_PASSWORD, not this service's
  # presence. No password is committed here on purpose: a literal default
  # would be a published admin password on every stack that forgot to
  # override it. Read seed-admin.ts for the exact behaviour it is modelled on:
  #
  #   - password unset + non-prod NODE_ENV -> warns, creates nothing, exits 0
  #   - password unset + production        -> throws, exits 1 (deliberate: a
  #                                           prod deploy must not complete
  #                                           believing an admin exists)
  #   - password set                       -> creates the account if absent,
  #                                           always ensures the `admin` role,
  #                                           never rewrites an existing
  #                                           password. Idempotent.
  #
  # Set it for a real deployment:
  #   SUPER_ADMIN_EMAIL=you@yourcompany.com SUPER_ADMIN_PASSWORD='...' docker compose up
  #
  # NOTHING depends on this service. That is deliberate, and it is the reason
  # `api` lists only `api-seed` above: a `service_completed_successfully`
  # edge from `api` to here would (a) be satisfied by the skip path anyway, so
  # it would buy nothing in the common case, and (b) hard-block the whole stack
  # from booting on the production-without-a-password path, turning "you have no
  # admin" into "you have no API". The skip is silent-ish by design, so check
  # this container's log/exit code (`docker compose logs api-seed-admin`) rather
  # than assuming an admin was created.
  #
  # It inherits `api`'s full environment (merge below) rather than just
  # DATABASE_URL, unlike api-migrate/api-seed, because seed-admin.ts imports
  # src/config.ts and therefore runs every one of its boot guards -- including
  # the production ADMIN_URL guard. Passing the same NODE_ENV/ADMIN_URL as the
  # API means this one-shot can never pass or fail those guards differently
  # from the service it is bootstrapping an admin for.
  #
  # Placed after `api` rather than next to the other one-shots only
  # because the `*api-environment` anchor it merges is defined on `api`, and
  # a YAML alias cannot precede its anchor. File order has no effect on
  # startup order -- that comes from depends_on.
  api-seed-admin:
    build:
      context: .
      dockerfile: apps/api/Dockerfile
      target: build
      secrets:
        - ca_cert
    image: databounty-community-api-build:latest
    environment:
      <<: *api-environment
      SUPER_ADMIN_EMAIL: ${SUPER_ADMIN_EMAIL:-superadmin@databounty.io}
      # Empty by default -- seed-admin.ts treats "" as unset (`if (!password)`)
      # and takes the warn-and-skip / throw-in-production path above. This is
      # an absent password, not a default one.
      SUPER_ADMIN_PASSWORD: ${SUPER_ADMIN_PASSWORD:-}
    command: ["npx", "tsx", "prisma/seed-admin.ts"]
    restart: "no"
    depends_on:
      api-seed:
        condition: service_completed_successfully
    networks:
      - community

  web:
    build:
      context: .
      dockerfile: apps/web/Dockerfile
      args:
        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
        NEXT_PUBLIC_LANDING_URL: ${NEXT_PUBLIC_LANDING_URL:-http://localhost:3001}
        NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL:-http://localhost:53000}
        NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-http://localhost:3002}
        NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
    ports:
      - "53000:53000"
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:53000/').then(r => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - community

  admin:
    build:
      context: .
      dockerfile: apps/admin/Dockerfile
      args:
        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
        NEXT_PUBLIC_LANDING_URL: ${NEXT_PUBLIC_LANDING_URL:-http://localhost:3001}
        NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL:-http://localhost:53000}
        NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-http://localhost:3002}
        NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
    ports:
      # Matches the app's conventional dev port (next dev -p 3002) and the
      # NEXT_PUBLIC_ADMIN_URL default.
      - "3002:3002"
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/').then(r => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - community

  landing:
    build:
      context: .
      dockerfile: apps/landing/Dockerfile
      args:
        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
        NEXT_PUBLIC_LANDING_URL: ${NEXT_PUBLIC_LANDING_URL:-http://localhost:3001}
        NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL:-http://localhost:53000}
        NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-http://localhost:3002}
    ports:
      - "3001:3001"
    environment:
      INTERNAL_API_URL: http://api:4000
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/').then(r => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - community

networks:
  community:
    driver: bridge

volumes:
  postgres_data:
  # Used by `api` (which also runs the background work) -- see the comment on api's `volumes:`.
  api_artifacts:

# Optional: only needed on a network that intercepts outbound TLS (e.g.
# corporate SSL inspection) -- prisma generate's engine-binary download
# fails there otherwise. Set CA_CERT_PATH to that network's CA cert before
# building; unset, this resolves to /dev/null (always present, always
# empty), which the Dockerfile's `-s` check correctly treats as "no cert
# provided" -- a normal build without CA_CERT_PATH set is unaffected.
secrets:
  ca_cert:
    file: ${CA_CERT_PATH:-/dev/null}