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