Docker Deployment¶
Quick start¶
cp .env.example .env
docker compose up -d
The server listens on port 8000 with HTTP transport, published on the host as 8000:8000. No reverse proxy, TLS terminator, or external network is assumed.
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.
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 file itself determines. Currently that isFASTMCP_HOME, which points at the state volume the file mounts. 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 left-hand side of the mapping ("9000:8000") rather than the server's port.
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¶
Proxy configuration is deployment-specific, so compose.yml ships none. Add it in a second file rather than by editing compose.yml, which is template-owned and re-rendered: save this as compose.override.yml, which Compose loads automatically alongside compose.yml.
services:
markdown-vault-mcp:
# `!reset` drops the published port: the proxy reaches the container over
# the shared network, so nothing needs to be on the host. Plain merging
# appends to sequences, so without this the port stays published.
ports: !reset []
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.markdown-vault-mcp.rule=Host(`mcp.example.com`)"
- "traefik.http.routers.markdown-vault-mcp.tls.certresolver=letsencrypt"
- "traefik.http.services.markdown-vault-mcp.loadbalancer.server.port=8000"
networks:
traefik:
external: true
!reset needs Compose 2.24.4 or newer. On an older engine, drop that line and remove the port mapping from compose.yml directly, accepting that the edit conflicts on the next template update.
Check the result before starting anything, since a merge that silently kept the port mapping looks identical until the port clashes:
docker compose config
Substitute your own hostname for mcp.example.com. Set MARKDOWN_VAULT_MCP_BASE_URL to the public URL as well: the server needs it to advertise its own address, and it is required once OIDC is enabled. Do not reach for MARKDOWN_VAULT_MCP_HOST here. That variable is the interface the server binds to, which is not the name the proxy routes.
The network must already exist and be the one the proxy watches. For the same overlay with OIDC, see OIDC.
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¶
The image sets FASTMCP_ENABLE_RICH_LOGGING=false, so docker logs gets one line per record: a JSON object for every MCP request the logging middleware sees, LEVEL: message from the rest of FastMCP. Both grep cleanly and both survive a log collector. The server's own loggers print one line either way.
The reason is that a container has no terminal. Rich falls back to 80 columns, its time, level and source columns claim most of them, and a structured record then wraps across three space-padded lines that no reader and no parser puts back together. Rich's time column goes with the setting, and Docker timestamps every line it captures anyway, so docker logs -t prints them.
This is an image default like any other, so .env or the compose environment: block overrides it. Turning Rich back on for a human reading docker logs needs COLUMNS set as well, since that is what Rich reads in place of asking a terminal it does not have. In .env:
FASTMCP_ENABLE_RICH_LOGGING=true
COLUMNS=200
Records then render one line each, in color, padded out to the full width. The packaged Debian and RPM installs make the same trade for journalctl: the systemd unit sets FASTMCP_ENABLE_RICH_LOGGING=false, and /etc/markdown-vault-mcp/env overrides it.
FASTMCP_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 |
FASTMCP_LOG_LEVEL |
INFO |
Log level (DEBUG / INFO / WARNING / ERROR) |
FASTMCP_ENABLE_RICH_LOGGING |
false in the image |
Rich output; off means one plain or JSON line per record (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 Subpath Deployments.
Volumes¶
| Path | Purpose |
|---|---|
/data/service |
Your service data (bind-mount or named volume) |
/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 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.