docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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}