Giving the homelab a docs brain (and teaching it to stop exiting)
So here’s the situation. I have this tool called docs-mcp-server — it’s an MCP server that indexes documentation libraries (Helm charts, Flux docs, SOPS, whatever you throw at it) and exposes them through an MCP endpoint so my AI agents can search docs without hallucinating APIs that don’t exist. Cool concept, right?
The problem: it was running on my M1 Max MacBook Pro. In Docker. With an embedding model. And you know what the M1 Max is supposed to be doing? Running LLMs and Hermes agents. Not babysitting a documentation indexer 😅
On June 26th I decided to fix this: move docs-mcp-server to homelab-2nd (my k3s cluster), give it its own Ollama for embeddings, and keep the whole thing local-only — no Cloudflare Tunnel, no public DNS, no Ingress. Just a nice quiet service on the LAN that my agents can talk to.
What followed was an evening of CrashLoopBackOff, ConfigMap disasters, DNS confusion, and a port conflict that reminded me why single-node clusters are both wonderful and terrible. Let me tell you the story 🎸
What is docs-mcp-server and why offload it?
docs-mcp-server by arabold is an MCP server that can scrape and index documentation from websites, GitHub repos, and local files. It stores everything in a SQLite database and exposes search through an SSE (Server-Sent Events) MCP endpoint. Your AI coding assistant — Cline, Roo Code, Claude Desktop, whatever — connects to it and can suddenly search the official Kubernetes docs instead of guessing what kubectl flags exist.
The thing is, running it on the MacBook meant:
- Docker was eating RAM that LLMs needed
- The embedding model (
nomic-embed-text) was competing for CPU - If the laptop was asleep, no agent could search docs
The homelab-2nd k3s cluster is always on, always available, and has an NVIDIA GPU sitting there for exactly this kind of thing. So the plan was clear.
The plan (and the ADR)
I wrote an ADR (Architecture Decision Record) to nail down the decisions before writing any YAML. Here’s the thinking:
graph TB
subgraph "homelab-2nd k3s cluster"
DMCP["docs-mcp-server<br/>port 6280 (hostPort)"]
OLLAMA["ollama-embeddings<br/>nomic-embed-text model"]
SQLITE[("SQLite<br/>/data PVC 50Gi")]
CONFIG[("Config<br/>/config PVC 1Gi")]
COREDNS["CoreDNS<br/>docs-mcp.homelab-2nd.lan"]
end
subgraph "MacBook (LAN client)"
AI["Hermes / Open Code "]
end
AI -->|"HTTP SSE<br/>10.0.0.1:6280"| DMCP
DMCP -->|"OpenAI API<br/>/v1/embeddings"| OLLAMA
DMCP --> SQLITE
DMCP --> CONFIG
COREDNS -->|"10.0.0.1"| DMCP
The key decisions, straight from ADR-002:
- Local-only: no Cloudflare Tunnel, no public DNS, no Ingress. Access via
http://10.0.0.1:6280. - SQLite on
local-path: docs-mcp-server only documents SQLite — no Postgres support. Treat the index as rebuildable cache, not durable data. - In-cluster Ollama:
ollama/ollamaservingnomic-embed-textvia an OpenAI-compatible API. The embedding model is small (~274 MB) and runs fine on CPU, but we have a GPU so why not. - DNS: add
docs-mcp.homelab-2nd.lan -> 10.0.0.1in CoreDNS for cluster-side resolution. Mac clients still need a local/etc/hostsentry (more on that later 🤦).
The ADR also notes that hostPort was chosen over NodePort because NodePort range is 30000-32767 by default, and we wanted to preserve port 6280 — the default docs-mcp-server port. This decision would come back to bite me, as you’ll see.
Step 1 — Namespace and PVCs
First, the namespace. Everything goes in docs-mcp:
1
2
3
4
5
6
apiVersion: v1
kind: Namespace
metadata:
name: docs-mcp
labels:
app.kubernetes.io/name: docs-mcp
Then two PVCs on local-path (the k3s default local storage provisioner). The SQLite index and the runtime config:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
---
# docs-mcp-data-pvc.yaml — 50Gi for the SQLite index and cached documents
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docs-mcp-data
namespace: docs-mcp
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 50Gi
---
# docs-mcp-config-pvc.yaml — 1Gi for runtime config
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: docs-mcp-config
namespace: docs-mcp
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
Both PVCs are on local-path — that means they live on the node’s NVMe drive. If the node burns down, the index is gone. That’s fine: docs-mcp-server can rebuild it by re-scraping the source documentation. Durable data lives on OMV MinIO, not here. This is the homelab storage philosophy: rebuildable cache on local-path, durable data on NFS/MinIO.
Step 2 — ConfigMap and SOPS secret
The ConfigMap tells docs-mcp-server which embedding model to use, where to store things, and that telemetry should be off (I don’t need anonymous usage data going anywhere):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: v1
kind: ConfigMap
metadata:
name: docs-mcp-config
namespace: docs-mcp
data:
config.yaml: |
app:
storePath: "/data"
telemetryEnabled: false
readOnly: false
embeddingModel: "openai:nomic-embed-text"
server:
protocol: http
host: "0.0.0.0"
ports:
default: 6280
worker: 8080
mcp: 6280
web: 6281
auth:
enabled: false
embeddings:
batchSize: 100
batchChars: 50000
requestTimeoutMs: 30000
initTimeoutMs: 30000
Now the fun part: docs-mcp-server talks to Ollama through an OpenAI-compatible API. It expects an OPENAI_API_KEY environment variable. Ollama doesn’t care about the key — it accepts anything. But to keep consistency with the rest of the repo (where all secrets are SOPS-encrypted), I created a SOPS secret with a fake key:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: v1
kind: Secret
metadata:
name: docs-mcp-embedding-key
namespace: docs-mcp
type: Opaque
stringData:
openai-api-key: ENC[AES256_GCM,data:XxrKaprb,iv:0u0R+fKv0ehV/ahNRVz8aDVZX5qjKzsqdr/WxuNyvtc=,tag:xr8QEUdo2aQSnZrnM97Wnw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyM2pxd1VjbnAxTDBXVHJH
...
-----END AGE ENCRYPTED FILE-----
recipient: <REDACTED_KEY>
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-06-26T21:26:41Z"
version: 3.13.1
Yes, the “API key” is just the string ollama. SOPS encrypts it with age. It’s a fake key for a fake OpenAI endpoint that doesn’t check keys. But at least it’s not sitting in plaintext in the repo 😎
Step 3 — Ollama: the embedding sidecar
This is where it gets interesting. I need an Ollama container running nomic-embed-text in the cluster, accessible from docs-mcp-server. The Ollama deployment uses an init container to pull the model before the main container starts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama-embeddings
namespace: docs-mcp
labels:
app.kubernetes.io/name: ollama-embeddings
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: ollama-embeddings
template:
metadata:
labels:
app.kubernetes.io/name: ollama-embeddings
spec:
runtimeClassName: nvidia
nodeSelector:
feature.node.kubernetes.io/pci-10de.present: "true"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
initContainers:
- name: pull-model
image: ollama/ollama:0.3.14
command:
- /bin/sh
- -c
- |
set -e
ollama serve &
SERVE_PID=$!
until ollama list >/dev/null 2>&1; do
echo "Waiting for Ollama API..."
sleep 1
done
if ollama list | grep -q nomic-embed-text; then
echo "nomic-embed-text already present; skipping pull."
else
echo "Pulling nomic-embed-text..."
ollama pull nomic-embed-text
echo "Model pulled."
fi
kill $SERVE_PID
wait $SERVE_PID 2>/dev/null || true
env:
- name: OLLAMA_HOST
value: "127.0.0.1:11434"
- name: HOME
value: "/root"
volumeMounts:
- name: ollama-models
mountPath: /root/.ollama
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 4Gi
containers:
- name: ollama
image: ollama/ollama:0.3.14
ports:
- name: http
containerPort: 11434
protocol: TCP
env:
- name: OLLAMA_HOST
value: "0.0.0.0:11434"
- name: HOME
value: "/root"
volumeMounts:
- name: ollama-models
mountPath: /root/.ollama
resources:
requests:
cpu: "2"
memory: 1Gi
nvidia.com/gpu: 1
limits:
cpu: "4"
memory: 4Gi
nvidia.com/gpu: 1
livenessProbe:
httpGet:
path: /
port: 11434
initialDelaySeconds: 30
periodSeconds: 15
readinessProbe:
httpGet:
path: /
port: 11434
volumes:
- name: ollama-models
persistentVolumeClaim:
claimName: ollama-models
The init container is smart about it: it checks if nomic-embed-text is already on the PVC before pulling. No need to re-download 274 MB on every pod restart. The model cache lives on a 20Gi local-path PVC so it survives pod restarts — just not node rebuilds.
And a ClusterIP service so docs-mcp-server can reach it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: ollama-embeddings
namespace: docs-mcp
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ollama-embeddings
ports:
- name: http
port: 11434
targetPort: http
protocol: TCP
Step 4 — The docs-mcp-server deployment
Now the main event. This is the deployment that caused me the most pain:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
apiVersion: apps/v1
kind: Deployment
metadata:
name: docs-mcp-server
namespace: docs-mcp
labels:
app.kubernetes.io/name: docs-mcp-server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: docs-mcp-server
template:
metadata:
labels:
app.kubernetes.io/name: docs-mcp-server
spec:
securityContext:
fsGroup: 1000
containers:
- name: server
image: ghcr.io/arabold/docs-mcp-server:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 6280
hostPort: 6280
protocol: TCP
env:
- name: PORT
value: "6280"
- name: HOST
value: "0.0.0.0"
- name: DOCS_MCP_STORE_PATH
value: "/data"
- name: XDG_CONFIG_HOME
value: "/config"
- name: DOCS_MCP_EMBEDDING_MODEL
value: "openai:nomic-embed-text"
- name: OPENAI_API_BASE
value: "http://ollama-embeddings.docs-mcp.svc.cluster.local:11434/v1"
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: docs-mcp-embedding-key
key: openai-api-key
- name: DOCS_MCP_TELEMETRY_ENABLED
value: "false"
- name: DOCS_MCP_CONFIG
value: "/config/docs-mcp-server/config.yaml"
args:
- "--protocol"
- "http"
- "--host"
- "0.0.0.0"
- "--port"
- "6280"
- "--config"
- "/config/docs-mcp-server/config.yaml"
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /config
- name: config-file
mountPath: /config/docs-mcp-server/config.yaml
subPath: config.yaml
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 8Gi
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: docs-mcp-data
- name: config
persistentVolumeClaim:
claimName: docs-mcp-config
- name: config-file
configMap:
name: docs-mcp-config
A ClusterIP service for in-cluster access (though external access goes through the hostPort):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: docs-mcp-server
namespace: docs-mcp
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: docs-mcp-server
ports:
- name: http
port: 6280
targetPort: http
protocol: TCP
Step 5 — CoreDNS
I added a CoreDNS entry so the cluster itself can resolve docs-mcp.homelab-2nd.lan:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-custom-homelab
namespace: kube-system
data:
homelab.server: |
homelab-2nd.lan:53 {
errors
cache 30
hosts {
10.0.0.1 docs-mcp.homelab-2nd.lan
fallthrough
}
}
This CoreDNS entry only helps cluster-side DNS. Your Mac is probably using Cloudflare (1.1.1.1) or whatever your router hands out via DHCP, and it has no idea what .homelab-2nd.lan is. You need either a router-level DNS entry or a local /etc/hosts entry on each Mac. I learned this the hard way.
Commit, push, and let Flux do its thing
With all manifests written, it was time to commit and push:
1
2
3
4
5
6
7
8
9
10
11
12
cd ~/Projects/homelab-2nd
git add -A
git commit -m "feat(docs-mcp): deploy local docs-mcp-server with Ollama embeddings"
git push origin main
# Force Flux reconciliation
ssh homelab-2nd -o ConnectTimeout=10 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; \
sudo -E kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%Y-%m-%dT%H:%M:%S%z)" --overwrite; \
sleep 10; \
sudo -E kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%Y-%m-%dT%H:%M:%S%z)" --overwrite'
And then… well, then the problems started 😅
Problem 1: The server kept exiting with Completed
The pod started, printed its banner, and then exited cleanly. Exit code 0. CrashLoopBackOff with Last State: Terminated, Reason: Completed.
Root cause: the default entrypoint dist/index.js starts in interactive/CLI mode unless you explicitly tell it to run as a server. The container needs --protocol http --host 0.0.0.0 --port 6280 arguments.
Fix: added args to the Deployment (you can see them in the manifest above). Without those args, docs-mcp-server thinks you want to run a CLI command, finishes, and exits. Makes sense in hindsight, but when the pod says “Completed” with exit code 0 and CrashLoopBackOff, you don’t immediately think “oh I just need to add command-line arguments” 🤦
Problem 2: Config not picked up — server used defaults
After fixing the exit problem, the server started but ignored my PORT and HOST environment variables. It was using some default config instead of the one in my ConfigMap.
Root cause: when DOCS_MCP_CONFIG points to /config/docs-mcp-server/config.yaml and that file exists, the server reads the config from the file and overrides the environment variables. My PORT and HOST env vars were being ignored because the config file said different things.
Fix: make sure the ConfigMap, the env vars, and the args all agree. If the config file says host: 0.0.0.0 and port: 6280, the args should say --host 0.0.0.0 --port 6280, and the env vars should say PORT=6280 HOST=0.0.0.0. Belt and suspenders.
Always check whether an app’s environment variables are overridden by a generated config file. docs-mcp-server’s PORT/HOST were ignored once the config file existed. This is a classic “the config file wins” pattern that will cost you an hour of debugging.
Problem 3: ConfigMap file missing apiVersion/kind/metadata
Flux kustomize build failed with:
1
2
kustomize build failed: accumulating resources: accumulation err='accumulating resources from
'docs-mcp/docs-mcp-config-configmap.yaml': missing Resource metadata'
Root cause: I wrote only the YAML payload (the config.yaml contents) without wrapping it in a proper ConfigMap resource. I was thinking “this is a config file” instead of “this is a Kubernetes manifest that contains a config file.”
Fix: re-wrote the file with proper apiVersion: v1, kind: ConfigMap, metadata, and data.config.yaml block. The key insight: everything in the apps/ directory is a Kubernetes manifest first, and a config file second. Kustomize doesn’t know about your config — it only knows about apiVersion, kind, and metadata.
Problem 4: hostPort scheduling conflict during rolling update
When I pushed an update, the new pod stayed Pending with:
1
0/1 nodes are available: 1 node(s) didn't have free ports for the requested pod ports
Root cause: only one pod can bind host port 6280 at a time. The old pod was still terminating during the rolling update, so the new pod couldn’t get the port.
Fix: manually deleted the old pod with --grace-period=0 --force so the new one could schedule:
1
2
kubectl -n docs-mcp delete pod docs-mcp-server-d55d648dc-p95wt \
--grace-period=0 --force
For a single-node cluster this is acceptable. For multi-node, you’d need a DaemonSet or anti-affinity rules. But since I have exactly one node, the rolling update strategy is basically “kill the old one first, then the new one can start.” Not elegant, but it works.
hostPort is the pragmatic way to preserve a well-known port on a single-node k3s cluster, but it complicates rolling updates. If you’re doing this, set replicas: 1 and use RollingUpdate with maxSurge: 0 so Kubernetes doesn’t try to schedule two pods at the same time.
Problem 5: DNS name not resolvable from Mac
1
2
curl http://docs-mcp.homelab-2nd.lan:6280/
# curl: (6) Could not resolve host: docs-mcp.homelab-2nd.lan
Root cause: the CoreDNS entry I added only affects cluster-side DNS. My MacBook uses Cloudflare (1.1.1.1) and has no resolver for .homelab-2nd.lan. The cluster can find docs-mcp.homelab-2nd.lan just fine; the Mac cannot.
Fix: direct IP works immediately — http://10.0.0.1:6280. A permanent fix requires either router-level DNS delegation for .homelab-2nd.lan or a local /etc/hosts entry on each Mac. I didn’t modify the Mac’s /etc/hosts because sudo requires an interactive password and I was running this from a non-interactive context.
Verification
After all the fixes, let me check if things actually work:
From the Mac:
1
2
3
4
5
6
7
8
9
curl -s -o /dev/null -w "%{http_code}\n" http://10.0.0.1:6280/
# 200
curl -s -o /dev/null -w "%{http_code}\n" http://10.0.0.1:6280/sse
# 200
# SSE endpoint returns:
event: endpoint
data: /messages?sessionId=...
From the cluster, the Ollama embedding API works:
1
2
3
4
5
6
kubectl -n docs-mcp run test-embed --rm -i --restart=Never \
--image=curlimages/curl:latest \
-- curl -s http://ollama-embeddings.docs-mcp.svc.cluster.local:11434/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model":"nomic-embed-text","input":"hello world"}'
# {"object":"list","data":[{"object":"embedding","embedding":[-0.0067923847,...
Pod status:
1
2
3
NAME READY STATUS
docs-mcp-server-d55d648dc-p95wt 1/1 Running
ollama-embeddings-c9857bc66-wtx77 1/1 Running
Flux status:
1
apps True Applied revision: main@sha1:98ea7bd...
200 on the HTTP endpoint, 200 on the SSE endpoint, embeddings returning vectors, both pods running, Flux happy. After five problems and a lot of facepalms, it works 😎
Client configuration
To use the docs-mcp-server from your AI coding assistant (Cline, Roo Code, Claude Desktop, etc.), add this to your MCP config:
1
2
3
4
5
6
7
8
{
"mcpServers": {
"docs-mcp-server": {
"type": "sse",
"url": "http://10.0.0.1:6280/sse"
}
}
}
If you set up local DNS/hosts for docs-mcp.homelab-2nd.lan, use http://docs-mcp.homelab-2nd.lan:6280/sse instead. Either works — one just looks more professional than an IP address 😅
What’s next
- First-time Web UI setup: open
http://10.0.0.1:6280and add some documentation libraries (React docs, Kubernetes docs, project READMEs, etc.) - Mac DNS: add
10.0.0.1 docs-mcp.homelab-2nd.lanto/etc/hostson each Mac, or configure the home router to delegate.homelab-2nd.lan - Observability: docs-mcp-server and Ollama don’t expose Prometheus metrics. We rely on logs + kubelet/cAdvisor pod metrics for now. If upstream adds a
/metricsendpoint, we’ll add a ServiceMonitor - Backup: because the SQLite index is on
local-path, a node rebuild wipes it. If the index becomes valuable, move the PVC to an NFS share from OMV or wait for upstream Postgres support
Two days later (June 28th), I would discover that I now had two docs-mcp servers — one on the Mac and one on the cluster — with completely different sets of indexed libraries. That’s a separate story for a separate post. Spoiler: it involved an 8 GiB OOM and a lot of library deduplication.
Lessons learned
hostPortis the pragmatic way to preserve a well-known port on a single-node k3s cluster, but it makes rolling updates annoying. SetmaxSurge: 0.- Always check whether an app’s env vars are overridden by a generated config file. docs-mcp-server’s
PORT/HOSTwere ignored once/config/docs-mcp-server/config.yamlexisted. - A tiny Ollama container pulling
nomic-embed-textis enough to get local embeddings running. No GPU required for this model — it’s only 274 MB and fast on CPU. - Local-only services can skip Cloudflare Tunnels entirely, which is a nice break from the usual public-service setup. No OAuth, no TLS, no cert-manager, no token rotation. Just a port on a LAN.
- When writing a ConfigMap, always include
apiVersion,kind, andmetadata. Kustomize doesn’t care that you were thinking “this is just a config file” — it sees a Kubernetes manifest or it sees nothing. - CoreDNS custom entries only help inside the cluster. Your Mac has its own DNS resolver and doesn’t know about your
.landomain unless you tell it.
And that’s how the homelab got a docs brain. Five bugs, one evening, one ADR, and a lot of coffee ☕🎸
References:
- arabold/docs-mcp-server — the upstream project
apps/docs-mcp/— all the manifestsdocs/adr/adr-002-docs-mcp-server-local-deployment.md— the ADR with all decisions



