feat(docker): add container image for MCP server and CLI

Add a multi-stage, uv-based Dockerfile producing a CPU image (with the
extract + spellcheck extras), plus a CUDA variant (Dockerfile.gpu) for
onnxruntime-gpu accelerated embeddings.

A single flexible entrypoint dispatches to the MCP stdio server (default)
or the mempalace CLI. All state -- palace, config, and the lazily
downloaded embedding model -- persists under /data via HOME, runs as a
non-root user, and is exposed as a volume.

Also add a docker-compose.yml for convenience, a GHCR publish workflow,
and a Docker section in the README.
This commit is contained in:
sebaplaza 2026-06-05 12:12:43 +02:00
parent 02b8753d97
commit 717a57ff43
7 changed files with 386 additions and 0 deletions

55
.dockerignore Normal file
View File

@ -0,0 +1,55 @@
# Keep the build context lean — only what `uv sync` + the package need.
# VCS
.git/
.gitignore
# Python build artifacts / caches
*.egg-info/
dist/
build/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
coverage.xml
# Virtual environments (rebuilt inside the image)
.venv/
venv/
# Local config / secrets
.env
.env.*
.envrc
mempal.yaml
# Editor / OS cruft
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Repo material not needed at runtime
.devcontainer/
.github/
.agents/
.claude/
.codex/
.codex-plugin/
.claude-plugin/
benchmarks/
docs/
website/
landing/
assets/
examples/
tests/
*.md
!README.md

61
.github/workflows/docker-publish.yml vendored Normal file
View File

@ -0,0 +1,61 @@
name: Docker
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main, develop]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Only authenticate + push for in-repo events. Fork PRs lack the
# packages:write token, so they build (to validate the Dockerfile) but
# do not push.
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# latest -> default branch; semver tags -> released versions.
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

100
Dockerfile Normal file
View File

@ -0,0 +1,100 @@
# syntax=docker/dockerfile:1.7
# MemPalace — CPU image.
#
# Multi-stage build using uv (the project ships a uv.lock, so we install from
# the frozen lockfile for reproducible images). The default runtime is the MCP
# server over stdio; the CLI is reachable through the same entrypoint.
#
# Build:
# docker build -t mempalace .
# docker build -t mempalace --build-arg EXTRAS="extract,spellcheck" .
#
# Run (MCP server over stdio, palace persisted on the host):
# docker run -i --rm -v mempalace-data:/data mempalace
#
# Run (CLI):
# docker run --rm -v mempalace-data:/data mempalace search "why GraphQL"
#
# GPU acceleration lives in Dockerfile.gpu (it needs a CUDA base image).
ARG PYTHON_VERSION=3.12
# --- builder ----------------------------------------------------------------
FROM python:${PYTHON_VERSION}-slim AS builder
# uv: fast, lockfile-driven installer. Pinned by digest-less tag for clarity;
# bump deliberately.
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /bin/
# Some transitive deps (grpcio, onnxruntime, tokenizers) ship manylinux wheels
# for cp312, but keep a compiler around so a missing wheel degrades to a source
# build instead of failing the image. Dropped from the final stage.
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# Optional extras baked into the image. CPU-safe by default; GPU is a separate
# Dockerfile. Pass a comma-separated list, e.g. EXTRAS="extract,spellcheck".
ARG EXTRAS="extract,spellcheck"
# Layer 1: dependencies only (no project) — cached across source changes.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=README.md,target=README.md \
set -e; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-install-project --no-dev ${flags}
# Layer 2: the project itself. --no-editable installs mempalace into the venv's
# site-packages (instead of an .pth pointing at /app), so the runtime stage can
# copy only /app/.venv and drop the source tree.
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
set -e; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-dev --no-editable ${flags}
# --- runtime ----------------------------------------------------------------
FROM python:${PYTHON_VERSION}-slim AS runtime
LABEL org.opencontainers.image.title="MemPalace" \
org.opencontainers.image.description="Local-first AI memory — verbatim storage, MCP server + CLI." \
org.opencontainers.image.source="https://github.com/MemPalace/mempalace" \
org.opencontainers.image.licenses="MIT"
# /data is the single persistence root: HOME points here, so the palace
# (~/.mempalace/palace), config (~/.mempalace), and the HuggingFace model
# cache (~/.cache/huggingface, ~300 MB, lazy-downloaded on first use) all
# land under one mountable volume.
ENV HOME=/data \
PATH="/app/.venv/bin:${PATH}" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Non-root user owning the data volume.
RUN groupadd --gid 1000 mempalace \
&& useradd --uid 1000 --gid 1000 --home-dir /data --create-home mempalace
WORKDIR /app
# The resolved virtualenv from the builder — no build toolchain in this layer.
COPY --from=builder --chown=mempalace:mempalace /app/.venv /app/.venv
COPY --chown=mempalace:mempalace docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER mempalace
VOLUME ["/data"]
# Default to the MCP server; `docker run` it with `-i` for stdio JSON-RPC.
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["mcp"]

77
Dockerfile.gpu Normal file
View File

@ -0,0 +1,77 @@
# syntax=docker/dockerfile:1.7
# MemPalace — GPU (NVIDIA CUDA) image.
#
# The `gpu` extra pulls onnxruntime-gpu, which needs CUDA + cuDNN shared
# libraries at runtime, so this variant builds on an nvidia/cuda base instead
# of python:slim. It is single-stage on purpose: the venv is created with a
# uv-managed interpreter, and copying that interpreter cleanly across build
# stages is more trouble than the size saving is worth for a GPU image.
#
# Build:
# docker build -f Dockerfile.gpu -t mempalace:gpu .
#
# Run (requires the NVIDIA Container Toolkit on the host):
# docker run -i --rm --gpus all \
# -e MEMPALACE_EMBEDDING_DEVICE=cuda \
# -v mempalace-data:/data mempalace:gpu
#
# NOTE: onnxruntime-gpu ties itself to a CUDA major version. If embeddings
# fail to load on the GPU, align CUDA_IMAGE below with the CUDA release that
# the resolved onnxruntime-gpu wheel targets (see its release notes), then
# rebuild.
ARG CUDA_IMAGE=nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
FROM ${CUDA_IMAGE}
LABEL org.opencontainers.image.title="MemPalace (GPU)" \
org.opencontainers.image.description="Local-first AI memory with CUDA-accelerated embeddings." \
org.opencontainers.image.source="https://github.com/MemPalace/mempalace" \
org.opencontainers.image.licenses="MIT"
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /bin/
# Minimal toolchain for any source-built wheels; ca-certificates for the
# lazy HuggingFace model download on first use.
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG PYTHON_VERSION=3.12
ARG EXTRAS="extract,spellcheck,gpu"
ENV HOME=/data \
PATH="/app/.venv/bin:${PATH}" \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
UV_PYTHON_PREFERENCE=only-managed \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
MEMPALACE_EMBEDDING_DEVICE=cuda
WORKDIR /app
# uv-managed interpreter pinned to PYTHON_VERSION, installed to a stable path
# so the venv stays valid at runtime.
RUN uv python install ${PYTHON_VERSION}
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
set -e; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-dev --no-editable --python ${PYTHON_VERSION} ${flags}
RUN groupadd --gid 1000 mempalace \
&& useradd --uid 1000 --gid 1000 --home-dir /data --create-home mempalace \
&& chown -R mempalace:mempalace /app /data
COPY --chown=mempalace:mempalace docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER mempalace
VOLUME ["/data"]
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["mcp"]

View File

@ -69,6 +69,43 @@ python -m venv .venv && source .venv/bin/activate
pip install mempalace
```
### Docker
A container image is also available for running the MCP server or the CLI
without a local Python toolchain. Everything persists under `/data` (palace,
config, and the cached embedding model), so mount a volume there.
```bash
# Build the image (CPU; bundles the `extract` + `spellcheck` extras)
docker build -t mempalace .
# MCP server over stdio — note the `-i` flag (JSON-RPC needs stdin)
docker run -i --rm -v mempalace-data:/data mempalace
# Run any CLI command instead
docker run --rm -v mempalace-data:/data mempalace mine /work
docker run --rm -v mempalace-data:/data mempalace search "why GraphQL"
```
Wire it into an MCP client (e.g. Claude Code) as a stdio server:
```json
{
"mcpServers": {
"mempalace": {
"command": "docker",
"args": ["run", "-i", "--rm", "-v", "mempalace-data:/data", "mempalace"]
}
}
}
```
`docker compose run --rm mcp` works too (see `docker-compose.yml`). For
CUDA-accelerated embeddings, build the GPU variant with
`docker build -f Dockerfile.gpu -t mempalace:gpu .` and run it with
`--gpus all`. Customise the bundled extras at build time, e.g.
`docker build --build-arg EXTRAS="extract,spellcheck" -t mempalace .`.
## Quickstart
```bash

30
docker-compose.yml Normal file
View File

@ -0,0 +1,30 @@
# MemPalace via Docker Compose.
#
# The MCP server speaks JSON-RPC over stdio, so the `mcp` service is meant to
# be driven interactively (`docker compose run`), not left running detached:
#
# docker compose build
# docker compose run --rm mcp # MCP server over stdio
# docker compose run --rm mcp cli search "GraphQL" # one-off CLI command
#
# The named volume `mempalace-data` is mounted at /data and holds the palace,
# config, and the cached embedding model across runs.
services:
mcp:
build:
context: .
dockerfile: Dockerfile
image: mempalace:local
# stdio transport: keep STDIN open and allocate no TTY (raw JSON-RPC).
stdin_open: true
tty: false
volumes:
- mempalace-data:/data
environment:
# Override the palace location, embedding model, etc. here if needed.
# - MEMPALACE_EMBEDDING_MODEL=minilm
MEMPALACE_PALACE_PATH: /data/.mempalace/palace
volumes:
mempalace-data:

26
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env sh
# Flexible entrypoint: pick the MCP server or the CLI from the first argument.
#
# docker run -i mempalace -> MCP server over stdio (default)
# docker run -i mempalace mcp -> MCP server over stdio (explicit)
# docker run mempalace cli search "query" -> CLI passthrough (explicit)
# docker run mempalace search "query" -> CLI passthrough (implicit)
#
# `mcp` and `cli` are dispatch keywords; anything else is forwarded to the
# `mempalace` CLI verbatim so subcommands like `mine`, `search`, `wake-up`
# work without ceremony.
set -e
case "${1:-mcp}" in
mcp)
shift 2>/dev/null || true
exec mempalace-mcp "$@"
;;
cli)
shift
exec mempalace "$@"
;;
*)
exec mempalace "$@"
;;
esac