# 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: {}