unifi-mcp Helm chart
Runs the UniFi MCP server on Kubernetes over Streamable HTTP with a bearer
token. It can run the server alone, or in fleet mode with workers, the
Postgres controller registry and audit store, and an optional Redis. The
chart creates no database or cache. It takes their URLs from a Secret you
provide, so you can use managed services or your own operators (CloudNativePG,
a Redis operator) for high availability.
Install
-
Create the Secret. The chart never templates secret values: every key in
this Secret becomes an environment variable of the server.
For fleet mode, also add:
Optional keys:
UNIFI_AUDIT_LOG_KEY, UNIFI_AUDIT_SIEM_WEBHOOK_SECRET,
UNIFI_AUDIT_SIEM_OTLP_HEADERS. External Secrets or Sealed Secrets can
produce the same Secret.
-
Install:
Fleet mode, three replicas, two workers, a year of audit records:
-
Check:
MCP clients connect to
http(s)://<host>/mcp with
Authorization: Bearer <MCP_AUTH_TOKEN>.
What it creates
- Server Deployment. Runs as uid 1000 with a read-only root filesystem,
every capability dropped, and no service account token. The startup,
liveness and readiness probes use
GET /healthz, which needs no token
and reveals nothing. A change to the non-secret settings restarts the
pods. After changing the Secret, run kubectl rollout restart.
- Worker Deployment (fleet). Runs
python -m src.worker for health
checks and snapshots. Without REDIS_URL, keep one worker.
- Migration Job (fleet). A pre-install and pre-upgrade hook that runs
python -m src.fleet.cli migrate. The servers refuse to start on an old
schema.
- Retention CronJob (fleet, with
audit.retentionDays). Archives
expired audit records to a PersistentVolumeClaim, then deletes them. Only
one run happens at a time. With a storage class that binds on first use
(WaitForFirstConsumer, the default on kind and most clouds), that claim
stays Pending until the first run, so helm install --wait would wait
for it. Wait on the Deployments instead (kubectl rollout status), or
create the claim yourself and set audit.archive.existingClaim.
- Service, plus optional Ingress, ServiceMonitor, PodDisruptionBudget and
NetworkPolicy. The ServiceMonitor reads the bearer token for
/metrics from the Secret.
Several replicas
An MCP session lives in one pod’s memory (MULTI_CONTROLLER.md). You have
three ways to handle that:
-
Keep each client on one pod. With more than one replica the Service sets
sessionAffinity: ClientIP. Behind ingress-nginx, hash on the client and
its token:
-
Set
server.statelessHttp=true. Any pod then answers any request, and
calls name their controller with controller=<name>.
-
Run one replica.
The audit log
In fleet mode audit records go to Postgres, and the pod’s log volume
(an emptyDir) holds only the SIEM buffer. Without fleet, the JSON-lines
file is the only copy. Set persistence.enabled to keep it on a volume
(one replica), or set audit.siemTargets to copy every record off the pod.
Rotation (audit.rotateMb) applies to the file either way.
Values
See values.yaml. Any non-secret setting from API.md goes
in server.extraEnv. The chart refuses to put a secret there.
Image
The default image is ghcr.io/enuno/unifi-mcp-server:<appVersion>. With
fleet.enabled it is ...:<appVersion>-fleet, the image built with the
Redis and Postgres extras that the release workflow publishes for
linux/amd64 and linux/arm64 (the base image also covers 32-bit ARM). To
build your own, run docker build --build-arg EXTRAS=fleet . and set
image.repository and image.tag.
Deploying from a local checkout
scripts/helm_deploy_local.sh automates the “build your own” step above
for a real (not throwaway) deployment of whatever is checked out right
now, including uncommitted changes (make helm-deploy-local wraps it with
sane defaults):
It builds the image, tags it local-<short-sha>[-dirty]
(scripts/resolve_dev_tag.sh — shared with the Ansible local-checkout
mode, see deploy/ansible/overview.md), gets it into the target cluster’s
runtime, and runs helm upgrade --install with
deploy/helm/unifi-mcp/ci/local-build-values.yaml plus any --values
file you pass. --target picks how the image reaches the cluster:
k3s (default) — k3s ctr images import (needs root for the
containerd socket; the script sudos explicitly).
kind — kind load docker-image, for dev against a kind cluster
instead of a real one.
push --registry <repo> — docker push for a remote/multi-node
cluster. Refuses to push to ghcr.io/enuno/unifi-mcp-server (the
release workflow’s own path) without
--i-know-this-overwrites-releases.
A -dirty tag means uncommitted changes are running — check
helm get values or kubectl describe pod before treating a deployment
as reproducible. The tag only changes with the tree: re-running the
script after a new commit is what picks the change up, since Kubernetes
does not notice an in-place image change under a reused tag with
pullPolicy: Never/IfNotPresent.
Tests
CI lints and renders the chart with each file in ci/, validates the
output with kubeconform, and runs scripts/helm_smoke_test.sh on a kind
cluster. That test installs the chart in fleet mode against a throwaway
Postgres, checks /healthz, checks that /mcp refuses requests without a
token and answers with one, and runs the retention job once.Last modified on October 28, 2026