Deploying with Ansible
This playbook installs Docker and runs the UniFi MCP server as containers, from one host up to a small cluster. It deploys the same services asdocker-compose.fleet.yml, adds nginx for several hosts, and sets up the
least-privilege database role and the audit retention job that
security policy describes.
Every layout serves Streamable HTTP with a bearer token (
MCP_AUTH_TOKEN).
To have an AI agent run all of this for you, start from a prompt in
docs/deploy-prompts/.
Requirements
- On the machine you run Ansible from:
ansible-core2.15 or later, thenansible-galaxy collection install -r requirements.yml -p ./collections. - Target hosts: Debian 12, Ubuntu 22.04/24.04, or RHEL/Rocky/Alma 9, with SSH
access and sudo. The
dockerrole installs Docker CE. - Network access from the
mcphosts to the UniFi controller, and an API key created on it (Settings → Control Plane → Integrations).
Steps
-
Choose an inventory and edit
hosts.yml(addresses) andgroup_vars/all/main.yml(controller, replicas, ports). -
Create the secrets:
vault.ymlis in.gitignore. Without Postgres onlyvault_unifi_api_key,vault_mcp_auth_tokenandvault_audit_chain_keyare needed. -
Check, then run:
-
Verify (from an
mcphost, or through nginx for the cluster):A401means the token is wrong. The second call returning aserverInfoshows the server is up and answering MCP.
unifi_mcp_replicas or unifi_mcp_workers and rerun to scale; containers
past the new count are removed.
What it sets up
- Image. Built on each
mcphost fromunifi_mcp_repoatunifi_mcp_versionwith thefleetextras, which the base published image does not include. Setunifi_mcp_imageto use a prebuilt image instead, such as theghcr.io/enuno/unifi-mcp-server:<version>-fleetimage a release publishes (amd64 and arm64 hosts; build from source on 32-bit ARM). - Migrations. With Postgres, the first
mcphost runspython -m src.fleet.cli migrateas the schema owner whenstatusreports the schema behind. - Least privilege. With
unifi_mcp_least_privilege(the default) and a Postgres the playbook runs, the server and workers connect asunifi_mcp_runtimewith the grants in SECURITY.md. Migrations and retention use the owner. The runtime password reachespsqlthrough an environment variable, never a file or the command line. - Containers.
unifi-mcp-0,unifi-mcp-1, … listen onunifi_mcp_bind_addressatunifi_mcp_port,unifi_mcp_port + 1, … Each has its own log directory under/opt/unifi-mcp/logs/<n>, because each audit log is its own hash chain. Docker checks/healthz.unifi-mcp-worker-<n>run health checks and snapshots (fleet only). Settings are in/opt/unifi-mcp/server.env(mode 0600). - Retention. When
unifi_mcp_audit_retention_daysis set, cron runs/opt/unifi-mcp/audit-retention.shdaily. With Postgres, one host runs it, archiving to/opt/unifi-mcp/archive. Without Postgres, each replica’s log is archived next to it. Logs rotate atunifi_mcp_audit_log_rotate_mb. - nginx (cluster). Hashes each client address and token to one replica,
so a session stays where it started (MULTI_CONTROLLER.md). Buffering is
off and reads are long, for streaming. Set
lb_tls_certificateandlb_tls_certificate_key(paths on thelbhost) to serve TLS.
Settings
The role defaults are inroles/unifi_mcp/defaults/main.yml. Any other server
setting from API reference goes in unifi_mcp_env_extra:
unifi_mcp_database_url (the role the server runs as),
unifi_mcp_migration_database_url (the owner, if different) and
unifi_mcp_redis_url. You then create the least-privilege role yourself,
following SECURITY.md.
Security notes
- Replicas bind to
127.0.0.1on a single host. In the cluster they bind to each host’sprivate_address, and Redis and Postgres publish their ports there too. Keep that network private, or encrypted (WireGuard, for example), and firewall it to the cluster’s hosts. - Without TLS on nginx, bearer tokens cross the network in the clear. Serve TLS whenever clients are not on the same private network.
- The vault holds every secret. Rotate
vault_mcp_auth_tokenby changing it and rerunning. Rotate the Fernet keys as SECURITY.md describes, not by replacing them.
Local dev deployment
inventories/dev-local is a third mode, alongside “pull a prebuilt image”
and “git clone unifi_mcp_repo@unifi_mcp_version”: set
unifi_mcp_local_checkout: true and the role skips git entirely, rsyncs
the live working tree at unifi_mcp_checkout_path (uncommitted changes
included) onto the target host, and builds from that. The image is tagged
local-<short-sha>[-dirty] by scripts/resolve_dev_tag.sh — the same
script scripts/helm_deploy_local.sh uses for the Helm chart’s equivalent
mode, so both deployment methods agree on one tag format. -dirty is a
deliberate, unmissable signal that the running image does not correspond
to any committed state.
unifi_mcp_checkout_path is required and never defaulted — pass it
explicitly (or set it in group_vars/all/main.yml if your checkout always
lives at the same absolute path) so there is never any ambiguity about
which tree produced the running image.
The build always runs (force_source: true, unlike the git-ref mode’s
“only rebuild if the checkout changed”) — a dev loop cares about picking
up the latest edit, and Docker’s own layer cache already makes a
same-content rebuild cheap. Re-running the playbook after any edit,
committed or not, is the whole point: do it instead of expecting the
container to notice a change under a tag it already has.
This mode is ansible_connection: local by default (deploy to the
machine you are running Ansible from); it is not exercised by CI or
molecule, since its correctness is “did it deploy my tree right now,”
not “is it reproducible” — verify it manually, the same way you would any
other local dev loop.