Ctrl K
ComunidadGuías…

docker-compose.yml

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

Archivo original ↗Preguntar a KiBot
# Nacre — the deployment profiles from docs/config.md.
#
#   minimal    api, mcp, web, worker, the migrate job, parser, postgres, qdrant,
#              redis.
#              Embeddings come from an endpoint you name. Runs on a laptop.
#   full       plus minio, an embedder, and a reranker. The typical install.
#   airgapped  everything local, zero outbound traffic, local OIDC.
#   hosted     minimal, plus the adapter that routes embeddings to a vendor's
#              API. Its own profile because typing it is the decision: routing
#              a model there means the text of your documents leaves your
#              installation, and that is never something a profile somebody
#              else named should turn on. Deliberately not composable with
#              `airgapped` in any arrangement this repository ships — the
#              adapter is simply absent from that profile, and `lint:compose`
#              asserts it.
#   demo       minimal, plus a small English-only embedder and a seeded corpus
#              with three logins — the profile docs/quickstart.md points a
#              first-time reader at.
#
# Services with no `profiles` key belong to every profile — that is how Compose
# works, and it is why the nine unprofiled services (the migrate job and mcp
# among them) are unmarked rather than listed three times.
#
#   docker compose --profile minimal up -d
#
# Nothing here carries a real credential. Values come from .env, and in
# production the secret-bearing ones are references into a secret store; see
# docs/config.md, which also explains why NACRE_PG_URL must not name a
# superuser.

name: nacre

# The configuration a fresh clone has to have, and did not.
#
# `env_file: { required: false }` on the anchor below said a clone works without
# copying `.env`, and that was half an arrangement: Compose rendered, then every
# process refused to start because `loadConfig` requires each of these and
# nothing supplied one. `--profile demo` is the case that made it visible — a
# profile whose whole claim is that it needs nothing configured, stopping on
# `NACRE_PG_URL is not set`.
#
# These are not guesses about somebody's deployment. `postgres`, `qdrant`,
# `redis` and `parser` are containers **this file** creates on a network **this
# file** makes, `POSTGRES_PASSWORD` already defaults to `nacre` further down,
# and every value here is the one `.env.example` documents. A real `.env` still
# wins, since each is `${VAR:-default}`.
#
# **Merged with `<<:` into each service rather than inherited from `x-app`.** A
# service's own `environment` replaces the anchor's outright — YAML merge is
# shallow — so putting them on the anchor reached `worker` and `migrate` and
# left `api`, `mcp` and `demo-seed` exactly as broken as before. Rendering the
# file and loading the result is what said so; reading it did not.
#
# Deliberately **not** including `NACRE_DEFAULT_EMBEDDING_ENDPOINT`, which ships
# empty on purpose: it names a service this file does not create in every
# profile, and docs/config.md's rule is no silent default for a URL somebody
# else has to run.
x-config: &config
  NACRE_CANONICAL_URL: ${NACRE_CANONICAL_URL:-http://localhost:8082}
  NACRE_PG_URL: ${NACRE_PG_URL:-postgres://nacre:nacre@postgres:5432/nacre}
  NACRE_QDRANT_URL: ${NACRE_QDRANT_URL:-http://qdrant:6333}
  NACRE_REDIS_URL: ${NACRE_REDIS_URL:-redis://redis:6379/0}
  NACRE_PARSER_ENDPOINT: ${NACRE_PARSER_ENDPOINT:-http://parser:8090}
  NACRE_JWT_ISSUER: ${NACRE_JWT_ISSUER:-http://localhost:8080}
  NACRE_JWT_AUDIENCE: ${NACRE_JWT_AUDIENCE:-nacre}

x-app: &app
  build:
    context: .
    dockerfile: docker/Dockerfile
  restart: unless-stopped
  # `host.docker.internal`, made to resolve — on every platform, because it is
  # not a Mac thing.
  #
  # This lived in a `docker-compose.apple-silicon.yml` overlay that an operator
  # had to select with COMPOSE_FILE, and both of its keys are no-ops where they
  # are not needed: `host-gateway` is supported by Docker on Linux, and the
  # `platform:` below is a no-op on an amd64 host. An overlay whose contents are
  # harmless everywhere is a second file, a second command and a documented
  # footgun (naming COMPOSE_FILE silently drops docker-compose.override.yml) in
  # exchange for nothing. One file, one command, every platform.
  #
  # Docker Desktop resolves this name on its own and the line changes nothing
  # there. Colima, Rancher Desktop, OrbStack and podman do not all agree, and
  # the failure without it is an `EAI_AGAIN` deep in a log that names no cause.
  #
  # On the **anchor** rather than on the two services the overlay named. Every
  # process that reaches an embedder needs it, and the overlay had `api` and
  # `worker` while `packages/mcp/src/services.ts` builds `HttpEmbedder.pool()`
  # too — so MCP search would have failed on exactly the runtimes this line
  # exists for, while REST search worked. `migrate` gets it and does not need
  # it, which costs nothing; one place that knows the list beats three that
  # have to agree.
  #
  # Deliberately **not** a default for NACRE_DEFAULT_EMBEDDING_ENDPOINT. That
  # ships empty and the processes refuse to start without it, which is the "no
  # silent defaults for URLs" rule in docs/config.md.
  extra_hosts: ["host.docker.internal:host-gateway"]
  # Optional so a fresh clone resolves without copying .env first, and so CI
  # can validate the file. Values still come from .env when it exists, and from
  # the environment otherwise.
  env_file:
    - path: .env
      required: false
  environment:
    <<: *config
  depends_on:
    postgres: { condition: service_healthy }
    qdrant: { condition: service_started }
    redis: { condition: service_healthy }

services:
  # Runs to completion before anything else starts, and is the only service
  # allowed to touch the schema. Without it a fresh `docker compose up` brought
  # the API up against an empty database and the first request failed on a
  # missing table — `pnpm migrate` goes through tsx, which is a development
  # dependency and is not in the runtime image.
  #
  # A separate one-shot rather than a step inside the API: three replicas
  # starting together would each try to migrate, and the ones that lose the race
  # report a failure that is not one.
  migrate:
    <<: *app
    command: ["node", "packages/core/dist/migrate-main.js"]
    restart: "no"
    depends_on:
      postgres: { condition: service_healthy }
    healthcheck:
      disable: true

  api:
    <<: *app
    command: ["node", "packages/api/dist/main.js"]
    # The host side is interpolated so a busy port is a one-line .env entry
    # rather than an override file. The container side is fixed: PORT, the
    # probes and every in-network reference agree on it, and none of them care
    # what the host publishes.
    ports: ["${NACRE_API_HOST_PORT:-8080}:8080"]
    environment:
      <<: *config
      PORT: 8080
      # No NACRE_OAUTH_CONSENT_URL, and its absence is the setting.
      #
      # This used to default it to `http://localhost:8082/#/consent`, because the
      # console was on its own port and the OAuth flow had to be told where. That
      # default was a third address naming `localhost` — and the one nothing can
      # derive, since the redirect knows the host the browser used and not the
      # port the console is published on. Reached from another machine it sent
      # the person to their own.
      #
      # The `web` front door now proxies `/oauth` too, so the console and the
      # authorization server are the same origin and `loadConfig`'s own default —
      # `${NACRE_CANONICAL_URL}/#/consent` — is correct without being restated.
      # One address to get right instead of three.
      #
      # Set it in .env only when the console is somewhere this stack does not put
      # it.
    depends_on:
      migrate: { condition: service_completed_successfully }
      postgres: { condition: service_healthy }
      qdrant: { condition: service_started }
      redis: { condition: service_healthy }
      # See the worker's copy: `full` only, so it has to be optional.
      minio-init: { condition: service_completed_successfully, required: false }

  mcp:
    <<: *app
    command: ["node", "packages/mcp/dist/main.js"]
    ports: ["${NACRE_MCP_HOST_PORT:-8081}:8081"]
    environment:
      <<: *config
      PORT: 8081
      # No NACRE_MCP_CANONICAL_URL here, and its absence is the fix rather than
      # an omission.
      #
      # This stack publishes two ports with nothing in front, so the discovery
      # document has to name *this* transport and not the API — RFC 9728 has the
      # client compare the identifier against the URL it reached and refuse
      # before sending a token. The first attempt at that set this to
      # `http://localhost:8081`, which is right only for a client on the
      # server's own machine: anyone reaching the stack at `10.8.0.1` or a
      # hostname got the same refusal with a different wrong value in it.
      #
      # Left unset, the transport builds the document from the `Host` each
      # client actually used, which is what the identifier is meant to match.
      # Set it in .env only when a proxy rewrites Host and the public name is
      # something the server cannot see.
    depends_on:
      migrate: { condition: service_completed_successfully }
      postgres: { condition: service_healthy }
      qdrant: { condition: service_started }
      redis: { condition: service_healthy }
    # The API's /v1/health does not exist here — this process serves /mcp and
    # nothing else — so the shared HTTP check would report every MCP container
    # unhealthy forever. A TCP connect is what this transport can honestly
    # answer without authenticating.
    healthcheck:
      test: ["CMD", "node", "-e", "require('node:net').connect(Number(process.env.PORT||8081),'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"]
      interval: 15s
      timeout: 3s
      start_period: 20s
      retries: 3

  # The company admin UI, served on its own origin so it has a URL the moment the
  # stack is up — no build step, no proxy for the operator to write. It serves the
  # static bundle and proxies /v1 to the api service, which is what keeps the
  # browser's requests same-origin; the API sends no CORS headers. In every
  # profile, because a single-organization admin is the community feature — the
  # cross-organization global admin is the commercial one. Not `<<: *app`: this is
  # nginx, not the node image. See docker/nginx.conf.
  web:
    build:
      context: .
      dockerfile: docker/Dockerfile
      target: web
    restart: unless-stopped
    ports: ["${NACRE_WEB_HOST_PORT:-8082}:80"]
    environment:
      # The image ships an nginx *template* rather than a config, because the
      # same image is deployed by the Helm chart where the API is a Service name
      # and not `api`. These are the defaults baked into the image, named here
      # so the one place the value differs is visible from the file that sets
      # every other endpoint.
      NACRE_API_UPSTREAM: "api:8080"
      NACRE_MCP_UPSTREAM: "mcp:8081"
      NGINX_PORT: "80"
    # Both, and `depends_on` is now ordering rather than a requirement.
    #
    # It used to be load-bearing: nginx resolved an upstream name once at load
    # and exited if it could not, so a `web` that started before `mcp` existed
    # failed to boot. The same property had a worse half nobody had met yet —
    # the address was then cached forever, so recreating `api` (which changing
    # `.env` is enough to do) left this container dialling the previous one's
    # IP address, answering 502 on every console request with everything
    # healthy. The proxy resolves per request now; see docker/nginx.conf.template.
    #
    # Kept because starting the front door after the things it fronts is still
    # the order you want, and because a 502 during startup is noise even when
    # it clears by itself.
    depends_on:
      api: { condition: service_started }
      mcp: { condition: service_started }

  worker:
    <<: *app
    command: ["node", "packages/worker/dist/main.js"]
    # Both: the default network for Postgres, Qdrant and Redis, and `parsing`,
    # which it shares with the parser and nothing else. See `parser`.
    networks: [default, parsing]
    # No ports. The worker talks outward only — which also means the image's
    # HTTP healthcheck can never pass here, and a container that is permanently
    # unhealthy is one an orchestrator will restart forever. It has nothing to
    # serve, so it has nothing to check.
    healthcheck:
      disable: true
    depends_on:
      migrate: { condition: service_completed_successfully }
      postgres: { condition: service_healthy }
      qdrant: { condition: service_started }
      redis: { condition: service_healthy }
      # `required: false` because this service exists only in `full`. Without
      # it, naming a profiled service here would break every profile that does
      # not start one.
      minio-init: { condition: service_completed_successfully, required: false }

  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      # A superuser, and therefore the one configuration docs/config.md tells
      # operators not to use: row-level security does not apply to a superuser
      # at all, so the tenant-isolation policies are enabled and inert here.
      #
      # Deliberate for a development and pilot distribution, where one role is
      # one less thing to get wrong — and stated rather than left to be
      # discovered, because it is exactly what hid two bugs for months. The
      # application worked only because of this. Anything past a pilot should
      # point NACRE_PG_URL at `nacre_app` (created by migration 0002, given a
      # password by the operator) and leave this role to the migrate service.
      POSTGRES_USER: nacre
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nacre}
      POSTGRES_DB: nacre
    volumes: ["postgres:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U nacre -d nacre"]
      interval: 5s
      timeout: 5s
      retries: 10

  qdrant:
    # Pinned to a version the client in packages/core actually supports.
    #
    # It was v1.15.1 against a `^1.18.0` client, so every process logged
    # "Client version 1.18.0 is incompatible with server version 1.15.1" on
    # startup, forever. A warning that appears on every boot is a warning
    # nobody reads, which is how the one that matters gets missed.
    #
    # CI ran `qdrant/qdrant:latest` at the same time, so the version this file
    # ships was the one version nothing tested against. Both are this now.
    image: qdrant/qdrant:v1.18.3
    restart: unless-stopped
    volumes: ["qdrant:/qdrant/storage"]
    # Not published by default. The vector store holds every embedding in the
    # installation and has no per-tenant authorization of its own — reaching it
    # directly bypasses the entire permission model.
    expose: ["6333"]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--save", "60", "1"]
    volumes: ["redis:/data"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  # On a network of its own, shared with the worker and nothing else. It is the
  # one process that reads hostile input — every uploaded file and every page
  # an ingest names — and the default network beside it holds Qdrant and Redis,
  # which take no credential, and Postgres. A parser that a crafted document
  # took over would have reached all three by name. The worker is its only
  # caller, so the worker is the only other member. `lint:compose` holds it.
  parser:
    build:
      context: .
      dockerfile: docker/Dockerfile.parser
    restart: unless-stopped
    expose: ["8090"]
    networks: [parsing]

  # ─────────────────────────── hosted ───────────────────────────

  # One protocol in, several vendors out. The worker and the search path speak
  # OpenAI's embeddings contract and nothing else; this routes a model name to a
  # vendor that does not, without the core learning three protocols and without
  # a vendor credential reaching Postgres — and therefore every dump.
  #
  # Point an `embedding_providers` row's endpoint at `http://embedding-adapter:8091`
  # and give it the model you routed. No migration, no new column: the request
  # already carries `model`, and that is the routing key.
  #
  # It starts with **nothing configured** and refuses to boot that way, naming
  # NACRE_EMBED_ROUTES. There is no default vendor and no fallback, because
  # which one sees your documents is not a decision this file makes. Nothing is
  # seeded in .env.example for the same reason.
  embedding-adapter:
    profiles: [hosted]
    build:
      context: .
      dockerfile: docker/Dockerfile.embedding-adapter
    restart: unless-stopped
    # The variables it reads and no others, named one by one. It used to take
    # `env_file: .env` of its own, under a comment saying it was handed only the
    # credentials it needs — and `.env` is the whole installation's: the JWT
    # signing secret, the database URL, the bucket keys, the second-factor key,
    # the mail relay. This is the one container that parses answers from
    # somebody else's servers. `lint:compose` holds this list against the
    # literals in services/embedding_adapter/app.py, in both directions.
    environment:
      NACRE_EMBED_ROUTES: ${NACRE_EMBED_ROUTES:-}
      NACRE_EMBED_OPENAI_COMPATIBLE_ENDPOINT: ${NACRE_EMBED_OPENAI_COMPATIBLE_ENDPOINT:-}
      NACRE_EMBED_OPENAI_COMPATIBLE_API_KEY: ${NACRE_EMBED_OPENAI_COMPATIBLE_API_KEY:-}
      NACRE_EMBED_OPENAI_COMPATIBLE_API_KEY_FILE: ${NACRE_EMBED_OPENAI_COMPATIBLE_API_KEY_FILE:-}
      NACRE_EMBED_CLOUDFLARE_ACCOUNT: ${NACRE_EMBED_CLOUDFLARE_ACCOUNT:-}
      NACRE_EMBED_CLOUDFLARE_API_KEY: ${NACRE_EMBED_CLOUDFLARE_API_KEY:-}
      NACRE_EMBED_CLOUDFLARE_API_KEY_FILE: ${NACRE_EMBED_CLOUDFLARE_API_KEY_FILE:-}
      NACRE_EMBED_GOOGLE_API_KEY: ${NACRE_EMBED_GOOGLE_API_KEY:-}
      NACRE_EMBED_GOOGLE_API_KEY_FILE: ${NACRE_EMBED_GOOGLE_API_KEY_FILE:-}
      NACRE_EMBED_VOYAGE_API_KEY: ${NACRE_EMBED_VOYAGE_API_KEY:-}
      NACRE_EMBED_VOYAGE_API_KEY_FILE: ${NACRE_EMBED_VOYAGE_API_KEY_FILE:-}
      NACRE_RERANK_VENDOR: ${NACRE_RERANK_VENDOR:-}
      NACRE_RERANK_MODEL: ${NACRE_RERANK_MODEL:-}
      NACRE_RERANK_CLOUDFLARE_ACCOUNT: ${NACRE_RERANK_CLOUDFLARE_ACCOUNT:-}
      NACRE_RERANK_CLOUDFLARE_API_KEY: ${NACRE_RERANK_CLOUDFLARE_API_KEY:-}
      NACRE_RERANK_CLOUDFLARE_API_KEY_FILE: ${NACRE_RERANK_CLOUDFLARE_API_KEY_FILE:-}
      NACRE_RERANK_COHERE_API_KEY: ${NACRE_RERANK_COHERE_API_KEY:-}
      NACRE_RERANK_COHERE_API_KEY_FILE: ${NACRE_RERANK_COHERE_API_KEY_FILE:-}
      NACRE_RERANK_JINA_API_KEY: ${NACRE_RERANK_JINA_API_KEY:-}
      NACRE_RERANK_JINA_API_KEY_FILE: ${NACRE_RERANK_JINA_API_KEY_FILE:-}
      NACRE_RERANK_VOYAGE_API_KEY: ${NACRE_RERANK_VOYAGE_API_KEY:-}
      NACRE_RERANK_VOYAGE_API_KEY_FILE: ${NACRE_RERANK_VOYAGE_API_KEY_FILE:-}
    expose: ["8091"]

  # ─────────────────────────── full ───────────────────────────

  minio:
    profiles: [full]
    image: cgr.dev/chainguard/minio:latest
    restart: unless-stopped
    command: ["server", "/data", "--console-address", ":9001"]
    environment:
      MINIO_ROOT_USER: ${NACRE_S3_ACCESS_KEY:-minioadmin}
      MINIO_ROOT_PASSWORD: ${NACRE_S3_SECRET_KEY:-minioadmin}
    volumes: ["minio:/data"]
    expose: ["9000"]
    # AGPLv3, which is why it is here and not in minimal. The default is an
    # external S3-compatible endpoint; see docs/licensing.md before making this
    # part of the standard install.

  # The bucket, which MinIO does not create and the S3 client does not either.
  #
  # Without it the first ingest on a deployment that turned object storage on
  # fails with `NoSuchBucket`, and the caller sees `500 Internal error` —
  # bytes go to the bucket before the row, so nothing is stored and the reason
  # is only in the API's log. Found by someone following docs/config.md to set
  # `NACRE_S3_*` and posting their first document.
  #
  # The CI overlay has had exactly this since binary ingest landed. That it was
  # not here is the gap: CI got a bucket and every real `full` deployment did
  # not, so the one arrangement nobody ran was the one people would.
  #
  # Only for the MinIO this profile starts. A deployment pointing NACRE_S3_* at
  # somebody else's S3 owns its own bucket, and this creates one here that
  # nothing reads — harmless, and better than a one-shot holding credentials
  # for an endpoint it was not given.
  minio-init:
    profiles: [full]
    image: cgr.dev/chainguard/minio:latest
    restart: "no"
    depends_on:
      minio: { condition: service_started }
    environment:
      MC_HOST_local: http://${NACRE_S3_ACCESS_KEY:-minioadmin}:${NACRE_S3_SECRET_KEY:-minioadmin}@minio:9000
    # The retry loop is the healthcheck MinIO's image cannot easily carry here:
    # `mc` waits for the server by trying the thing it came to do.
    entrypoint:
      - sh
      - -c
      - >
        for i in $$(seq 1 30); do
          mc mb --ignore-existing "local/${NACRE_S3_BUCKET:-nacre}" && exit 0;
          sleep 2;
        done;
        echo "the bucket was never created" >&2; exit 1

  # The two services with no arm64 image at all, and the only two that state a
  # platform.
  #
  # Text Embeddings Inference publishes linux/amd64 only — checked against the
  # registry for cpu-1.6, cpu-1.7, cpu-1.8 and cpu-latest. Without this key an
  # arm64 host (an M-series Mac, a Graviton node) asks for its own architecture
  # and the pull fails with `no matching manifest for linux/arm64/v8`; with it,
  # the amd64 image runs under emulation. On an amd64 host the key does nothing
  # at all, which is what lets it live in the file everyone uses.
  #
  # Stating the platform is not a claim that it works: whether an x86 build with
  # vector instructions runs under Rosetta 2 or QEMU is a question about your
  # runtime. docs/apple-silicon.md recommends the arrangement that avoids it —
  # an embedder on the host, reached through `host.docker.internal` above.
  embedder:
    profiles: [full, airgapped]
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.6
    platform: linux/amd64
    restart: unless-stopped
    command: ["--model-id", "BAAI/bge-m3"]
    volumes: ["models:/data"]
    # Off by default so `full` fetches the weights on first boot as it should.
    # An airgapped install seeds the models volume once from a connected machine
    # and sets HF_HUB_OFFLINE=1, so these read the cache and never reach the Hub;
    # with it set and the cache empty they fail at startup rather than downloading.
    environment:
      HF_HUB_OFFLINE: ${HF_HUB_OFFLINE:-0}
    expose: ["80"]

  # ─── the `demo` profile ─────────────────────────────────────────────────────
  #
  # `minimal` needs an embedder you name, which is the honest arrangement for an
  # installation and the wrong one for somebody deciding whether to spend an
  # afternoon on this. So `demo` brings its own, seeds a corpus, and hands over
  # three logins that see different things.
  #
  # **A different model from `full`, and that is the whole reason this is a
  # separate service.** `full` runs bge-m3: 560M parameters, 1024 dimensions,
  # and on a laptop CPU it blows the worker's 120-second budget — which
  # docs/apple-silicon.md already says. bge-small-en-v1.5 is 33M and 384, small
  # enough to answer in milliseconds on four cores and good enough that the
  # ranking in a demo looks like ranking. Compose cannot vary a command by
  # profile, so it is its own service rather than a flag.
  #
  # English-only, and that is a demo's trade rather than a recommendation: the
  # corpus is English, and `bge-small-en-v1.5` is a tenth the size of the
  # multilingual model that would handle anything else. An installation with
  # documents in more than one language wants bge-m3.
  demo-embedder:
    profiles: [demo]
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.6
    platform: linux/amd64
    restart: unless-stopped
    command: ["--model-id", "BAAI/bge-small-en-v1.5"]
    volumes: ["models:/data"]
    environment:
      HF_HUB_OFFLINE: ${HF_HUB_OFFLINE:-0}
    expose: ["80"]

  # One shot, after the API is ready.
  #
  # **No bind mount, and that was learned by running it.** The seed and its
  # corpus live at `/demo` inside the image. Mounting `./docker/demo` was the
  # first shape and it works on exactly one arrangement: a daemon on the same
  # machine as the checkout. A bind mount names a path on the *daemon*, so with
  # DOCKER_HOST over TCP — a remote daemon, a VPS, a CI runner — Compose finds
  # nothing there, mounts an empty directory, and the container fails with
  # `can't open '/demo/seed.sh'` while the file sits in the checkout.
  #
  # To use your own documents instead, mount over it: `-v ./mine:/demo/corpus`.
  # That is a deliberate choice by somebody who knows where their daemon is,
  # rather than the default that quietly requires it.
  demo-seed:
    <<: *app
    profiles: [demo]
    restart: "no"
    entrypoint: ["/bin/sh", "/demo/seed.sh"]
    # Where the credentials are written, and the reason there is a volume at
    # all. They are generated, printed once, and stored nowhere as plaintext —
    # so a `docker compose down` followed by `up` used to leave a seeded stack
    # whose logins nobody could recover: the data survived, the seed said
    # "already seeded", and the container holding the only copy of the output
    # was gone. Now the second run reads them back.
    volumes: ["demo:/state"]
    environment:
      <<: *config
      NACRE_API_URL: http://api:8080
      # The provider row `init` writes, and therefore what every layer here
      # embeds with. It names the demo embedder rather than whatever the .env
      # points at, so `--profile demo` does not depend on the operator having
      # configured one — which is the entire point of the profile.
      NACRE_DEFAULT_EMBEDDING_ENDPOINT: http://demo-embedder:80
      NACRE_DEFAULT_EMBEDDING_MODEL: bge-small-en-v1.5
      NACRE_DEFAULT_EMBEDDING_DIM: 384
    depends_on:
      api: { condition: service_healthy }
      worker: { condition: service_started }
      demo-embedder: { condition: service_started }
    healthcheck:
      disable: true

  reranker:
    profiles: [full, airgapped]
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.6
    platform: linux/amd64
    restart: unless-stopped
    # `--max-client-batch-size` is not tuning. TEI's default is **32**, and the
    # search path sends `NACRE_RERANK_CANDIDATES` texts in one call — 50 by
    # default, up to 500 — so the combination this profile ships answered
    # `413 batch size 50 > maximum allowed batch size 32` for every search with
    # more than 32 candidates. Reranking fails open, so nothing broke: searches
    # kept answering in fusion order, with a counter and a log line, and a
    # deployment that turned reranking on got none of it.
    #
    # Splitting the batch is the wrong repair and `docs/config.md` says why — a
    # reranker is not promised to score each text independently, so two calls
    # produce two sets that cannot be compared, which is a wrong ordering with
    # no symptom at all. So the server is told to accept what the product can
    # send, and `lint:rerank-batch` holds the two numbers against each other.
    command:
      ["--model-id", "BAAI/bge-reranker-base", "--max-client-batch-size", "512"]
    volumes: ["models:/data"]
    environment:
      HF_HUB_OFFLINE: ${HF_HUB_OFFLINE:-0}
    expose: ["80"]

  # ───────────────────────── airgapped ─────────────────────────

  # A placeholder for the commercial `sso` module, not something the open core
  # uses: sign-in here is email and password, and nothing in this repository
  # accepts a token Keycloak issues (OIDC verification is `sso`, which is
  # commercial). It runs so an airgapped install that later adds `sso` has the
  # identity provider already local; on the open product it consumes nothing.
  keycloak:
    profiles: [airgapped]
    image: quay.io/keycloak/keycloak:26.0
    restart: unless-stopped
    command: ["start-dev"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_PASSWORD:-admin}
    expose: ["8080"]

volumes:
  postgres:
  qdrant:
  redis:
  minio:
  models:
  # What the demo seed wrote down. It survives `down` and dies with `down -v`,
  # which is the same lifetime as the organization those credentials belong to —
  # keeping them any longer would leave a file naming logins that no longer
  # exist.
  demo:

# `default` is Compose's own and holds everything. `parsing` holds the parser
# and the worker: the parser reads hostile input, and nothing it could be made
# to do should find Postgres, Qdrant or Redis on its network.
networks:
  default: {}
  parsing: {}