HTTP and Docker deployment
How to run OpenZIM MCP as a long-running networked service. Covers the streamable HTTP transport, the published Docker image, operational concerns (TLS, reverse proxy, health probes, systemd), and end-to-end deployment recipes for LAN, Tailscale tailnet, and public VPS topologies.
Notation: examples on this page use JSON-RPC tool-call framing (
{"name": "...", "arguments": {...}}) for protocol-level snippets and shell snippets for environment / HTTP commands. Tool surfaces referenced match the 8-tool advanced surface.
Source of truth: openzim_mcp/http_app.py and the Dockerfile.
If you only want to use OpenZIM MCP locally with Claude Desktop, Cursor, or another MCP client launching it as a subprocess, stay on the Quick start — that path uses stdio transport and doesn’t need any of this.
When to use HTTP vs stdio
| Transport | Use case |
|---|---|
stdio (default) | Local desktop MCP hosts (Claude Desktop, Inspector, MCP-aware editors). The host owns the process lifetime. |
http (streamable HTTP) | Long-running service. Multiple clients, possibly across the network. Bearer-token auth, CORS, health probes. |
sse (legacy) | Older clients that haven’t migrated to streamable HTTP. Loopback only — no auth middleware. |
The rest of this page assumes --transport http.
Quick start (no Docker)
export OPENZIM_MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
openzim-mcp --transport http --host 127.0.0.1 --port 8000 /srv/zim
This binds the loopback interface only. Put a TLS-terminating reverse proxy in front for external access. The server refuses to bind a non-loopback host without an auth token as a safe default.
Quick start (Docker)
docker run --rm -p 8000:8000 \
-v /srv/zim:/data:ro \
-e OPENZIM_MCP_AUTH_TOKEN="$(openssl rand -hex 32)" \
ghcr.io/cameronrye/openzim-mcp:2.1.1
The published image:
- Multi-arch:
linux/amd64,linux/arm64 - Non-root: runs as
appuser(uid 10001, gid 10001) - Built-in healthcheck:
curl -fsS http://localhost:8000/readyz - Default env:
OPENZIM_MCP_TRANSPORT=http,OPENZIM_MCP_HOST=0.0.0.0,OPENZIM_MCP_PORT=8000 - Entrypoint:
python -m openzim_mcp /data(mount your ZIM directory at/data)
The image binds 0.0.0.0 by default; the safe-default check requires OPENZIM_MCP_AUTH_TOKEN for that bind. To run without a token, override -e OPENZIM_MCP_HOST=127.0.0.1.
Authentication
Bearer-token auth via BearerTokenAuthMiddleware:
- Set
OPENZIM_MCP_AUTH_TOKEN(env only — never put it in a config file). - Stored as a pydantic
SecretStr— value never appears inrepr(), logs, or the configuration view ofzim_health. - Comparison is timing-safe (
hmac.compare_digest). - The attempted token is never logged.
/healthzand/readyzare exempt.OPTIONS /mcpis not exempt — the deliberately-closed preflight bypass means OPTIONS requests must also carry a validAuthorization: Bearer ....
Client request format:
POST /mcp HTTP/1.1
Host: openzim-mcp.example
Authorization: Bearer <YOUR_TOKEN>
Content-Type: application/json
Mcp-Session-Id: <session-id-if-resuming>
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
Failures return 401 {"error":"unauthorized"} with WWW-Authenticate: Bearer.
Rotate by setting a new value and restarting the container; there is no in-flight rotation.
CORS
For browser clients, set OPENZIM_MCP_CORS_ORIGINS to an explicit JSON list:
export OPENZIM_MCP_CORS_ORIGINS='["https://app.example.com","https://other.example.com"]'
- Wildcard
"*"is rejected at startup — including whitespace-padded variants like" * ". There is no opt-out. Wildcard CORS combined with bearer-token auth is the canonical recipe for token theft via a malicious site. Mcp-Session-Idis inallow_headersandexpose_headersso streamable-HTTP clients can resume sessions across CORS preflight.- CORS is the outer middleware layer (LIFO add order) so a 401 from auth still carries
Access-Control-Allow-Originheaders — browsers see “401 unauthorized” instead of an opaque CORS error. - Allowed methods:
GET,POST,OPTIONS.
If no browser client connects (you’re hitting the endpoint from another server, a CLI client, or a desktop MCP client), leave this unset.
Public hostname allow-list
Required when openzim-mcp sits behind a reverse proxy (Caddy, nginx) or Tailscale serve and the public hostname differs from the bind interface. The MCP SDK applies DNS rebinding protection by validating the Host header against an allow-list — loopback values (127.0.0.1, localhost, [::1]) are always permitted, but anything else needs to be listed explicitly:
export OPENZIM_MCP_ALLOWED_HOSTS='["mcp.example.com"]'
Symptom of a missing entry: requests proxied to openzim-mcp return 421 Misdirected Request with body Invalid Host header, and the server log shows Invalid Host header: <hostname>.
Entries can include the :* wildcard-port suffix (mcp.example.com:*) when the proxy preserves a non-default port in the Host header. The wildcard * alone is rejected at startup — the whole point of the allow-list is DNS rebinding protection.
Safe-default startup matrix
check_safe_startup() refuses to start in two cases:
| Transport | Host | Token | Result |
|---|---|---|---|
http | loopback (127.0.0.1/::1/resolved localhost) | unset | OK |
http | loopback | set | OK |
http | non-loopback | unset | REFUSE |
http | non-loopback | set | OK |
sse | loopback | (any) | OK |
sse | non-loopback | (any) | REFUSE (no auth middleware in SSE path) |
If host=localhost and /etc/hosts maps localhost away from 127.0.0.1, the server emits a UserWarning and treats the host as public — which then triggers the safe-default refusal.
Health endpoints
| Endpoint | Purpose | Auth | Response |
|---|---|---|---|
/healthz | Liveness — process is up, event loop responsive | exempt | 200 {"status":"ok"} |
/readyz | Readiness — at least one allowed dir is readable | exempt | 200 {"status":"ready"} or 503 {"status":"not_ready","reason":"no readable allowed directories"} |
Both endpoints are CORS-friendly and safe to wire into Kubernetes probes, Docker HEALTHCHECK, systemd WatchdogSec, or external uptime monitors.
curl -fsS http://localhost:8000/healthz
# {"status":"ok"}
curl -fsS http://localhost:8000/readyz
# {"status":"ready"}
Resource subscriptions over HTTP
The polling watcher (MtimeWatcher) is started via a wrapped lifespan handler so it works under streamable HTTP (FastMCP supplies its own lifespan; add_event_handler('startup', …) is silently a no-op).
OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=falseskips the watcher entirely.OPENZIM_MCP_WATCH_INTERVAL_SECONDS(default 5, range 1–60) controls poll cadence.- A subscriber that doesn’t respond within 5 seconds is treated as dead and evicted from the registry — the fan-out is concurrent, so one hung subscriber doesn’t delay others.
See Resources, prompts and subscriptions for client-side examples.
Reverse proxy / TLS
There is no built-in TLS. Terminate at a reverse proxy.
Caddy
openzim-mcp.example.com {
reverse_proxy 127.0.0.1:8000
}
Caddy auto-provisions Let’s Encrypt certs and forwards Authorization, Mcp-Session-Id, and Content-Type by default.
nginx
server {
listen 443 ssl http2;
server_name openzim-mcp.example.com;
ssl_certificate /etc/letsencrypt/live/openzim-mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openzim-mcp.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Mcp-Session-Id;
proxy_buffering off; # Streamable HTTP is long-poll friendly
proxy_read_timeout 300s;
}
}
nginx will not auto-provision certs, so pair it with Certbot or a similar tool.
Traefik
http:
routers:
openzim-mcp:
rule: "Host(`openzim-mcp.example.com`)"
entryPoints: [websecure]
tls:
certResolver: letsencrypt
service: openzim-mcp
services:
openzim-mcp:
loadBalancer:
servers:
- url: "http://127.0.0.1:8000"
systemd unit
[Unit]
Description=OpenZIM MCP
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=openzim-mcp
Group=openzim-mcp
Environment=OPENZIM_MCP_TRANSPORT=http
Environment=OPENZIM_MCP_HOST=127.0.0.1
Environment=OPENZIM_MCP_PORT=8000
Environment=OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=true
EnvironmentFile=/etc/openzim-mcp/secrets.env # OPENZIM_MCP_AUTH_TOKEN, etc.
ExecStart=/usr/local/bin/openzim-mcp /srv/zim
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadOnlyPaths=/srv/zim
ReadWritePaths=/var/cache/openzim-mcp
[Install]
WantedBy=multi-user.target
Kubernetes example
apiVersion: apps/v1
kind: Deployment
metadata:
name: openzim-mcp
spec:
replicas: 2
selector:
matchLabels: { app: openzim-mcp }
template:
metadata:
labels: { app: openzim-mcp }
spec:
containers:
- name: openzim-mcp
image: ghcr.io/cameronrye/openzim-mcp:2.1.1
ports:
- containerPort: 8000
env:
- name: OPENZIM_MCP_HOST
value: "0.0.0.0"
- name: OPENZIM_MCP_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: openzim-mcp-auth
key: token
- name: OPENZIM_MCP_CACHE__MAX_SIZE
value: "500"
volumeMounts:
- name: zim-data
mountPath: /data
readOnly: true
livenessProbe:
httpGet: { path: /healthz, port: 8000 }
periodSeconds: 30
readinessProbe:
httpGet: { path: /readyz, port: 8000 }
periodSeconds: 10
resources:
requests: { memory: "256Mi", cpu: "100m" }
limits: { memory: "1Gi", cpu: "1000m" }
volumes:
- name: zim-data
persistentVolumeClaim:
claimName: zim-archives
Each replica has its own in-memory cache. Persistent cache on shared storage is not recommended unless you handle concurrent-writer issues at the storage layer (the on-disk cache uses simple file rewrites).
Hardening checklist
- Bind to a specific interface, not
0.0.0.0, unless behind a reverse proxy that already restricts ingress. - Set
OPENZIM_MCP_AUTH_TOKENto a high-entropy value (openssl rand -hex 32). - Set
OPENZIM_MCP_CORS_ORIGINSto the explicit list of allowed origins (never*). - Terminate TLS at a reverse proxy.
- Run as a non-root user (Docker image already does this).
- Mount ZIM directories read-only.
- Tune
OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECONDfor your client load. - Wire
/healthzand/readyzinto your platform’s health-check tooling. - Subscribe alerting to repo Security Advisories.
For a full security review of the model behind these recommendations, see Security best practices.
Troubleshooting
Common failure modes:
- Container exits immediately on start. Almost always one of three startup checks: HTTP transport bound to a non-loopback host without
OPENZIM_MCP_AUTH_TOKEN, orOPENZIM_MCP_CORS_ORIGINS/OPENZIM_MCP_ALLOWED_HOSTSset to a value containing*. The startup error message identifies which. - Clients get 421 Misdirected Request /
Invalid Host header. The proxiedHostheader (e.g.zim.example.com) isn’t inOPENZIM_MCP_ALLOWED_HOSTS. Add it. Loopback (127.0.0.1,localhost,[::1]) is always allowed without configuration; everything else needs an explicit entry. - Clients get 401 Unauthorized. Token mismatch. Verify with
curl -H "Authorization: Bearer $TOKEN" http://host/mcp/.... Don’t paste the token into chat or tickets. - Browser clients get 403 / CORS errors. Either
OPENZIM_MCP_CORS_ORIGINSis unset (and a browser is calling) or the client’s origin isn’t in the allow-list. The browser console shows the offending origin; add it. /readyzreturns 503. None of the configured ZIM directories are readable from inside the container. (With multiple allowed directories, one bad mount is fine — readiness flips only when all of them fail.) Check the volume mount path (host side and/dataside) and that the mount isn’t empty. Permissions: the in-container user is UID 10001; the host directory needs to be world-readable or owned by UID 10001.- Subscriptions stop firing. Confirm
OPENZIM_MCP_SUBSCRIPTIONS_ENABLEDisn’t set tofalse. If a client claims it subscribed but never gets updates, check that the subscribe call actually returned a successful response — clients silently treating a failed subscription as success is a known footgun.
Deployment patterns
The reference above is the surface area. The recipes below are end-to-end deployments for the three topologies most operators land on: LAN host, Tailscale tailnet, and public VPS with TLS.
Recipe 1: Docker Compose on a LAN host
This recipe puts OpenZIM MCP on a single host, reachable from the rest of your LAN over plain HTTP plus a bearer token. There’s no TLS — the bearer token is the only thing protecting the endpoint, so this recipe is appropriate for trusted networks (a home LAN, a Tailscale tailnet) and not for anything reachable from the public internet.
Prerequisites
- Docker and Docker Compose v2 installed on the host
- A directory of
.zimfiles (download from the Kiwix Library) - A generated bearer token:
openssl rand -hex 32
docker-compose.yml
services:
openzim-mcp:
image: ghcr.io/cameronrye/openzim-mcp:2.1.1
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000"
volumes:
- /srv/zim:/data:ro
- openzim-cache:/home/appuser/.cache/openzim-mcp
environment:
OPENZIM_MCP_AUTH_TOKEN: "${OPENZIM_MCP_AUTH_TOKEN}"
OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED: "true"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/readyz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
openzim-cache:
Still running v1.x? The
ghcr.io/cameronrye/openzim-mcp:1.2.0image stays available; v1.x is in maintenance mode (security + data-corruption + pre-v2.0.0 crash fixes accepted; no new features) until the FIRST of{v2.5.0 ships, 2026-11-27}. The v2.0.0 advanced tool surface is a breaking rename per CHANGELOG — migrate when convenient.
What’s intentional here:
- Pinned tag (
:2.1.1, not:latest). Upgrades are deliberate —docker compose pullis a thing you do, not a thing that happens to you. - Host port bound to
127.0.0.1. The container listens on all interfaces (the image’s defaultOPENZIM_MCP_HOST=0.0.0.0), but Docker’s port publish restricts external exposure to loopback. The Tailscale variant below lifts this restriction by binding to a specific interface. - Read-only ZIM mount (
:ro). The server only reads ZIM files; mounting read-only ensures a server compromise can’t damage them. - Token via env, not hard-coded. Put it in a
.envfile next to the compose file (and add.envto.gitignore). The image’s defaults already setTRANSPORT=http,HOST=0.0.0.0,PORT=8000, so they’re not repeated here. - Explicit healthcheck block. The image already declares
HEALTHCHECKin the Dockerfile, but compose needs an explicit block to surface status todocker ps.
/srv/zim is an example. Adjust to wherever your ZIM files live.
Start it
echo "OPENZIM_MCP_AUTH_TOKEN=$(openssl rand -hex 32)" > .env
docker compose up -d
Verify
# Liveness
curl -sf http://127.0.0.1:8000/healthz && echo " healthz ok"
# Readiness (will fail if /srv/zim is empty or unreadable)
curl -sf http://127.0.0.1:8000/readyz && echo " readyz ok"
# Authed RPC call: list available tools
TOKEN=$(grep OPENZIM_MCP_AUTH_TOKEN .env | cut -d= -f2)
curl -sf -X POST http://127.0.0.1:8000/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| head -c 500
If tools/list returns a JSON envelope with a result.tools array, you’re up.
Connect a client
Add OpenZIM MCP to your client’s MCP config. Substitute the host, port, and token. Cursor supports remote HTTP MCP servers natively; Claude Desktop currently does not — it needs the mcp-remote bridge, which translates Claude Desktop’s stdio expectations into HTTP calls.
Cursor (~/.cursor/mcp.json for global, or .cursor/mcp.json for project-local):
{
"mcpServers": {
"openzim-mcp": {
"url": "http://127.0.0.1:8000/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows). Requires Node.js on the client host (mcp-remote runs via npx):
{
"mcpServers": {
"openzim-mcp": {
"command": "npx",
"args": [
"-y",
"mcp-remote@^0.1.16",
"http://127.0.0.1:8000/mcp",
"--header",
"Authorization:Bearer YOUR_TOKEN_HERE"
]
}
}
}
The Authorization:Bearer form (no space after the colon) matches mcp-remote’s documented header syntax. The token lives directly in the config file and is visible in ps output on the client machine while mcp-remote is running — treat the file as a secret: don’t commit claude_desktop_config.json to a shared repo and keep its filesystem permissions tight (chmod 600 on Unix).
Tailscale variant
To reach the server from your tailnet instead of the LAN, change two lines in docker-compose.yml:
ports:
- "100.x.y.z:8000:8000" # your Tailscale IPv4, from `tailscale ip -4`
environment:
OPENZIM_MCP_AUTH_TOKEN: "${OPENZIM_MCP_AUTH_TOKEN}"
OPENZIM_MCP_ALLOWED_HOSTS: '["<device>.<tailnet>.ts.net"]'
Then make sure the host firewall blocks port 8000 on the public interface (it should already, since you’re not publishing on 0.0.0.0). The bearer token plus tailnet ACLs are your trust boundary; you don’t need TLS because the tailnet itself is encrypted.
OPENZIM_MCP_ALLOWED_HOSTS is required because Tailscale serve and similar reverse proxies preserve the original Host header (the MagicDNS name), which the SDK’s default DNS-rebinding allow-list rejects. List the MagicDNS hostname your clients connect to. If you connect by raw Tailscale IP rather than MagicDNS, add 100.x.y.z to the list instead.
Recipe 2: VPS with Caddy and automatic TLS
This recipe puts OpenZIM MCP on a public VPS, fronted by Caddy for automatic Let’s Encrypt TLS. The bearer token is still the auth boundary; TLS prevents a network observer from stealing it in transit.
Prerequisites
- A VPS with a public IPv4 (and optionally IPv6)
- A domain name with an A record (and optional AAAA) pointing at the VPS
- Ports 80 and 443 open on the VPS firewall (Caddy needs both for the HTTP-01 ACME challenge and TLS service)
- Same bearer token and ZIM directory as Recipe 1
docker-compose.yml
This is the LAN compose file with two changes: a caddy service is added, and the openzim-mcp service no longer publishes a host port (it’s reachable only on the internal Docker network).
services:
openzim-mcp:
image: ghcr.io/cameronrye/openzim-mcp:2.1.1
restart: unless-stopped
expose:
- "8000"
volumes:
- /srv/zim:/data:ro
environment:
OPENZIM_MCP_AUTH_TOKEN: "${OPENZIM_MCP_AUTH_TOKEN}"
OPENZIM_MCP_ALLOWED_HOSTS: '["zim.example.com"]'
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- openzim-mcp
volumes:
caddy_data:
caddy_config:
Caddyfile
zim.example.com {
reverse_proxy openzim-mcp:8000
}
That’s it. Caddy provisions a certificate on first request (and renews automatically), terminates TLS, and forwards the Authorization header to OpenZIM MCP unchanged. Replace zim.example.com with your hostname.
nginx alternative: if you already run nginx and prefer it to Caddy, the only requirements are reverse-proxying
/toopenzim-mcp:8000and forwarding theAuthorizationheader unchanged. See the nginx snippet above; pair it with Certbot or a similar tool for certificate provisioning.
Start it
echo "OPENZIM_MCP_AUTH_TOKEN=$(openssl rand -hex 32)" > .env
docker compose up -d
# Watch Caddy obtain its certificate (first start only)
docker compose logs -f caddy
The first request triggers ACME issuance; subsequent restarts reuse the cached cert from the caddy_data volume.
Verify
# TLS chain
curl -vsf https://zim.example.com/healthz 2>&1 | grep -E "subject|issuer|HTTP/"
# Authed RPC call
TOKEN=$(grep OPENZIM_MCP_AUTH_TOKEN .env | cut -d= -f2)
curl -sf -X POST https://zim.example.com/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| head -c 500
Production hardening checklist (per-recipe)
- Token rotation. Generate a new token, update
.env, rundocker compose up -dto pick up the change. Distribute the new token to clients out-of-band. - CORS. Set
OPENZIM_MCP_CORS_ORIGINSto the exact origins of any browser clients that connect directly. Skip if no browser clients connect. - Non-root. The published image already runs as
appuser(UID 10001). Confirm withdocker compose exec openzim-mcp id— output should beuid=10001(appuser). - Log review.
docker compose logs -f openzim-mcpshows auth failures (the attempted token is not logged, but the request and outcome are). A burst of 401s from one client usually means a stale token. - Read-only ZIM mount. Already in the compose (
:ro). Don’t remove it.
Operations
Upgrades
# Pull the new image, then recreate containers using it
docker compose pull
docker compose up -d
The image tag in docker-compose.yml is pinned (:2.1.1) — change it to the new version before pulling. Pinning is deliberate: :latest makes upgrades a surprise, and the release notes for each tag tell you when an upgrade involves a breaking change. The v1.x → v2.0.0 jump is a breaking rename of the advanced-mode tool surface (22 tools → 8); see the CHANGELOG before bumping.
Watching logs
docker compose logs -f openzim-mcp
A clean startup logs the bind host/port and the configured allowed directory. An auth failure logs the route, status, and client IP — not the attempted token. A subscription update logs the watched path and the change type (created / replaced / removed).
Scaling considerations
Each replica has its own in-memory cache; persistent cache on shared storage isn’t recommended (see the Kubernetes section above). Horizontal scaling is straightforward — add replicas, front them with any L7 load balancer that supports HTTP/1.1 keep-alive and forwards Authorization + Mcp-Session-Id unchanged. Session-stickiness isn’t required: every MCP request carries the session id explicitly and the server is otherwise stateless across requests.
Configuration reference: Configuration. Security model: Security best practices. Performance tuning: Performance optimization.
v1.x is in maintenance through 2026-11-27. See CHANGELOG for the v1 → v2 migration table.