Skip to main content

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

  1. 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.
  2. Install:
    Fleet mode, three replicas, two workers, a year of audit records:
  3. 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