Matrix, Matrix Admin, and Maubot on Docker: The Lean Homelab Stack
Every Matrix homelab guide assumes you want PostgreSQL. The official docs say SQLite is testing-only. Community write-ups default to five containers before you've sent a single message. For a personal server running a handful of users without federation into large public rooms, that's a lot of machinery to keep running for what amounts to private encrypted chat and a bot or two. This build uses Synapse in single-process mode with SQLite, adds Synapse Admin for visibility, and wires in Maubot for bots — behind Traefik, behind HTTPS, from one compose file. No Postgres, no Redis, no workers.
The non-obvious parts are: generating the Synapse config before the stack runs, creating the admin account with a command that runs inside the container, and getting Maubot's bot account connected without invalidating the access token in the process. That last one breaks most setups that get this far. Jump to Commands Reference →
Directory Structure and Synapse Config Generation
Create the host directories before the stack runs. Docker does not create missing bind-mount paths, and on a system with SELinux enforcing, the failure mode is a silent permission error that looks like a container problem when the actual cause is a missing directory.
mkdir -p /working/docker/synapse/data
mkdir -p /working/docker/maubot/dataSynapse generates its configuration on first run via a dedicated generate command. Run this before starting the stack — it creates homeserver.yaml and a signing key in the data directory:
docker run --rm \
-e SYNAPSE_SERVER_NAME=matrix.domain.com \
-e SYNAPSE_REPORT_STATS=no \
-v /working/docker/synapse/data:/data:Z \
matrixdotorg/synapse:latest generateReplace matrix.domain.com with your actual domain. The :Z volume label is required — it sets the SELinux context to private for this container. Without it, the Synapse process cannot write to the mounted path and the container fails to start without a useful error.
After the generate command completes, open /working/docker/synapse/data/homeserver.yaml and make three edits. Set public_baseurl to your public URL, disable open registration, and add a registration shared secret for creating accounts manually. The shared secret is what the register_new_matrix_user command authenticates against — without it, the command is blocked.
public_baseurl: https://matrix.domain.com/
enable_registration: false
registration_shared_secret: "your-registration-secret-here"Generate the shared secret with openssl rand -base64 32 and paste it in. Keep it somewhere — you'll need it to create accounts.
Maubot Config Before First Run
Maubot generates a default config.yaml on first run if one isn't present. Starting it without config and editing after means an extra restart cycle. Write the config before deploying.
Create /working/docker/maubot/data/config.yaml with the following. Replace the domain, public URL, and admin password before the stack starts — the password is stored in cleartext here and replaced with a bcrypt hash when Maubot first reads it:
server:
hostname: 0.0.0.0
port: 29316
public_url: https://bots.domain.com
base_path: /_matrix/maubot/v1
ui_base_path: /_matrix/maubot
homeservers:
matrix.domain.com:
url: http://synapse:8008
admins:
admin: yourpassword
database: sqlite:///maubot.db
plugin_directories:
upload: /data/plugins
load:
- /data/plugins
trash: /data/trash
shared_data: /data/plugin_dataThe homeservers URL points to http://synapse:8008 — the internal Docker service name and port. Maubot reaches Synapse over the shared Docker network, not the public URL. The entry name under homeservers — matrix.domain.com in this example — must match exactly when you run mbc auth later. A mismatch causes a "Registration target server not found" error that isn't immediately obvious to trace back to the config.
The Stack
All three services share the external Traefik network. Maubot and Synapse communicate internally over this network by service name. Synapse Admin is a static web app that makes API calls from the browser to your public Synapse URL — it doesn't need internal network access to Synapse.
version: "3.8"
services:
synapse:
image: matrixdotorg/synapse:latest
container_name: synapse
restart: unless-stopped
volumes:
- /working/docker/synapse/data:/data:Z
networks:
- proxy
healthcheck:
test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.synapse.rule=Host(`matrix.domain.com`)"
- "traefik.http.routers.synapse.entrypoints=websecure"
- "traefik.http.routers.synapse.tls.certresolver=letsencrypt"
- "traefik.http.services.synapse.loadbalancer.server.port=8008"
synapse-admin:
image: awesometechnologies/synapse-admin:latest
container_name: synapse-admin
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.synapse-admin.rule=Host(`admin.domain.com`)"
- "traefik.http.routers.synapse-admin.entrypoints=websecure"
- "traefik.http.routers.synapse-admin.tls.certresolver=letsencrypt"
- "traefik.http.services.synapse-admin.loadbalancer.server.port=80"
maubot:
image: dock.mau.dev/maubot/maubot:latest
container_name: maubot
restart: unless-stopped
volumes:
- /working/docker/maubot/data:/data:Z
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.maubot.rule=Host(`bots.domain.com`)"
- "traefik.http.routers.maubot.entrypoints=websecure"
- "traefik.http.routers.maubot.tls.certresolver=letsencrypt"
- "traefik.http.services.maubot.loadbalancer.server.port=29316"
networks:
proxy:
external: trueReplace all three hostnames with your actual domains. The Traefik certresolver name (letsencrypt) should match whatever you've named yours. The volume mounts use :Z on both Synapse and Maubot — private SELinux context, required on Rocky Linux with SELinux enforcing.
Deploy the stack:
docker compose up -dSynapse takes 20–30 seconds to initialize on a cold start. The healthcheck has a start_period of 30 seconds to give it room — without this, Docker may report the container unhealthy while Synapse is still loading, which can trigger restart loops if you have dependent container logic. Check that it's running:
docker logs synapse --tail 30Creating the Admin Account
Registration is disabled in homeserver.yaml, which is the right call for a personal server. Account creation goes through register_new_matrix_user, run inside the Synapse container. It authenticates using the shared secret you set earlier:
docker exec -it synapse register_new_matrix_user \
--config /data/homeserver.yaml \
--admin \
--user admin \
--password 'youradminpassword'The --admin flag gives this account server admin privileges, which Synapse Admin needs to make administrative API calls. Once this completes, navigate to your Synapse Admin URL, enter your public Synapse URL and these credentials, and the admin panel connects. If it can't reach the Synapse admin API, check that the Traefik routing is correct and that port 8008 is not blocked between Traefik and the Synapse container.
Bot Account Creation and Maubot Registration
This is where most setups go wrong. Creating a bot account and registering it with Maubot looks straightforward — log in as the bot in Element, grab the access token, paste it into Maubot Manager. That path works until you log out of Element, at which point the session is invalidated and Maubot silently loses access. The bot stops responding. No error surfaced anywhere obvious.
The correct approach creates a dedicated device session for Maubot using the Matrix client API directly, with a stable device ID. That session is independent of any Element login and persists until the device is explicitly revoked.
First, get an admin access token by logging in as the admin account:
curl -s -X POST 'https://matrix.domain.com/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"admin"},"password":"youradminpassword"}' \
| python3 -m json.toolPull the access_token value from the response. Use it to create the bot account via the Synapse admin API:
curl -s -X PUT 'https://matrix.domain.com/_synapse/admin/v2/users/@maubot:matrix.domain.com' \
-H 'Authorization: Bearer ADMIN_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"password":"botpassword","displayname":"Maubot","admin":false}' \
| python3 -m json.toolReplace @maubot:matrix.domain.com with your intended bot username and server name. Replace ADMIN_ACCESS_TOKEN with the token from the previous step.
Now create a login session for the bot with an explicit, stable device ID. This is the session Maubot will use — naming the device ID makes it identifiable in the admin panel and in any future session management:
curl -s -X POST 'https://matrix.domain.com/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"maubot"},"password":"botpassword","device_id":"MAUBOT_DEVICE","initial_device_display_name":"Maubot"}' \
| python3 -m json.toolThe response returns the access_token for this device session and confirms the device_id as MAUBOT_DEVICE. Do not log into this account via Element after this point. Logging in from Element creates a new device session, which is harmless — but logging out or clearing sessions from Element can revoke the Maubot device session if you're not careful about which session you're managing.
Navigate to the Maubot Manager at your configured URL. Log in with the admin credentials from config.yaml. Go to Clients and add a new client with:
- User ID:
@maubot:matrix.domain.com - Access Token: the token from the bot login curl command
- Device ID:
MAUBOT_DEVICE - Homeserver URL:
https://matrix.domain.com
Save and confirm the client status shows connected. Maubot is now operating as the bot account, with a session that holds independently of any other Matrix client session on that account.
Commands Reference
# Create host directories
mkdir -p /working/docker/synapse/data
mkdir -p /working/docker/maubot/data
# Generate Synapse configuration
docker run --rm \
-e SYNAPSE_SERVER_NAME=matrix.domain.com \
-e SYNAPSE_REPORT_STATS=no \
-v /working/docker/synapse/data:/data:Z \
matrixdotorg/synapse:latest generate
# Generate registration shared secret
openssl rand -base64 32# /working/docker/synapse/data/homeserver.yaml — edits only
public_baseurl: https://matrix.domain.com/
enable_registration: false
registration_shared_secret: "your-registration-secret-here"# /working/docker/maubot/data/config.yaml — full file
server:
hostname: 0.0.0.0
port: 29316
public_url: https://bots.domain.com
base_path: /_matrix/maubot/v1
ui_base_path: /_matrix/maubot
homeservers:
matrix.domain.com:
url: http://synapse:8008
admins:
admin: yourpassword
database: sqlite:///maubot.db
plugin_directories:
upload: /data/plugins
load:
- /data/plugins
trash: /data/trash
shared_data: /data/plugin_data# docker-compose.yml
version: "3.8"
services:
synapse:
image: matrixdotorg/synapse:latest
container_name: synapse
restart: unless-stopped
volumes:
- /working/docker/synapse/data:/data:Z
networks:
- proxy
healthcheck:
test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.synapse.rule=Host(`matrix.domain.com`)"
- "traefik.http.routers.synapse.entrypoints=websecure"
- "traefik.http.routers.synapse.tls.certresolver=letsencrypt"
- "traefik.http.services.synapse.loadbalancer.server.port=8008"
synapse-admin:
image: awesometechnologies/synapse-admin:latest
container_name: synapse-admin
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.synapse-admin.rule=Host(`admin.domain.com`)"
- "traefik.http.routers.synapse-admin.entrypoints=websecure"
- "traefik.http.routers.synapse-admin.tls.certresolver=letsencrypt"
- "traefik.http.services.synapse-admin.loadbalancer.server.port=80"
maubot:
image: dock.mau.dev/maubot/maubot:latest
container_name: maubot
restart: unless-stopped
volumes:
- /working/docker/maubot/data:/data:Z
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.maubot.rule=Host(`bots.domain.com`)"
- "traefik.http.routers.maubot.entrypoints=websecure"
- "traefik.http.routers.maubot.tls.certresolver=letsencrypt"
- "traefik.http.services.maubot.loadbalancer.server.port=29316"
networks:
proxy:
external: true# Deploy
docker compose up -d
# Check Synapse logs
docker logs synapse --tail 30
# Create admin account
docker exec -it synapse register_new_matrix_user \
--config /data/homeserver.yaml \
--admin \
--user admin \
--password 'youradminpassword'
# Get admin access token
curl -s -X POST 'https://matrix.domain.com/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"admin"},"password":"youradminpassword"}' \
| python3 -m json.tool
# Create bot account via Synapse admin API
curl -s -X PUT 'https://matrix.domain.com/_synapse/admin/v2/users/@maubot:matrix.domain.com' \
-H 'Authorization: Bearer ADMIN_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"password":"botpassword","displayname":"Maubot","admin":false}' \
| python3 -m json.tool
# Create Maubot device session (stable token — do not log into this account via Element)
curl -s -X POST 'https://matrix.domain.com/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"maubot"},"password":"botpassword","device_id":"MAUBOT_DEVICE","initial_device_display_name":"Maubot"}' \
| python3 -m json.tool