A complete operator runbook written for future‑me
This document is a long‑form, narrative how‑to guide that explains how to deploy a private Matrix homeserver inside a Tailscale tailnet, how to integrate the InfiniGPT Matrix bot, and how to connect multiple MCP servers so the bot can interact with my infrastructure. It is written deliberately as prose rather than lists because I know that in six months I will have forgotten the small details that matter. This guide is meant to be readable, memorable, and complete, while still containing all the necessary code blocks to rebuild the system from scratch.
However reader be warned. Its not perfect, will be missing some data and I will update it as often as i can. IF you see i’m missing something and want me to add it, DM me on Mastodon (link on right) and I’ll review and add.
Introduction
When I first built this system, I wanted a communication platform that was entirely under my control, secure by default, and capable of interacting with my operational systems. Matrix was the natural choice because it provides a modern chat experience, supports end‑to‑end encryption, and can be extended with bots and automation. However, I did not want to expose anything to the public internet. I wanted a system that lived entirely inside my Tailscale tailnet, where every device is authenticated and every connection is encrypted.
By combining Matrix with Tailscale Serve, I created a private Slack‑like environment that requires no public ports, no reverse proxies, and no public TLS certificates. Everything is reachable only by devices inside my tailnet. On top of this, I added InfiniGPT, a Matrix bot capable of interacting with large language models and executing MCP tools. MCP allows the bot to safely interact with external systems such as Netdata, Nextcloud, Rancher, PatchMon, and ManageLM. This means I can ask questions about my Kubernetes clusters, check patch status, read files, or inspect metrics directly from a Matrix room.
This document explains how to build the entire system from scratch, how to configure it, and how to operate it long‑term.
Part 1 — Preparing the Environment
Step 1: Clone the Repository
The first step is to obtain the Matrix server repository that contains the Docker Compose stack, the configuration renderer, and the InfiniGPT integration. I usually keep my code in ~/code, so I begin by cloning the repository and entering the directory.
cd ~/code
git clone https://github.com/william-opie/matrix-server
cd matrix-server
Step 2: Create and Edit the .env File
The repository includes an example environment file that serves as a template. I copy this file to .env and begin editing it. This file defines the Matrix server name, the public base URL, TURN configuration, LiveKit settings, and all secrets required by Synapse and the surrounding services.
cp .env.example .env
A typical .env file for a tailnet‑only deployment looks like this:
env
MATRIX_SERVER_NAME=matrix.homelan.com
PUBLIC_BASEURL=https://matrix.homelan.com
TURN_REALM=matrix.homelan.com
TURN_SHARED_SECRET=CHANGE_ME_TURN_SECRET
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
MACAROON_SECRET_KEY=CHANGE_ME_MACAROON_SECRET
REGISTRATION_SHARED_SECRET=CHANGE_ME_REGISTRATION_SECRET
LIVEKIT_NODE_IP=100.100.100.50
ELEMENT_CALL_URL=https://matrix.homelan.com:8443
INFINIGPT_PASSWORD=CHANGE_ME_INFINIGPT_PASSWORD
OPENROUTER_API_KEY=CHANGE_ME_OPENROUTER_KEY
I replace every placeholder with a strong random token. I generate these tokens using Python:
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
Once the .env file is complete, I validate it using:
docker compose config
If the configuration renders correctly, I proceed to the next stage.
Part 2 — Starting the Matrix Stack
Step 1: Launch the Services
With the environment configured, I start the entire stack using Docker Compose. When the stack starts, the configuration renderer generates runtime configuration files inside the runtime/ directory. Postgres starts first, followed by Synapse, which waits until the database is ready. The bootstrap script then runs automatically, creating the initial admin user and the default Spaces and rooms. After that, the web frontends, coturn, LiveKit, Element Call, and the admin UI all come online.
docker compose up -d
Step 2: Verify Container Health
Once the stack is running, I check the container status:
docker compose ps
If Synapse appears unhealthy, I inspect its logs:
docker compose logs -f synapse
If Postgres did not start correctly, I inspect its logs:
docker compose logs -f postgres
If the runtime directory is missing or incomplete, it usually means the configuration renderer failed. In that case, I inspect the renderer logs and correct any issues in the .env file.
Part 3 — Exposing Matrix via Tailscale Serve
Step 1: Publish Services Inside the Tailnet
NOTE: I have a feeling i didn’t need to do this in the end, however i’ve added it because it was in my notes
Tailscale Serve allows me to expose local ports as HTTPS endpoints inside the tailnet. This means I can access Matrix, Element Web, Element Call, and the admin UI from any device connected to Tailscale without exposing anything publicly.
tailscale serve --bg --https=443 http://127.0.0.1:8088
tailscale serve --bg --https=8443 http://127.0.0.1:8089
tailscale serve --bg --https=8444 http://127.0.0.1:8090
Step 2: Verify Serve Configuration
After configuring Serve, I check its status:
tailscale serve status
If a route does not load, I verify that my client is connected to Tailscale, that the local ports are listening, and that the nginx container is running correctly.
This is what i did
Exposing Matrix via Nginx and Cloudflare Instead of Tailscale Serve
When I first planned this deployment, I considered using Tailscale Serve to publish HTTPS endpoints inside the tailnet. Serve is convenient because it automatically provisions certificates for internal .ts.net or MagicDNS names and handles HTTPS termination directly within the tailnet. However, in practice, I decided to take a different route that better suited my existing infrastructure and domain setup.
Rather than relying on Tailscale Serve, I configured each container — Synapse, Element Web, Element Call, and the admin UI — to bind directly to its own Tailscale interface IP address. This meant that every service was reachable on its unique tailnet IP, and I could integrate those addresses into my existing nginx‑proxy and Cloudflare configuration. By doing this, I gained full control over domain naming, certificate management, and external access while still keeping the backend services isolated from the public internet.
In my nginx configuration, I defined upstreams pointing to the Tailscale IPs of each container. Cloudflare handled the public DNS and TLS termination, issuing certificates for my chosen hostnames. This allowed me to present clean, user‑friendly URLs such as https://matrix.homelan.com for the Matrix homeserver and https://chat.homelan.com for Element Web. Behind the scenes, nginx securely proxied requests to the containers running on their Tailscale IPs.
A simplified example of the nginx configuration looked like this:
nginx
server {
server_name matrix.homelan.com;
ssl_certificate /etc/letsencrypt/live/matrix.homelan.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.homelan.com/privkey.pem;
location / {
proxy_pass http://100.100.100.10:8008; # Synapse container on Tailscale IP
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
server {
server_name chat.homelan.com;
ssl_certificate /etc/letsencrypt/live/chat.homelan.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.homelan.com/privkey.pem;
location / {
proxy_pass http://100.100.100.11:8080; # Element Web container on Tailscale IP
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
Each container advertised its own Tailscale IP, which I confirmed using tailscale ip -4. I then added these IPs to my nginx upstream definitions and verified connectivity. Cloudflare’s edge handled HTTPS requests, and nginx performed local proxying to the containers. This approach gave me the flexibility to use my own domain names and certificates while maintaining the same security posture as a tailnet‑only deployment.
To verify that everything was working correctly, I checked that each container was listening on its Tailscale IP and that nginx could reach it internally. I also confirmed that Cloudflare’s SSL configuration was set to “Full (Strict)” to ensure end‑to‑end encryption between Cloudflare and my server.
This method proved more adaptable than Tailscale Serve because it integrated seamlessly with my existing Cloudflare setup and allowed me to use familiar domain names. It also made it easier to manage certificates and renewals through Let’s Encrypt or Cloudflare’s API. The result was a clean, secure HTTPS setup that felt native to my infrastructure while still leveraging Tailscale’s private networking for backend isolation.
Verifying Tailscale IP Bindings and Nginx Connectivity
Once I decided not to use Tailscale Serve and instead expose my Matrix stack through nginx and Cloudflare, I needed a reliable way to confirm that each container was correctly bound to its own Tailscale interface IP. This verification step was essential because nginx would be proxying traffic directly to those IPs, and any mismatch or incorrect binding would result in unreachable services or confusing 502 errors. The process was straightforward, but it’s exactly the kind of thing I would forget months later, so I’m documenting it here.
The first thing I did was check the Tailscale IP assigned to the host. Tailscale typically assigns a stable IPv4 address to each machine, and I retrieved it using the standard command:
bash
tailscale ip -4
This gave me the base IP that the host itself was using inside the tailnet. However, because each Docker container was configured to bind directly to the Tailscale interface, I needed to confirm that the containers were actually listening on that interface rather than only on localhost. To do this, I inspected each container individually. Docker’s inspection command allowed me to see the network configuration and confirm that the container was reachable on the Tailscale IP:
bash
docker inspect <container_name> | grep -i "IPAddress"
This showed me the container’s internal Docker IP, but what mattered more was whether the service inside the container was bound to the host’s Tailscale interface. To verify that, I used ss and netstat to check listening ports:
bash
sudo ss -tulpn | grep 8008
sudo ss -tulpn | grep 8080
sudo ss -tulpn | grep 8089
Each of these commands confirmed that Synapse, Element Web, and Element Call were listening on the correct ports and were bound to 0.0.0.0, which meant they were reachable via the Tailscale IP. If any service had been bound only to 127.0.0.1, nginx would not have been able to reach it, so this check was important.
Once I confirmed that the containers were reachable on the Tailscale IP, I moved on to verifying nginx connectivity. I tested this by curling the Tailscale IP directly from the host:
bash
curl -vk https://matrix.homelan.com --resolve matrix.homelan.com:443:100.100.100.10
This command forced curl to connect to the Tailscale IP while still using the public hostname, which allowed me to confirm that nginx was correctly proxying traffic to Synapse. I repeated similar tests for Element Web and Element Call, adjusting the hostname and IP accordingly. If nginx had been misconfigured, curl would have returned an error or shown the wrong backend response.
I also checked the nginx logs to ensure that requests were flowing correctly:
bash
docker logs nginx-proxy --tail 100
This showed me real‑time proxy activity and confirmed that Cloudflare’s edge was reaching nginx, and nginx was successfully forwarding requests to the containers on their Tailscale IPs.
Finally, I verified Cloudflare’s SSL mode. Because nginx was terminating TLS locally using Let’s Encrypt or Cloudflare‑issued certificates, I needed Cloudflare to operate in “Full (Strict)” mode. This ensured that Cloudflare validated the certificate presented by nginx and maintained end‑to‑end encryption. Without this setting, Cloudflare might downgrade the connection or introduce insecure behavior.
By combining these checks — confirming Tailscale IP bindings, verifying container listening ports, testing nginx proxying, and validating Cloudflare’s SSL configuration — I ensured that the entire system was correctly exposed through my chosen domain names while still maintaining the security and isolation provided by Tailscale. This approach integrated seamlessly with my existing infrastructure and gave me full control over how Matrix and its related services were published.
Part 4 — User Onboarding
User onboarding is handled through the admin UI, which allows me to create invite tokens. I provide users with the public base URL, which is the Tailscale Serve hostname, and they join using their invite token. Because the deployment is tailnet‑only, users must be connected to Tailscale before they can access the homeserver. The bootstrap script creates the initial Spaces and rooms, so users immediately see a structured environment when they log in.
Part 5 — Adding the InfiniGPT Matrix Bot
Step 1: Configure Bot Secrets
InfiniGPT requires a Matrix password and an OpenRouter API key. I set these values in the .env file so that the bot container can read them at startup.
Step 2: Create the Bot User
The repository includes a script that creates the InfiniGPT user inside Synapse:
python3 scripts/create_infinigpt_user.py
Step 3: Configure config.json
The bot configuration file lives in runtime/infinigpt/config.json. A typical configuration looks like this:
json
{
"matrix": {
"server": "http://synapse:8008",
"username": "infinigpt",
"password": "CHANGE_ME_INFINIGPT_PASSWORD",
"device_id": "INFINIGPT001",
"channels": [
"#support:matrix.homelan.com",
"#chat:matrix.homelan.com"
],
"admins": [
"@dave:matrix.homelan.com"
],
"e2e": false,
"store_path": "/workspace/store"
},
"llm": {
"models": {
"openrouter": [
"anthropic/claude-sonnet-4.6",
"openai/gpt-4o",
"google/gemini-2.5-pro"
]
},
"api_keys": {
"openrouter": "CHANGE_ME_OPENROUTER_KEY"
},
"default_model": "anthropic/claude-sonnet-4.6",
"personality": "You are a helpful and friendly AI assistant.",
"history_size": 24,
"timeout": 180,
"mcp_servers": {
"Netdata": {
"command": "bash",
"args": [
"-lc",
"mcp-remote http://monitoring.homelan.com:19999/mcp --allow-http --header 'Authorization: Bearer CHANGE_ME_NETDATA_KEY'"
]
},
"Nextcloud": {
"command": "bash",
"args": [
"-lc",
"set -a && . /config/nextcloud.env && set +a && nextcloud-mcp-server run --transport stdio --log-level warning"
]
},
"Rancher": {
"command": "bash",
"args": [
"-lc",
"set -a && . /config/rancher.env && set +a && rancher-mcp-server"
]
},
"PatchMon": {
"command": "bash",
"args": [
"-lc",
"set -a && . /config/patchmon.env && set +a && patchmon-mcp"
]
}
}
},
"markdown": true
}
Step 4: MCP Environment Files
Each MCP server requires its own environment file.
Netdata:
env
NETDATA_API_URL=http://monitoring.homelan.com:19999
NETDATA_MCP_API_KEY=CHANGE_ME_NETDATA_KEY
LOG_LEVEL=INFO
Nextcloud:
env
NEXTCLOUD_HOST=https://files.homelan.com
NEXTCLOUD_USERNAME=infinigpt
NEXTCLOUD_PASSWORD=CHANGE_ME_NEXTCLOUD_APP_PASSWORD
Rancher:
env
RANCHER_MCP_RANCHER_SERVER_URL=https://rancher.homelan.com
RANCHER_MCP_RANCHER_TOKEN=CHANGE_ME_RANCHER_TOKEN
RANCHER_MCP_TOOLSETS=rancher,kubernetes,helm,fleet
RANCHER_MCP_TLS_INSECURE=false
RANCHER_MCP_READ_ONLY=true
PatchMon:
env
PATCHMON_ENDPOINT=https://patchmon.homelan.com
PATCHMON_TOKEN_KEY=CHANGE_ME_PATCHMON_KEY
PATCHMON_TOKEN_SECRET=CHANGE_ME_PATCHMON_SECRET
LOG_LEVEL=INFO
Step 5: Build and Start the Bot
I rebuild the bot container to ensure the latest patches and MCP binaries are included:
docker compose up -d --build --force-recreate infinigpt
I then inspect the logs:
docker compose logs --no-color --tail=200 infinigpt
Part 6 — MCP Integrations
MCP allows InfiniGPT to interact with external systems in a controlled and secure way. Each MCP server exposes a set of tools that the bot can call. For example, Netdata exposes metrics and alerts, Nextcloud exposes file operations and notes, Rancher exposes Kubernetes and Helm operations, PatchMon exposes patch management, and ManageLM exposes inventory, logs, certificates, and security audits.
After editing the MCP environment files, I restart the bot so it can rediscover the tools.
Part 7 — Using InfiniGPT in Matrix
InfiniGPT responds to commands typed in Matrix rooms. The .ai command sends a message to the language model and returns the response. The .persona command changes the bot’s personality for the current user. The .custom command sets a custom system prompt. The .reset command clears the user’s conversation history. The .mymodel command allows users to choose a different model for their own messages.
Administrators have access to additional commands. The .tools command shows whether tool calling is enabled and lists available MCP tools. The .model command changes the global model. The .clear command resets all histories for all users.
Because MCP is integrated, I can ask the bot to perform tasks such as listing Kubernetes pods, checking patch status, reading files from Nextcloud, or inspecting Netdata alerts. The bot uses the MCP tools to gather information and then synthesises the results using the language model.
Part 8 — Operational Maintenance
Maintaining the system involves backing up the database, preserving configuration files, updating containers, and monitoring logs. I back up the Synapse database using:
docker compose exec postgres pg_dump -U synapse synapse > backup_synapse.sql
I also preserve the runtime directory:
cp -r runtime/ ~/backups/
Updates are performed by pulling new container images and restarting the stack:
docker compose pull
docker compose up -d
Logs from Synapse and InfiniGPT help diagnose issues:
docker compose logs -f synapse
docker compose logs -f infinigpt
The repository includes a watchdog script that monitors the bot’s health:
scripts/watchdog_infinigpt.sh
The watchdog logs are stored here:
cat data/infinigpt/watchdog.log
Diagram

Conclusion
This deployment gives me a private, secure, AI‑augmented communication platform that integrates deeply with my homelab. By running everything inside a Tailscale tailnet, I avoid the complexity and risk of public exposure. InfiniGPT adds powerful automation capabilities through MCP, allowing me to interact with my systems using natural language. This document captures the entire process in a narrative form so that I can rebuild or modify the system even if I forget the details months from now.