Pular para o conteúdo principal

Self-hosting

Run your own server with Docker Compose: Ollama, the MCP server over HTTP, the GitHub webhook that keeps the index fresh, and optionally a Cloudflare Tunnel to publish it. This is how mcpdocs.byjg.com runs.

To work on the code without Docker, see Running locally instead.

Topology

Four containers. Ollama and the MCP server talk over the compose network; cloudflared reaches the MCP server the same way and is the only thing exposed to the internet.

Cloudflare edge reaches cloudflared through an outbound tunnel; cloudflared reaches the MCP server, which talks to Ollama. Two Docker volumes hold the model and the index; the docs checkout is temporary.

The MCP server is published on port 2954 of the host so clients on this machine or the LAN can reach it without going out to the internet and back. Ollama is not published at all.

The compose stack

docker-compose.yml defines four services:

ServiceWhat it doesRestarts
ollamaServes embeddings on :11434. Reserves the host GPU. Not published -- only mcp reaches it, over the compose network.always
ollama-initPulls nomic-embed-text into the shared volume, then exits. mcp waits for it to complete successfully.never
mcpThe MCP server. Publishes port 2954 on the host; the tunnel reaches it internally.always
cloudflaredDials out to Cloudflare and forwards the public hostname to mcp:8080. Opt-in: only starts with --profile tunnel.always

and three named volumes:

VolumeHoldsSafe to delete?
ollamaModel weights (~274 MB)Yes -- ollama-init re-pulls
indexbyjg-docs.db (24 MB)Yes -- rebuilt in ~60s
logsqueries.jsonl, the query log (at most 60 MB)Yes, but the query history is gone

There is no volume for the documentation: each refresh clones it from GitHub into a temporary directory and deletes the checkout afterwards. The query log is the only state that cannot be rebuilt, which is why it has a volume of its own and the index stays disposable.

Deploying

Configure

git clone [email protected]:byjg/mcpserver-byjg-docs.git
cd mcpserver-byjg-docs
cp .env.example .env
openssl rand -hex 32 # paste into BYJG_DOCS_AUTH_TOKEN
openssl rand -hex 32 # paste into BYJG_DOCS_WEBHOOK_SECRET
VariableValueRequired?
BYJG_DOCS_AUTH_TOKENopenssl rand -hex 32Yes -- compose refuses to start without it
BYJG_DOCS_PUBLIC_URLthe address clients actually useYes -- same reason
BYJG_DOCS_WEBHOOK_SECRETa second, different openssl rand -hex 32Only for the webhook; empty disables it
CLOUDFLARE_TUNNEL_TOKENfrom Zero Trust > Networks > TunnelsOnly with --profile tunnel

BYJG_DOCS_PUBLIC_URL is the tunnel hostname if you have one, otherwise this machine's address (e.g. http://<host-ip>:2954). Why it must match, and every other setting: Configuration.

Start

docker compose pull mcp
docker compose up -d

That starts ollama, ollama-init and mcp, using the published byjg/mcpserver-byjg-docs:latest image (see Updating). The tunnel is opt-in:

docker compose --profile tunnel up -d # adds cloudflared

Skip the profile if you run cloudflared on the host, or reach the server over the LAN only; CLOUDFLARE_TUNNEL_TOKEN can stay empty in that case.

Compose refuses to start if a required secret is unset, rather than booting something half-configured:

error while interpolating services.mcp.environment.BYJG_DOCS_AUTH_TOKEN:
required variable BYJG_DOCS_AUTH_TOKEN is missing a value: set it in .env

First boot

Nothing to initialise. The mcp container starts with an empty index volume, notices it is empty, clones byjg.github.io into a temporary directory, builds the index in the background, and serves /healthz throughout:

curl -s http://127.0.0.1:2954/healthz
# {"status":"ok","reindexing":true,"documents":327,"chunks":2361}
# {"status":"ok","reindexing":false,"documents":544,"chunks":4136}

Measured on an RTX 2000 Ada: ~90s, of which ~22s is the clone.

Startup ordering

ollama-init pulls the embedding model into the shared volume and exits; mcp waits for it to complete successfully. Without that gate the server would come up before the model existed and fail its first embedding call.

Running a subset

The services are independent enough to run partially. Without a tunnel token you can still run everything else and reach it on the host port:

docker compose up -d ollama mcp # skip cloudflared

Updating

The Build workflow publishes the image on every push to main (latest) and every tag (1.2.3), after the tests pass. To update a running stack:

docker compose pull mcp
docker compose up -d mcp

The index volume survives; the new container reuses it. To pin a release instead of following main, change image: in docker-compose.yml to a version tag.

To run local, unpublished changes, build this checkout instead:

docker compose up -d --build mcp

GPU

The ollama service reserves the host GPU. This needs nvidia-container-toolkit on the host -- the NVIDIA driver alone is not enough, because Docker needs a runtime that can pass the device through.

Verify before deploying:

docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi

If that fails with failed to discover GPU vendor from CDI, install the toolkit (NVIDIA's guide):

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --yes --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

Does it need a GPU?

No. It changes indexing cost, not whether it works:

Embedding the full corpusSingle query embedding
GPU (RTX 2000 Ada)~40s~15ms
CPU only~30min~100-150ms

(Add ~22s of clone to get the end-to-end refresh time.)

Query latency is what a user feels, and it survives CPU fine. The 45x gap only bites on a full rebuild. If you index rarely, drop the deploy.resources block from the ollama service and run on CPU.

Memory

nomic-embed-text is 137M parameters -- 595 MB resident. The index is 24 MB. The whole stack fits comfortably in 2 GB.

Cloudflare Tunnel

The tunnel dials out from the host, so no inbound port is opened and the host needs no public IP. In Zero Trust > Networks > Tunnels, create a tunnel, copy its token into CLOUDFLARE_TUNNEL_TOKEN, and map a public hostname to http://mcp:8080. Then start it with docker compose --profile tunnel up -d; without the profile the service is not created at all.

Set BYJG_DOCS_PUBLIC_URL to that hostname. MCP's auth model advertises the protected resource under this URL, so a mismatch breaks client authentication.

Network exposure

Three things are easy to confuse. They are independent:

SettingControlsValue
BYJG_DOCS_HOSTWhere the process listens inside the container0.0.0.0 -- must be, or cloudflared could not reach it
ports: in composeWhich host interface publishes the portBYJG_DOCS_BIND_ADDR, default 0.0.0.0
Cloudflare TunnelAccess from outside the networkReaches mcp:8080 over the compose network

The tunnel does not use the published port. cloudflared resolves mcp on the compose network, so the tunnel works even with ports: removed entirely. Publishing is purely for clients on this machine or the LAN.

The default publishes on 0.0.0.0, so other machines on your network connect directly by IP -- faster than going out to Cloudflare and back, and it keeps working if your internet does not:

curl -s http://<host-ip>:2954/healthz

Set BYJG_DOCS_BIND_ADDR=127.0.0.1 to restrict it to this machine.

What actually protects the port

The bearer token, and only the bearer token.

A host firewall does not. Docker publishes ports by writing iptables rules in its own chain, which is evaluated before ufw's. On a machine with ufw active and denying incoming traffic, a published port is still reachable from the LAN -- verified, not assumed. So:

  • Treat BYJG_DOCS_AUTH_TOKEN as the only barrier, and generate it with openssl rand -hex 32 rather than picking something memorable.
  • Check what can route to this host. If it has a public IP, or a forwarded port on the router, 0.0.0.0 means the internet, not just your LAN.
  • /healthz answers without a token by design, exposing only document and chunk counts. The MCP endpoint itself returns 401 without a valid token.

If you want the firewall to be meaningful here, bind to 127.0.0.1 and let the tunnel be the only way in.

Authentication

Two independent layers:

  1. Cloudflare Access (optional, at the edge) -- policies before a request ever reaches the host.
  2. Bearer token (in the app) -- StaticTokenVerifier, compared in constant time so a wrong token cannot be recovered by timing.

The server refuses to bind a non-loopback address without a token, so the second layer cannot be forgotten.

This is deliberately minimal: one pre-shared token for a read-only service. More than one consumer with per-user revocation wants real OAuth via the SDK's auth_server_provider.

Keeping the index fresh

POST /webhook/github reindexes when documentation changes. The compose stack enables it automatically, because it sets both variables the endpoint needs (BYJG_DOCS_TRANSPORT=http in the image, BYJG_DOCS_WEBHOOK_SECRET from your .env).

On byjg/byjg.github.io, open Settings > Webhooks > Add webhook (it needs admin rights on the repository) and fill in:

FieldValue
Payload URLhttps://<your-hostname>/webhook/github
Content typeapplication/json
Secretyour BYJG_DOCS_WEBHOOK_SECRET
SSL verificationEnable
EventsJust the push event

GitHub never shows the secret again after saving, so keep it wherever you keep the server's .env.

On save, GitHub sends a ping. Open the webhook's Recent Deliveries tab to see the server's answer: a 200 there already proves the whole path works -- see the table below.

What the endpoint does, in order:

  1. Verifies X-Hub-Signature-256. Absent or malformed is a rejection, never a pass. Without this, anyone could trigger reindexing.
  2. Filters by path. The site repo also holds the Docusaurus app, CI config and blog. A push touching no docs/ path is ignored -- rebuilding for a package-lock.json bump is waste.
  3. Returns 202 immediately and refreshes in a background thread: the clone plus reindex takes longer than GitHub's delivery timeout.
  4. Drops overlapping triggers. A burst of pushes must not start concurrent clones; the run already in flight picks up the newer commits.

Confirm the endpoint is registered:

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:2954/webhook/github -d '{}'
# 401 -- registered, and rejecting an unsigned request
# 404 -- not registered: BYJG_DOCS_WEBHOOK_SECRET is empty

Verify a delivery end to end:

BODY='{"commits":[{"added":[],"modified":["docs/php/micro-orm/a.md"],"removed":[]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$BYJG_DOCS_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -s -X POST http://127.0.0.1:2954/webhook/github \
-H "X-GitHub-Event: push" -H "X-Hub-Signature-256: sha256=$SIG" \
-H "Content-Type: application/json" -d "$BODY"
# {"status":"reindexing"}

Reading a delivery

Every delivery in Recent Deliveries shows the server's response:

ResponseMeaning
200 {"status": "pong"}The ping on save. Only sent after the signature checks out, so the secret matches and the endpoint is reachable
202 {"status": "reindexing"}A push touched docs/; a refresh started in the background
202 {"status": "already running"}A refresh was already in flight; this push did not start another
200 {"status": "ignored", "reason": "no docs changed"}The push touched no docs/ path -- ignored on purpose
200 {"status": "ignored", "event": "..."}An event other than push or ping
401 {"error": "invalid signature"}The webhook's secret does not match BYJG_DOCS_WEBHOOK_SECRET
404Endpoint not registered: BYJG_DOCS_WEBHOOK_SECRET is empty on the server
A Cloudflare error page or timeoutThe server or the tunnel is down

After a 202, /healthz reports "reindexing": true until the clone and reindex finish -- usually well under two minutes when few files changed.

Behind Cloudflare Access

If the hostname is protected by Cloudflare Access, GitHub's deliveries carry no Access credentials and are stopped at the edge: Recent Deliveries shows a Cloudflare redirect or 403 instead of the server's answer. Add a policy with action Bypass for the path /webhook/github -- the endpoint authenticates every request by its signature anyway.

Connecting clients

Over the network

Follow Connecting a client, with two substitutions: your BYJG_DOCS_PUBLIC_URL plus /mcp in place of https://mcpdocs.byjg.com/mcp, and your BYJG_DOCS_AUTH_TOKEN in place of <TOKEN>.

From the same machine, over stdio

A client on the host running the stack can skip the token and the network: docker exec starts a stdio server inside the running mcp container.

claude mcp add --scope user byjg-docs -- \
docker exec -i -e BYJG_DOCS_TRANSPORT=stdio mcpserver-byjg-docs-mcp-1 byjg-docs-mcp

For a client configured through a form or a JSON file:

FieldValue
Command/usr/bin/docker -- the full path, since GUI apps often lack your shell's PATH
Argumentsexec -i -e BYJG_DOCS_TRANSPORT=stdio mcpserver-byjg-docs-mcp-1 byjg-docs-mcp
  • -i keeps stdin open; the protocol runs over it. Do not add -t -- a TTY mangles the stream.
  • BYJG_DOCS_TRANSPORT=stdio overrides the http the container runs with.
  • The process shares the container's index volume and reaches Ollama at ollama:11434, so nothing is built on the host. It never triggers a reindex; the long-running HTTP server keeps the index fresh.
  • No bearer token: stdio does not pass through HTTP authentication, and anyone who can run docker exec already controls the container.
  • The stack must be up. mcpserver-byjg-docs-mcp-1 comes from the project directory name -- if you cloned into a different directory, check docker compose ps for the actual name.

To search from the terminal, run the CLI in the container:

docker exec mcpserver-byjg-docs-mcp-1 byjg-docs-index search "soft delete" -n 3

Operations

docker compose ps
docker compose logs -f mcp
curl -s http://127.0.0.1:2954/healthz
docker compose restart mcp

/healthz reports liveness, whether a reindex is running, and the current document and chunk counts -- enough to distinguish "empty index" from "still building" from "healthy".

Rebuilding from scratch

docker compose down
docker volume rm mcpserver-byjg-docs_index
docker compose up -d # notices the empty index and rebuilds it

Query log

Every tool call is appended to /data/logs/queries.jsonl in the logs volume, one JSON object per line:

{"ts": "2026-09-11T16:20:03+00:00", "tool": "search_docs", "query": "soft delete", "limit": 8, "category": null, "project": null, "hits": 8, "top_score": 0.0325, "top_vec_rank": 1, "top_bm25_rank": 2, "sources": ["php/micro-orm/softdelete.md", "..."]}
{"ts": "2026-09-11T16:20:09+00:00", "tool": "get_document", "source_path": "php/micro-orm/softdelete.md", "found": true}

Its purpose is finding what the documentation does not cover. The signals:

  • hits: 0 -- nothing matched at all (usually a category/project filter with nothing behind it).
  • A low top_score -- something came back, but nothing matched well. Scores are Reciprocal Rank Fusion values: a hit ranked first by both rankers scores about 0.033. Judge "low" from your own data rather than a fixed number.
  • top_bm25_rank: null -- only the vector ranker found the best hit: the words of the query appear nowhere in the docs. Expected for paraphrases, suspicious for a symbol name.
  • get_document with found: false -- the model asked for a page that does not exist.

The image has no jq, so stream the file out and filter on the host (queries.jsonl* includes the rotated files):

qlog() { docker exec mcpserver-byjg-docs-mcp-1 sh -c 'cat /data/logs/queries.jsonl*'; }

# Queries that found nothing
qlog | jq -c 'select(.tool=="search_docs" and .hits==0) | .query'

# Weakest searches first
qlog | jq -r 'select(.tool=="search_docs" and .hits>0) | [.top_score, .query] | @tsv' | sort -n | head -20

# Best hit found only by the vector ranker
qlog | jq -c 'select(.tool=="search_docs" and .hits>0 and .top_bm25_rank==null) | .query'

The file rotates at 10 MB and keeps five old files (queries.jsonl.1 to .5), so it never grows past about 60 MB. A client using the stack over stdio inherits the setting and writes to the same file.

Privacy: queries can contain pieces of the user's own code or questions. The log stays in the volume on this host; nothing sends it anywhere.

To turn it off, remove the BYJG_DOCS_QUERY_LOG line from docker-compose.yml. Running from source it is off unless BYJG_DOCS_QUERY_LOG names a file.

Backup

The index is one file inside the index volume, fully derived from a public git repository, and rebuilt in ~60s. There is nothing there worth backing up that GitHub does not already hold. The only state worth keeping is the query log in the logs volume, and losing it costs history, not service.

Data flows

Worth knowing given the tunnel exposes this publicly:

  • Documentation content is cloned from a public repo on each refresh, embedded by a local Ollama, and stored in a local file. The checkout is temporary and deleted afterwards.
  • Query text goes to the local Ollama only, and into the query log on this host. No embedding provider sees it.
  • What crosses the internet is the MCP request and response through the Cloudflare tunnel, and the git clone of the public docs repo.