Docker Deployment¶
Quick start¶
In a clone of the repository:
cp .env.example .env
docker compose up -d
Without a clone, fetch the two files from a release tag into an empty directory first, replacing vX.Y.Z with the release you run:
curl -fsSLO https://raw.githubusercontent.com/pvliesdonk/markdown-vault-mcp/vX.Y.Z/compose.yml
curl -fsSLO https://raw.githubusercontent.com/pvliesdonk/markdown-vault-mcp/vX.Y.Z/.env.example
The service shows what compose.yml runs, if you want to read it before starting it or adapt it to an existing Compose setup.
The server listens on port 8000 with HTTP transport, published on the host as 127.0.0.1:8000:8000, so only the host itself can connect. No reverse proxy, TLS terminator, or external network is assumed. Authentication is off, since every auth variable in .env.example is commented out; Ports covers serving other machines.
The security model says what a deployed server exposes and what stays your responsibility.
Copying .env.example first is the step to keep. Every variable in it arrives commented out, so the server starts on its defaults and the copy changes no behaviour by itself. It is the file you edit next, and compose.yml names it.
Apply a later edit with docker compose up -d, which recreates the container with the new values. docker compose restart does not pick them up: an env file is read when a container is created, so a restarted container keeps the values it was created with and the edit is ignored without any message.
Docker Compose¶
compose.yml is a working deployment, not an illustration. It is re-rendered on every copier update, so fixes and new defaults reach it; edit it inside the sentinel blocks described below and your changes survive.
The service¶
This is compose.yml as this version of the documentation ships it, including any entries this project adds in its sentinel blocks:
# Minimal working deployment for Markdown Vault MCP. Runs as-is:
#
# cp .env.example .env
# docker compose up -d
#
# Configuration lives in `.env`, not here. `.env.example` is generated from
# the server's own config surface and lists every variable with its default
# and a one-line description, so it is the file to edit; `docs/reference/configuration.md`
# carries the full reference. The `environment:` block below is only for
# values this compose file itself determines — paths on the volumes it mounts.
#
# Reverse proxies, TLS and published hostnames are deployment-specific and are
# NOT assumed here; `docs/deploy/docker.md` has a Traefik overlay to copy.
#
# The image is pulled, not built. To run your own build:
# `docker build -t ghcr.io/pvliesdonk/markdown-vault-mcp:dev . && docker compose up -d`
# with the tag overridden.
services:
markdown-vault-mcp:
image: ghcr.io/pvliesdonk/markdown-vault-mcp:latest
restart: unless-stopped
# `required: false` (Compose >= 2.24.0) so a checkout with no `.env` still
# starts on defaults; a named env_file that does not exist is otherwise a
# hard error, not a skipped entry.
env_file:
- path: .env
required: false
# Published on the host's loopback interface only, so this file as written
# reaches no other machine. Authentication, not the bind address, is what
# protects the server: set a bearer token or OIDC in `.env` first, then
# publish wider from `compose.override.yml` (`docs/deploy/docker.md`).
ports:
- "127.0.0.1:8000:8000"
# Named volume permissions are fixed automatically by the entrypoint.
# For bind-mount UID/GID mismatch, set PUID/PGID in your .env file:
# PUID=1001
# PGID=1001
volumes:
- service-data:/data/service
- state-data:/data/state
# DOMAIN-COMPOSE-VOLUMES-START — project mounts; kept across copier update
- ${MARKDOWN_VAULT_MCP_SOURCE_DIR:-./vault}:/data/vault
# DOMAIN-COMPOSE-VOLUMES-END
environment:
# Determined by this file, not an operator knob: points the server at
# the state volume mounted above. Everything else belongs in `.env`.
FASTMCP_HOME: /data/state/fastmcp
# A container's stderr is not a terminal, so the server logs one JSON
# object per record with nothing set. Uncomment to read `docker logs`
# in colour instead (`.env` works too; this is the same knob).
# MARKDOWN_VAULT_MCP_LOG_FORMAT: rich
# DOMAIN-COMPOSE-ENVIRONMENT-START — project env; kept across copier update
MARKDOWN_VAULT_MCP_SOURCE_DIR: /data/vault
MARKDOWN_VAULT_MCP_INDEX_PATH: /data/state/index.db
MARKDOWN_VAULT_MCP_EMBEDDINGS_PATH: /data/state/embeddings/embeddings
MARKDOWN_VAULT_MCP_FASTEMBED_CACHE_DIR: /data/state/fastembed
# DOMAIN-COMPOSE-ENVIRONMENT-END
# Liveness: `GET /health` answers 200 for as long as the process serves.
# Compose itself only reports the verdict (`docker compose ps`, `up
# --wait`, `depends_on: condition: service_healthy`); an orchestrator that
# acts on it (Swarm, a Kubernetes livenessProbe, an autoheal sidecar)
# restarts the container. Deliberately not `/health/ready` (503 when a
# backing store is gone): a restart does not fix an unreachable backend,
# and a readiness verdict here would hold up every `service_healthy`
# dependent for one. Probe `/health/ready` from the load balancer instead.
# The path assumes the conventional `/mcp` mount — the server derives its
# health prefix from `MARKDOWN_VAULT_MCP_HTTP_PATH`, so a `.env` that mounts
# under `/x/mcp` moves the route to `/x/health` and this probe must
# follow. `python` is the image's venv interpreter, already on PATH;
# `urlopen` raises on any non-2xx, so a 503 or a refused socket exits 1.
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).close()"
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# DOMAIN-COMPOSE-SERVICES-START — sidecars (a task backend, a cache); kept across copier update
# DOMAIN-COMPOSE-SERVICES-END
volumes:
service-data:
state-data:
# DOMAIN-COMPOSE-VOLUME-NAMES-START — named volumes for the mounts above; kept across copier update
# DOMAIN-COMPOSE-VOLUME-NAMES-END
Upgrading the image¶
compose.yml names a rolling tag, so a new release is adopted by pulling and recreating:
docker compose pull
docker compose up -d
docker compose restart does not pick up a new image any more than it picks up an edited .env: a restarted container keeps the image it was created from. State lives in the volumes, so recreating the container keeps it; what a release changes for your clients and your data is on the Upgrade page.
Where configuration goes¶
The split matters, because two files can set the same variable:
.envholds the server's configuration.compose.ymlreads it withenv_file:..env.exampleis generated from the server's own config surface and lists every variable with its default and a one-line description, so it is both the checklist and the place to edit. Configuration carries the full reference.compose.yml'senvironment:block holds only what the deployment itself determines, such as paths on the volumes the file mounts. The template's own entry isFASTMCP_HOME, which points at the state volume. This project may add more in itsDOMAIN-COMPOSE-ENVIRONMENTblock (The service shows the block as shipped). Values here override.env, so a knob set in both places takes the value fromcompose.yml, which is rarely what an operator editing.envexpects.
The env_file: entry is marked required: false, so a checkout with no .env still starts on defaults. That form needs Compose 2.24.0 or newer; on an older engine, either upgrade or replace the entry with plain env_file: .env and make sure the file exists.
Ports¶
The image pins its own listener: CMD passes --host 0.0.0.0 --port 8000, so MARKDOWN_VAULT_MCP_HOST and MARKDOWN_VAULT_MCP_PORT in a .env do not move it. To serve on a different host port, change the host port in the mapping ("127.0.0.1:9000:8000") rather than the server's port.
compose.yml publishes on 127.0.0.1 because the quick start runs without authentication. The bind address limits which machines can connect; it does not replace authentication. To serve other machines, first configure a bearer token or OIDC (Authentication), then either put a reverse proxy in front (Behind a reverse proxy) or publish on every interface from compose.override.yml:
services:
markdown-vault-mcp:
ports: !override
- "8000:8000"
!override replaces the mapping instead of appending to it. The override file leaves compose.yml unedited, so a template update does not conflict with the change.
Domain content and copier update¶
Four sentinel blocks mark the parts of compose.yml a project owns. Content inside them survives a template update; content outside them does not, and will conflict.
| Block | For |
|---|---|
DOMAIN-COMPOSE-VOLUMES |
Extra mounts on the service |
DOMAIN-COMPOSE-ENVIRONMENT |
Extra environment this file determines |
DOMAIN-COMPOSE-SERVICES |
Sidecars, such as a task backend or a cache |
DOMAIN-COMPOSE-VOLUME-NAMES |
Top-level declarations for any named volume added above |
A named volume needs an entry in two of those: the mount in DOMAIN-COMPOSE-VOLUMES, and its declaration in DOMAIN-COMPOSE-VOLUME-NAMES. A bind mount needs only the first.
Behind a reverse proxy¶
A compose.override.yml that puts the container on the proxy's network, and the routing a path prefix needs, are on Reverse proxy. Set MARKDOWN_VAULT_MCP_BASE_URL to the public URL there; MARKDOWN_VAULT_MCP_HOST is the interface the server binds to, not the name the proxy routes.
Building the image yourself¶
compose.yml pulls a published image rather than building one, so docker compose up -d never rebuilds from a stale checkout. To run your own build, build and tag it first:
docker build -t ghcr.io/pvliesdonk/markdown-vault-mcp:dev .
then point the image: line at that tag.
Health¶
The server serves two unauthenticated routes for an orchestrator to probe. They sit outside the MCP mount and outside auth, so they answer normally while the MCP endpoint still answers 401.
| Route | Question | Answer |
|---|---|---|
/health |
Is the process serving? | Static 200 for as long as it does. A failure means restart it. |
/health/ready |
Can it do its job? | Runs every readiness check and answers 503 if any fails. A failure means take it out of rotation. |
compose.yml probes /health, so docker compose ps reports healthy once the server answers and docker compose up --wait returns. The image carries the same probe as its HEALTHCHECK, so a bare docker run reports health too. Compose only reports the verdict: it gates depends_on: condition: service_healthy on it but restarts nothing. An orchestrator that does act on liveness, such as Swarm or a Kubernetes livenessProbe, restarts the container, so the probe is deliberately not /health/ready. A restart does not fix an unreachable backing store, and a readiness verdict here would hold up every dependent service for one. Point a load balancer or a Kubernetes readinessProbe at /health/ready instead.
Readiness ships with one check, kv_store, which writes a short-lived key so a state volume that has silently vanished is detected. A project adds its own checks, such as whether an upstream API key is still valid, in the health_checks dict beside the DOMAIN-WIRING block in src/markdown_vault_mcp/server.py.
The paths follow the mount. The server strips a conventional trailing mcp segment from MARKDOWN_VAULT_MCP_HTTP_PATH, so the default /mcp publishes /health and a mount at /markdown-vault-mcp/mcp publishes /markdown-vault-mcp/health. The shipped probe assumes the default, so a .env that changes the mount path must move the probe with it.
Check the readiness verdict through the published port; curl prints the body on a 503 as well, which is where the failing check is named:
curl -s http://localhost:8000/health/ready
MARKDOWN_VAULT_MCP_HEALTH_DETAIL decides how much the bodies say, because anyone who can reach the port can read them. status returns the status alone. The default, standard, adds the server name and version on /health and a verdict per check on /health/ready. full adds the exception type and a redacted reason for each check that raised, and belongs only where the port is reachable from a trusted network.
Logs¶
docker logs gets one JSON object per record: a container's stderr is not a
terminal, so the server picks its JSON renderer with nothing configured. Each
line carries ts, level and logger, then the event name and its fields
for a request or a tool call, and Docker adds its own timestamp on
docker logs -t. Every logger in the process renders this way, FastMCP's and
uvicorn's included.
The image sets no log format, so .env or the compose environment: block
decides. To read the stream in colour instead, for a person rather than a
collector:
MARKDOWN_VAULT_MCP_LOG_FORMAT=rich
Records then render one event key=value line each. The packaged Debian and
RPM installs behave the same way under journalctl: the systemd unit sets
nothing, and /etc/markdown-vault-mcp/env carries the choice.
MARKDOWN_VAULT_MCP_LOG_LEVEL sets how much is logged; see Configuration.
Image tags¶
| Tag | Contents | Updated by |
|---|---|---|
latest |
Newest stable release | Each stable release that is newest across all series |
vX.Y.Z |
That exact release (pre-releases included, as vX.Y.Z-rc.N) |
Never (immutable) |
vX.Y, vX |
Newest stable release in that series | Each stable release that is newest in its series |
rc |
Newest release candidate | Each pre-release still ahead of latest |
edge |
Newest commit on main |
Every merge to main |
Rolling tags are ordering-aware: a patch release cut from an old release/X.Y branch after a newer stable has shipped updates its own series tags but never latest. The same rule governs rc: a candidate only moves the tag while its version is still ahead of the newest stable, so a candidate for an already-released version never pulls rc behind latest.
The three rolling tags answer different questions. Use latest to run released code, rc to test the candidate for the next release, and edge to run the newest merged commit. Note that rc is not cleared when its release ships: it keeps pointing at the last candidate until the next one is cut, so latest is the tag to follow in production. To find the commit behind an edge image, read its org.opencontainers.image.revision label:
docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
ghcr.io/pvliesdonk/markdown-vault-mcp:edge
Environment variables¶
| Variable | Default | Description |
|---|---|---|
MARKDOWN_VAULT_MCP_BEARER_TOKEN |
n/a | Enable bearer token auth |
MARKDOWN_VAULT_MCP_LOG_LEVEL |
INFO |
Log level (DEBUG / INFO / WARNING / ERROR / CRITICAL) |
MARKDOWN_VAULT_MCP_LOG_FORMAT |
unset: json in a container |
rich or json; unset picks by whether stderr is a terminal (see Logs) |
MARKDOWN_VAULT_MCP_INSTANCE_DESCRIPTION |
n/a | Routing context that distinguishes this deployment |
MARKDOWN_VAULT_MCP_INSTRUCTIONS_EXTRA |
n/a | Deployment-specific behavioral policy added to the generated MCP instructions |
MARKDOWN_VAULT_MCP_INSTRUCTIONS |
(computed at startup) | Legacy full replacement of the generated instructions (deprecated) |
MARKDOWN_VAULT_MCP_DEBUG_PORT |
n/a | Remote-debugger TCP port (see Remote debugging; requires --build-arg DEBUG=true image) |
MARKDOWN_VAULT_MCP_DEBUG_WAIT |
false |
Block startup until IDE attaches (see Remote debugging) |
For OIDC auth variables, see Authentication.
Running behind a reverse proxy on a path prefix (https://mcp.example.com/myservice/mcp) rather than its own hostname needs two routing rules, one of which sits outside the prefix: see A path prefix.
Volumes¶
| Path | Purpose |
|---|---|
/data/service |
For a server that keeps files of its own (bind-mount or named volume); one that stores nothing here leaves it empty |
/data/state |
State files (FastMCP OIDC state, etc.) |
UID/GID¶
Set PUID and PGID in your .env file to match the owner of bind-mounted
directories (default 1000/1000).
Remote debugging¶
Production images ship without debugpy to keep the image lean. To attach a remote Python debugger from VS Code or PyCharm:
-
Build with the debug extra:
docker build --build-arg DEBUG=true -t markdown-vault-mcp:debug .This installs the
[debug]optional-dependency group (which pullsdebugpytransitively fromfastmcp-pvl-core). Default builds (DEBUG=false) skip it. -
Run with the debug env vars set and the port mapped:
docker run --rm \ -e MARKDOWN_VAULT_MCP_DEBUG_PORT=5678 \ -e MARKDOWN_VAULT_MCP_DEBUG_WAIT=true \ -p 127.0.0.1:5678:5678 \ -p 127.0.0.1:8000:8000 \ markdown-vault-mcp:debugEnv var Effect MARKDOWN_VAULT_MCP_DEBUG_PORTTCP port the debugger listens on (any value parsing to 0disables; non-numeric or out-of-range values log a WARNING and the listener stays off)MARKDOWN_VAULT_MCP_DEBUG_WAITWhen truthy ( 1/true/yes/on), block startup until the IDE attaches. Default is non-blocking. -
Attach from VS Code, adding a launch config:
{ "name": "Attach to markdown-vault-mcp", "type": "debugpy", "request": "attach", "connect": { "host": "localhost", "port": 5678 } }PyCharm uses Run → Edit Configurations → Python Debug Server with the same host/port.
Never publish the debug port on a public network
The debug listener binds 0.0.0.0 inside the container so the IDE can reach it from the host, but debugpy's DAP protocol is unauthenticated: any peer that can reach the port has arbitrary code execution as the server process. Always bind the port mapping to localhost (-p 127.0.0.1:5678:5678) or tunnel via kubectl port-forward / SSH. Production images should be built with default DEBUG=false.
When the helper is invoked but debugpy isn't installed (say, someone sets DEBUG_PORT on a non-debug image), it logs a WARNING and continues; this is the safe failure mode.
The vault¶
Set MARKDOWN_VAULT_MCP_SOURCE_DIR in .env to the vault's path on the host. This server's compose.yml mounts that path at /data/vault and tells the server inside to use /data/vault, so the one variable is a host path to Compose and a container path to the server. Left unset, as .env.example ships it, Compose mounts ./vault beside compose.yml: an empty vault, enough for a first try. The files under examples/ carry a host path; replace it with your own.
The write tools are on by default; MARKDOWN_VAULT_MCP_READ_ONLY=true makes a search-only server. The server keeps its own state on the state-data volume, so the vault mount can be read-only.
What /data/state holds¶
This server keeps its search index (index.db), its embeddings and the FastEmbed model cache under /data/state, beside the server's own state. It writes nothing to /data/service. The notes live only in the vault: the index and embeddings under /data/state are rebuilt when lost, at the cost of a full reindex.
Git¶
The image includes git and git-lfs. For a vault that commits and pushes the changes made through the server, set the variables in Git integration. An empty vault folder plus MARKDOWN_VAULT_MCP_GIT_REPO_URL is cloned at the first start.