Dockerfile
Rama main · réplica de lectura del repositorio original.
# exomem — multi-stage container image.
#
# Four final targets share this one file:
# lean (target `lean`, DEFAULT — base deps only, no torch, no model download)
# ml (target `ml` — the `embeddings` extra, CPU-only torch, no CUDA runtime)
# cuda (target `cuda` — the `embeddings` extra, CUDA-capable torch, CPU-default at idle)
# cloud (target `cloud` — Exomem Cloud cell runtime, derived from `cell-runtime`)
#
# `lean` is intentionally the LAST stage in this file: `docker build .` / `docker
# buildx build .` with no `--target` builds the final stage in the file, and lean
# is meant to be that zero-argument default (matches the stdio one-liner's
# zero-Python, zero-model-download onboarding promise — see docs/docker.md).
# Build `ml` explicitly with `--target ml` (or pull the published `:ml` tag).
# Build `cuda` explicitly with `--target cuda` (or pull the published `:cuda` tag).
#
# Every stage builds from the checked-out source tree (COPY src/ pyproject.toml
# uv.lock), never a published PyPI wheel — see design.md D1.
ARG UV_VERSION=0.11.28
# COPY --from does not support ARG expansion; a global-scope FROM alias does
# (buildx's documented workaround), so the pin lives in one place above.
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv
########################################################################
# builder-lean — base dependencies only, installed from local source.
# Never touches pyproject.toml's cu132 (CUDA) torch index: `uv sync` here
# only resolves the base `dependencies = [...]` list, which has no torch.
########################################################################
FROM python:3.12-slim AS builder-lean
COPY --from=uv /uv /usr/local/bin/uv
WORKDIR /app
# Deterministic, reproducible build: install the venv at a fixed path, never
# let uv silently download its own Python (python:3.12-slim already has one),
# and copy (not hardlink) into the venv so it survives the COPY --from into a
# later stage cleanly.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_PYTHON_DOWNLOADS=never \
UV_LINK_MODE=copy
# Matches .dockerignore's allowlist exactly — nothing else is needed to build.
COPY pyproject.toml uv.lock README.md LICENSE ./
COPY src/ src/
RUN uv sync --frozen --no-dev --no-editable
########################################################################
# builder-ml — adds the `embeddings` extra (hybrid search) with CPU-only
# torch. See design.md D2 for the full rationale; summary:
#
# pyproject.toml pins `[tool.uv.sources] torch = [{ index = "pytorch-cu132",
# marker = "sys_platform == 'win32' or sys_platform == 'linux'" }]`. This
# container's platform IS Linux, so a plain `uv sync --extra embeddings` here
# would resolve torch against the cu132 (CUDA 13.2) index — multi-GB CUDA
# wheels baked into an image with no CUDA runtime and no GPU. Instead:
#
# 1) `uv pip install torch --index-url .../cpu` — the CPU-only wheel from
# an explicit index, bypassing [tool.uv.sources] entirely.
# 2) `uv pip install ".[embeddings]"` — the rest of the extra. `uv pip`
# (unlike `uv sync`/`uv add`) does NOT consult [tool.uv.sources] or
# [tool.uv.index], so it does not re-resolve/reinstall torch against the
# CUDA index; the already-installed CPU wheel satisfies the `torch>=2.12`
# requirement.
#
# If a future pyproject.toml edit changes the `embeddings` extra's torch pin
# in a way the pinned CPU wheel below can no longer satisfy, step 2 fails
# loudly (version conflict) — see design.md's Risks section before changing
# either the extra or this Dockerfile.
########################################################################
FROM builder-lean AS builder-ml
RUN uv pip install --python /app/.venv/bin/python "torch>=2.12" --index-url https://download.pytorch.org/whl/cpu \
&& uv pip install --python /app/.venv/bin/python ".[embeddings]"
########################################################################
# builder-cuda — adds the `embeddings` extra with CUDA-capable torch.
#
# This is intentionally separate from `ml`: the CPU image stays portable and
# small, while this image carries the NVIDIA/CUDA torch wheel for Linux hosts
# that run Docker with the NVIDIA container runtime. CUDA capability still does
# NOT imply CUDA residency: exomem's default `normal` mode selects CPU unless the
# user opts into performance mode or an explicit CUDA device.
########################################################################
FROM builder-lean AS builder-cuda
RUN uv pip install --python /app/.venv/bin/python "torch>=2.12" --index-url https://download.pytorch.org/whl/cu132 \
&& uv pip install --python /app/.venv/bin/python ".[embeddings]"
########################################################################
# builder-hosted — builder-lean plus ONNX Runtime and the embedding weights
# resolved at BUILD time into the image itself.
#
# A hosted cell runs under a default-deny NetworkPolicy with no egress rules
# and a read-only root filesystem. It therefore cannot download a model, and
# has nowhere writable to cache one if it could. Any weight the cell needs
# must already be a layer, or the `embeddings` grant produces a cell that
# advertises semantic recall and fails on first use.
#
# This stage builds from `builder-lean`, NOT `builder-ml`: the hosted lane
# serves the same bi-encoder through ONNX Runtime, which imports ~40 MiB
# against torch's ~400 and holds a materially smaller peak. Peak per cell is
# what decides how many tenants a node carries, so torch's absence here is the
# capacity argument, not a packaging preference. It is only possible in this
# lane — the reranker and CLIP are sentence-transformers models, and the hosted
# stage withholds both (EXOMEM_DISABLE_RANKING; CLIP belongs to `vision`).
#
# Only the bi-encoder is fetched, and only in the form the runtime reads.
########################################################################
FROM builder-lean AS builder-hosted
# Install the lean ONNX environment shared by Cloud, without fetching a model.
RUN uv pip install --python /app/.venv/bin/python ".[embeddings-onnx]" \
&& /app/.venv/bin/python -c "import importlib.util; assert importlib.util.find_spec('torch') is None, 'torch reached the cell environment'"
########################################################################
# builder-cloud-model — the multilingual recall model a cloud cell encodes
# with: bge-m3's pinned int8 artefact and its tokenizer, the model a personal
# server runs. Fetched through the same backend, then loaded once more with
# the network closed. It
# fetches into a root of its own, so the cloud stage copies bge-m3 alone: a
# cloud cell never loads the English model, and re-embeds a legacy sidecar
# with bge-m3.
########################################################################
FROM builder-hosted AS builder-cloud-model
ENV HF_HOME=/opt/exomem-cloud-models \
EXOMEM_RECALL_MODEL=BAAI/bge-m3
# The pinned multilingual export may need int8 quantization. ONNX's graph
# builder stays in this build stage; the cloud runtime copies only its models.
RUN uv pip install --python /app/.venv/bin/python "onnx>=1.17" \
&& EXOMEM_EMBED_BACKEND=onnx /app/.venv/bin/python -c "\
from exomem.embeddings import MODEL_NAME; \
from exomem import embedding_backend as backend; \
backend.load_encoder(MODEL_NAME, backend=backend.ONNX); \
print('fetched', MODEL_NAME)" \
&& HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 EXOMEM_EMBED_BACKEND=onnx \
/app/.venv/bin/python -c "\
from exomem.embeddings import MODEL_NAME; \
from exomem import embedding_backend as backend, recall_space; \
encoder = backend.load_encoder(MODEL_NAME, backend=backend.ONNX); \
v = encoder.encode(['offline load gate'], batch_size=1); \
assert v.shape == (1, recall_space.declared_dim(MODEL_NAME)), v.shape; \
print('offline load verified', MODEL_NAME, v.shape)"
########################################################################
# restic-fetch — the static restic binary for the cloud image's backup and
# restore Jobs (design D8). Fetched and verified in its own small stage, not
# the final `cloud` image, so no curl/bzip2/apt residue ships in the runtime
# layer that the image-pin admission policy trusts.
#
# The cloud image is published for linux/amd64 and linux/arm64. BuildKit sets
# TARGETARCH to the platform being built, which names the matching restic
# release asset; each asset has its own digest from that release's SHA256SUMS.
# The case below is closed over the platforms the release publishes: any other
# architecture fails the build instead of shipping a binary that cannot run.
# The final `restic version` runs the binary in this stage, which runs on the
# target platform. On a native build, as in the release and the pull-request
# check, a binary for another architecture fails here rather than in a
# tenant's first backup Job.
########################################################################
FROM debian:bookworm-slim AS restic-fetch
ARG TARGETARCH
ARG RESTIC_VERSION=0.19.1
ARG RESTIC_SHA256_AMD64=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c
ARG RESTIC_SHA256_ARM64=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465
RUN case "${TARGETARCH}" in \
amd64) restic_sha256="${RESTIC_SHA256_AMD64}" ;; \
arm64) restic_sha256="${RESTIC_SHA256_ARM64}" ;; \
*) echo "restic-fetch: no pinned restic for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
esac \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl bzip2 \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL -o /tmp/restic.bz2 \
"https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2" \
&& echo "${restic_sha256} /tmp/restic.bz2" | sha256sum -c - \
&& bunzip2 /tmp/restic.bz2 \
&& chmod 755 /tmp/restic \
&& mv /tmp/restic /usr/local/bin/restic \
&& /usr/local/bin/restic version
########################################################################
# Final: ml (target `ml`). Fresh slim base — no build tooling, no uv, no
# source tree — just the populated venv. HF_HOME pins the downloaded
# embedding/CLIP model weights under the declared /data volume so they
# persist across restarts instead of re-downloading into an ephemeral layer.
########################################################################
FROM python:3.12-slim AS ml
COPY --from=builder-ml /app/.venv /app/.venv
COPY LICENSE /LICENSE
ENV PATH=/app/.venv/bin:$PATH \
EXOMEM_HOST=0.0.0.0 \
EXOMEM_LOG_DIR=/data/logs \
HF_HOME=/data/hf \
EXOMEM_CONTAINER_VARIANT=ml
LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
org.opencontainers.image.description="exomem — local knowledge substrate for owned markdown/Obsidian vaults, exposed through MCP, REST, and CLI (ml: hybrid embeddings + CLIP search, CPU-only torch, no GPU)"
# No HEALTHCHECK here by design (design.md D5) — a Dockerfile-level HEALTHCHECK
# would run unconditionally, including for `--transport stdio`, where there is
# no HTTP server to probe. The healthcheck lives in compose.yaml instead, where
# it only applies to the long-running HTTP deployment.
VOLUME /data
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]
########################################################################
# Final: cuda (target `cuda`). Fresh slim base — no build tooling, no uv, no
# source tree — just the populated venv. The image is CUDA-capable but remains
# CPU-default at idle via exomem's mode resolver. NVIDIA_* env vars are hints
# consumed by the NVIDIA container runtime; without that runtime, doctor reports
# CUDA unavailable and the service degrades to CPU.
########################################################################
FROM python:3.12-slim AS cuda
COPY --from=builder-cuda /app/.venv /app/.venv
COPY LICENSE /LICENSE
ENV PATH=/app/.venv/bin:$PATH \
EXOMEM_HOST=0.0.0.0 \
EXOMEM_LOG_DIR=/data/logs \
HF_HOME=/data/hf \
EXOMEM_CONTAINER_VARIANT=cuda \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
org.opencontainers.image.description="exomem — local knowledge substrate for owned markdown/Obsidian vaults, exposed through MCP, REST, and CLI (cuda: hybrid embeddings + CLIP search, CUDA-capable torch, CPU-default at idle)"
# No HEALTHCHECK here by design — a Dockerfile-level HEALTHCHECK would run
# unconditionally, including for `--transport stdio`, where there is no HTTP
# server to probe. The healthcheck lives in compose.yaml instead.
VOLUME /data
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]
########################################################################
# cell-runtime — the base of the cloud image, which adds its own model. The
# platform mounts only the bound vault/state/log roots plus native
# Secret/operator projections. Kubernetes supplies readOnlyRootFilesystem; this image supplies
# the matching fixed runtime identity and avoids bytecode writes to the image
# layer.
#
# The cell image carries the embedding runtime and pre-baked weights, not the lean venv:
# a paying tenant gets the same semantic recall as the free local runtime.
# The cell has no egress and no writable root, so the weights are a read-only
# image layer under HF_HOME and the hub client is pinned offline — an
# accidental fetch must fail loudly at load rather than hang a tenant query
# against a blocked NetworkPolicy until the client's deadline.
#
# MALLOC_ARENA_MAX=2 bounds glibc's per-thread arenas, whose freed-but-held
# high-water otherwise grows with the thread count and never returns to the
# OS; the runtime also calls malloc_trim(0) after model reaps and derived
# drain batches. The cloud stage inherits both.
########################################################################
FROM python:3.12-slim AS cell-runtime
COPY --from=builder-hosted /app/.venv /app/.venv
COPY LICENSE /LICENSE
ARG EXOMEM_RELEASE_BUILD_TIME
RUN test -n "$EXOMEM_RELEASE_BUILD_TIME" \
&& groupadd --gid 10001 exomem \
&& useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin exomem
ENV PATH=/app/.venv/bin:$PATH \
EXOMEM_HOST=0.0.0.0 \
EXOMEM_RELEASE_BUILD_TIME=${EXOMEM_RELEASE_BUILD_TIME} \
HF_HOME=/opt/exomem-models \
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1 \
EXOMEM_DISABLE_RANKING=1 \
EXOMEM_EMBED_BACKEND=onnx \
MALLOC_ARENA_MAX=2 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER 10001:10001
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]
########################################################################
# Final: cloud (target `cloud`). Exomem Cloud cell runtime (design D1/D2/D8).
#
# Derived from `cell-runtime`: the offline ONNX model environment,
# `EXOMEM_DISABLE_RANKING` and read-only-root compatibility, with bge-m3 as its
# only pre-baked model (see `recall_space.configured_recall_model`). It adds the
# media engines below; each stays off until cellctl switches it on in a cell
# (EXOMEM_MEDIA_ENGINES). Cloud mode (`EXOMEM_CLOUD_CELL=1`) is a
# thin seam over the standalone runtime (D1), so this stage exists to add the
# identity, backup tooling and command the cell pod needs — not a different
# Python environment.
#
# UID/GID 10001 keeps `cell-runtime`'s numeric identity but gets home `/data/host`
# instead of `/nonexistent`: standalone custody resolves under
# `<home>/.local/state/exomem/standalone-host-control-v1` with no code
# override (`authorization_custody._standalone_host_control_root`, D2), so the
# passwd home directory IS the custody root. The pod's `fsGroup: 10001` plus
# this image's explicit UID/GID keep ownership consistent across first start,
# pod replacement and restore.
#
# restic ships baked into the image (not fetched at runtime) because a cloud
# cell's NetworkPolicy grants no runtime egress at all, and every Job that
# touches the volume — init, backup, restore — runs this one digest-pinned
# image under the ValidatingAdmissionPolicy (D4, D8).
########################################################################
FROM cell-runtime AS cloud
COPY --from=restic-fetch /usr/local/bin/restic /usr/local/bin/restic
COPY --from=builder-cloud-model /opt/exomem-cloud-models /opt/exomem-models
USER root
RUN usermod --home /data/host exomem \
&& apt-get update \
&& apt-get install -y --no-install-recommends age \
&& rm -rf /var/lib/apt/lists/*
# Media engines (change `add-cloud-multimodal-processing`, design D3–D5).
#
# Tesseract with every script model Debian packages from tessdata, the OSD model,
# and the language packs EXOMEM_OCR_LANGS names (`+`-separated tessdata names). A
# pack added there needs no code change: OCR reads each pack's script from its own
# data. The same list is the default OCR reads with when a page names no script.
#
# The Python engines are the packages the `media-cpu` extra adds to the venv that
# `cell-runtime` already holds, at the versions and hashes uv.lock pins. The export
# skips every package the venv has, so the serving runtime (onnxruntime, protobuf,
# click) keeps the versions production runs; `--no-deps` stops uv from changing one
# to satisfy a media package, and `uv pip check` fails the build when that leaves a
# requirement unmet. It has no CUDA wheel and no torch; the gate below fails the
# build if either arrives.
#
# EXOMEM_OCR_INVENTORY: reading each OCR model's script reads every traineddata
# file (about 350 MB), and every media child is fresh. The gate's first OCR run
# writes that inventory here, read-only at run time, so a child reads one small file.
#
# OMP_THREAD_LIMIT=1: Tesseract otherwise starts one OpenMP thread per host CPU,
# not per cell CPU, and each thread's stack counts against the media child's
# data-segment limit.
ARG EXOMEM_OCR_LANGS=eng+jpn+jpn_vert+est
# The engines this image claims. The gate needs a passing sample of every media
# kind they serve, so a claimed format cannot ship unproven.
ARG EXOMEM_MEDIA_SHIPPED_ENGINES=documents,ocr,speech
RUN packs="$(echo "${EXOMEM_OCR_LANGS}" | tr '+_' ' -' | sed 's/[^ ][^ ]*/tesseract-ocr-&/g')" \
&& apt-get update \
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-osd 'tesseract-ocr-script-*' ${packs} \
&& rm -rf /var/lib/apt/lists/*
RUN --mount=type=bind,from=uv,source=/uv,target=/usr/local/bin/uv \
--mount=type=bind,from=builder-lean,source=/app,target=/src,rw \
uv export --project /src --frozen --no-dev --no-emit-project --extra media-cpu \
$(/app/.venv/bin/python -c 'import importlib.metadata as m; print(*{"--no-emit-package=" + d.metadata["Name"] for d in m.distributions()})') \
--output-file /tmp/media-cpu.txt \
&& uv pip install --python /app/.venv/bin/python --no-cache --require-hashes --no-deps -r /tmp/media-cpu.txt \
&& uv pip check --python /app/.venv/bin/python \
&& rm /tmp/media-cpu.txt
ENV EXOMEM_OCR_DEFAULT_LANGS=${EXOMEM_OCR_LANGS} \
EXOMEM_OCR_INVENTORY=/opt/exomem-ocr/inventory.json \
OMP_THREAD_LIMIT=1 \
EXOMEM_ASR_BACKEND=onnx \
EXOMEM_MEDIA_BUDGETS='{"speech":{"anon_mib":1024,"vmdata_mib":4096},"clip":{"anon_mib":256,"vmdata_mib":1792}}'
# New media artifacts only fetch their pinned publication; a cell never builds them.
RUN HF_HUB_OFFLINE=0 python -c "\
from exomem.media_models import ensure_media_model; \
ensure_media_model('image'); ensure_media_model('image-query'); ensure_media_model('speech')"
RUN --network=none python -c "\
from exomem.clip_query import OnnxClipQueryEncoder; \
assert OnnxClipQueryEncoder().encode(['offline query gate']).shape == (1, 512)"
# The image tower must keep the stored vector space: with networking off, it
# reproduces the sentence-transformers vector of a committed photograph.
RUN --network=none \
--mount=type=bind,source=tests/fixtures/clip,target=/clip-gate \
python -c "\
import json, numpy as np; from pathlib import Path; from exomem import embeddings; \
ref = np.asarray(json.loads(Path('/clip-gate/reference.json').read_text())['vector'], dtype=np.float32); \
vec = embeddings.embed_image(Path('/clip-gate/photo.jpg')); \
assert type(embeddings._CLIP_MODEL).__name__ == 'OnnxClipImageEncoder'; \
assert float(vec @ (ref / np.linalg.norm(ref))) >= 0.9999"
# The support proof: with networking off, extract a real sample of every format
# the image serves and find its phrase. A format that fails is not shipped.
RUN --network=none \
--mount=type=bind,source=tests/fixtures/media-samples,target=/samples \
--mount=type=bind,source=scripts/check-media-samples.py,target=/media-gate.py \
mkdir -p /opt/exomem-ocr \
&& HOME=/tmp python /media-gate.py /samples "${EXOMEM_MEDIA_SHIPPED_ENGINES}" \
&& test -s "${EXOMEM_OCR_INVENTORY}" \
&& find /tmp -mindepth 1 -delete
# EXOMEM_LOG_DIR: no runtime log ever lands on the tenant volume D8 backs up,
# even without the manifest setting it (design D1.2 "Log directory"). D2's
# writable `/tmp` emptyDir is where this actually lands at runtime.
#
# FASTMCP_CHECK_FOR_UPDATES / FASTMCP_SHOW_SERVER_BANNER: a cell's
# NetworkPolicy grants no egress at all, not even DNS (D5), so FastMCP's
# startup update check otherwise stalls every cold start on DNS (measured:
# `/health` up after 24s against 4s with it off). Both names are read by the
# installed `fastmcp.settings.Settings` (`env_prefix="FASTMCP_"`).
#
# EXOMEM_RECALL_MODEL: a cloud cell encodes recall with the multilingual model
# a personal server runs.
# ORT_DISABLE_TELEMETRY: ORT reads this before import, preventing its device-ID
# database from entering the tenant volume without changing the custody home.
#
# EXOMEM_DISABLE_CLIP: the image carries the ONNX image tower and the multilingual
# query tower. Image search stays off by default; a cell turns it on after the image
# engine's own acceptance passes.
ENV EXOMEM_CONTAINER_VARIANT=cloud \
EXOMEM_CLOUD_RESOURCE_POLICY=service-v1 \
EXOMEM_RECALL_MODEL=BAAI/bge-m3 \
EXOMEM_DISABLE_CLIP=1 \
ORT_DISABLE_TELEMETRY=1 \
EXOMEM_LOG_DIR=/tmp/exomem-logs \
FASTMCP_CHECK_FOR_UPDATES=off \
FASTMCP_SHOW_SERVER_BANNER=false
# The builder's gate loaded the model at its fetch root. Load the model this
# image names once more where the cell reads it, offline, so a broken copy
# fails the build rather than a tenant's first query. Cloud mode resolves the
# model as a cell does; HOME and the temporary files stay in /tmp, which the
# step empties, so the gate leaves nothing in the image.
RUN HOME=/tmp EXOMEM_CLOUD_CELL=1 python -c "\
from exomem.embeddings import MODEL_NAME; \
from exomem import embedding_backend as backend, recall_space; \
v = backend.load_encoder(MODEL_NAME, backend=backend.ONNX).encode(['image gate'], batch_size=1); \
assert v.shape == (1, recall_space.declared_dim(MODEL_NAME)), v.shape; \
print('image load verified', MODEL_NAME, v.shape)" \
&& find /tmp -mindepth 1 -delete
LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
org.opencontainers.image.description="exomem — Exomem Cloud cell runtime (fixed UID/GID 10001, home /data/host, restic-equipped, standalone trust model)"
# No HEALTHCHECK here by design (design.md D5) — see the `ml` stage's comment
# above; cellctl reads readiness from the pod's own probes, not from Docker.
USER 10001:10001
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--host", "0.0.0.0", "--port", "8765"]
########################################################################
# Final: lean (target `lean`) — the DEFAULT stage (last in this file; see
# the top-of-file note). Base dependencies only: no torch, no
# sentence-transformers, no model download. EXOMEM_DISABLE_EMBEDDINGS=1
# keeps search in keyword/BM25 mode. Opt into `ml` explicitly for hybrid
# search (`--target ml` at build time, or the published `:ml` tag).
########################################################################
FROM python:3.12-slim AS lean
COPY --from=builder-lean /app/.venv /app/.venv
COPY LICENSE /LICENSE
ENV PATH=/app/.venv/bin:$PATH \
EXOMEM_HOST=0.0.0.0 \
EXOMEM_LOG_DIR=/data/logs \
EXOMEM_DISABLE_EMBEDDINGS=1 \
EXOMEM_CONTAINER_VARIANT=lean
LABEL org.opencontainers.image.source="https://github.com/Artexis10/exomem" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
org.opencontainers.image.description="exomem — local knowledge substrate for owned markdown/Obsidian vaults, exposed through MCP, REST, and CLI (lean: keyword/BM25 search, no torch, no model download)"
# No HEALTHCHECK here by design (design.md D5) — see the `ml` stage's comment
# above; the healthcheck lives in compose.yaml.
VOLUME /data
EXPOSE 8765
ENTRYPOINT ["exomem"]
CMD ["--transport", "http", "--port", "8765"]