Docker Deployment¶
Quick Start¶
# Pull the image
docker pull ghcr.io/pvliesdonk/markdown-vault-mcp:latest
# Copy an example env file
cp examples/obsidian-readonly.env .env
# Edit .env — set MARKDOWN_VAULT_MCP_SOURCE_DIR to the vault path on the host
# Then start the service
docker compose up -d
# Check it's running
curl http://localhost:8000/health
Docker Compose Configuration¶
The compose.yml defines a single service:
services:
markdown-vault-mcp:
image: ghcr.io/pvliesdonk/markdown-vault-mcp:latest
build: .
env_file: .env
volumes:
- ${MARKDOWN_VAULT_MCP_SOURCE_DIR:?Set MARKDOWN_VAULT_MCP_SOURCE_DIR}:/data/vault
- state-data:/data/state
environment:
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
FASTMCP_HOME: /data/state/fastmcp
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.markdown-vault-mcp.rule=Host(`${MARKDOWN_VAULT_MCP_HOST:-markdown-vault-mcp.local}`)"
- "traefik.http.services.markdown-vault-mcp.loadbalancer.server.port=8000"
volumes:
state-data:
Volume Mounts¶
| Container Path | Type | Purpose |
|---|---|---|
/data/vault |
Bind mount or named volume | Your Markdown vault; pre-created in the image for managed repo mode |
/data/state |
Named volume | All server-managed internal state: SQLite FTS index, embedding vectors, FastEmbed model cache, and OIDC proxy state |
All /data/* directories are pre-created and owned by the runtime user in the image. For managed repo mode (where the server clones a git repo on first start), /data/vault must be writable — this works automatically with named volumes or when UID/GID match the bind-mount owner. The first startup triggers a full index build; subsequent starts only reindex changed files.
Upgrading from v1.8.x
Versions before v1.9.0 used three separate state volumes (index-data, embeddings-data, fastembed-data). These have been consolidated into a single state-data volume mounted at /data/state. Existing state is not migrated automatically — the index and embeddings will be rebuilt on first startup (the index rebuild is incremental; the embeddings rebuild may take several minutes for large collections). The FastEmbed model cache will be re-downloaded (~100 MB). To avoid the rebuild, copy data from the old volumes into state-data before starting the new container.
Traefik Reverse Proxy¶
The compose.yml includes Traefik labels out of the box. When Traefik is running and watching Docker, it picks up these labels and routes traffic automatically.
What the labels do:
traefik.enable=true— opt this service in to Traefik discoverytraefik.http.routers.markdown-vault-mcp.rule— defines theHostrule; defaults tomarkdown-vault-mcp.localtraefik.http.services.markdown-vault-mcp.loadbalancer.server.port— tells Traefik the container listens on port 8000
Prerequisites¶
- Traefik running in Docker with the Docker provider enabled
-
Both Traefik and this service on the same Docker network:
-
A DNS entry (or
/etc/hostsline) resolving the hostname to your host
Custom Hostname¶
Set MARKDOWN_VAULT_MCP_HOST in your .env:
Mounting Under a Subpath¶
To serve MCP at https://mcp.example.com/vault/mcp, set:
And use a path-aware Traefik rule:
labels:
- "traefik.http.routers.markdown-vault-mcp.rule=Host(`mcp.example.com`) && PathPrefix(`/vault/mcp`)"
- "traefik.http.services.markdown-vault-mcp.loadbalancer.server.port=8000"
OIDC subpath deployments use a different pattern
When OIDC is enabled, do not include the subpath in HTTP_PATH. Instead, put the subpath in BASE_URL and configure the reverse proxy to strip the prefix. See the OIDC subpath deployment guide for details.
TLS with Let's Encrypt¶
Add a certificatesResolvers block to your Traefik static config and these labels to the service:
- "traefik.http.routers.markdown-vault-mcp.tls.certresolver=letsencrypt"
- "traefik.http.routers.markdown-vault-mcp.entrypoints=websecure"
See the Traefik ACME documentation for the full setup.
Git-Backed Write Support¶
Git integration supports three modes:
- Managed (
GIT_REPO_URL+GIT_TOKEN): clone/pull/commit/push - Unmanaged / commit-only (no
GIT_REPO_URL, existing git repo): commit only - No-git: no git operations
Setup¶
-
For managed mode, set a remote URL and credentials:
-
For unmanaged/commit-only mode, omit
GIT_REPO_URLandGIT_TOKEN. If the vault path is a git repo, writes are committed locally only. -
The vault mount must include
.gitwhen using managed or unmanaged mode:
For managed mode, the token needs repo scope (or contents: write for fine-grained tokens).
Without auto-push
Use unmanaged/commit-only mode: omit MARKDOWN_VAULT_MCP_GIT_REPO_URL.
Writes are committed locally; run git pull/git push externally.
UID/GID Configuration¶
The container runs as a non-root appuser (UID 1000 / GID 1000 by default). On startup, the entrypoint automatically fixes ownership of all /data/* directories before dropping privileges — so named volumes work out of the box regardless of how Docker initialised them.
This is the same entrypoint + gosu pattern used by the official PostgreSQL, Redis, and MySQL Docker images.
Runtime UID/GID override¶
To match a specific host user (e.g. for bind-mounted vaults), set PUID and PGID:
The entrypoint updates appuser's UID/GID to the specified values and chowns /data accordingly.
Build-time UID/GID (alternative for bind mounts)¶
If you prefer to bake the UID/GID into the image:
Fix host permissions (bind mounts only)¶
For bind-mounted vaults where the host user doesn't match, fix host-side:
Troubleshooting¶
Traefik network not found¶
Create the network first: docker network create traefik
Git push failures¶
Check logs: docker compose logs markdown-vault-mcp
Common causes:
- Token lacks
reposcope — regenerate with the right permissions - Remote URL is SSH-based — the PAT strategy only works with HTTPS remotes. Convert:
git remote set-url origin https://github.com/user/repo.git - In unmanaged/commit-only mode, the vault directory is not a git repo — run
git initon the host first
Stale index after adding files outside the server¶
The server reindexes on startup. Restart the container:
For continuous sync, use the MCP reindex tool instead of restarting.
Ollama on Linux without Docker Desktop¶
Add extra_hosts to compose.yml for host.docker.internal to resolve: