How to Deploy Maubot for Matrix with Docker

Introduction

Maubot sits in a sweet spot that many Matrix users eventually reach: simple bots become too limited, while fully custom implementations are too time-consuming to maintain. Instead of writing and operating a standalone bot for every use case, Maubot provides a structured, reusable system built around plugins, clients and instances.

The real strength lies in its flexibility. Plugins are written in Python, follow a clean structure and can be reused across multiple bots. That makes Maubot attractive not just for running existing plugins, but also for building your own automation with relatively little effort. One server, multiple bot identities, and a modular architecture that scales with your needs — that is the core idea.

At the same time, the initial setup is where many deployments fail or become unnecessarily fragile. The config.yaml is powerful but not self-explanatory, admin handling behaves differently than expected, and the interplay between Matrix access tokens, device IDs and encryption is often misunderstood. This guide aims to close exactly that gap: not just showing how to run Maubot in Docker, but explaining the critical decisions behind the setup so that the system remains understandable and maintainable in the long run.

Docker Compose Setup

Running Maubot in Docker is straightforward, and your existing setup already reflects a pragmatic approach. There is no strong reason to pin a specific version in most self-hosted environments, so using the latest tag is acceptable here.

services:
  maubot:
    image: dock.mau.dev/maubot/maubot:latest
    container_name: maubot
    hostname: maubot
    restart: unless-stopped
    command: >
      /bin/sh -c "pip install --break-system-packages emoji &&
      /opt/maubot/docker/run.sh"
    ports:
      - "29316:29316"
    volumes:
      - /volume2/docker/tuwunel/maubot/data:/data     
    environment:
      - TZ=Europe/Berlin

One architectural detail that is easy to overlook is the network design. It is good practice to place Maubot in the same Docker network as your Matrix homeserver (e.g. Synapse, Dendrite, Conduit). This allows direct container-to-container communication without leaving the Docker network stack.

In practice, this has several advantages:

  • Reduced latency between Maubot and the homeserver
  • No dependency on public DNS or reverse proxy paths for internal traffic
  • Cleaner separation between internal service communication and external exposure

If both containers share a network, you can reference the homeserver by its container name (e.g. http://synapse:8008) instead of routing traffic through the public domain. This reduces complexity and avoids subtle issues with TLS termination or misconfigured proxies. You can do so by adding the following to your compose.yaml

(.. your maubot compose.yaml ..)
networks: 
   - frontend

networks:
  frontend:
    external: true
    name: NETWORKNAME

The important detail: the network must exist before starting the stack if it is marked as external: true.

docker network create NETWORKNAME

Alternatively, if you manage your entire Matrix stack via a single Compose project, you can define the network internally and let Docker handle its lifecycle.

Extending the Container at Runtime

pip install --break-system-packages emoji && /opt/maubot/docker/run.sh

This construct injects an additional Python dependency (emoji) into the container before Maubot starts. The reason is simple: not all plugins limit themselves to the dependencies bundled with the official image.

Instead of building and maintaining a custom Docker image, this approach allows you to extend the runtime environment dynamically. The trade-off is a slightly longer startup time and the implicit trust that the package installation will succeed on each restart.

From an operational perspective, this is a pragmatic compromise:

  • Pros: no custom image maintenance, flexible, fast iteration
  • Cons: runtime dependency installation, slightly less deterministic builds

For most self-hosted setups, the benefits outweigh the downsides — especially when experimenting with plugins.

Maubot Configuration

Maubot deliberately does not follow the “everything via environment variables” pattern that many containerized applications use. Instead, it relies on a central, explicit config.yaml. This makes the system significantly more transparent and flexible in the long run — but it also means that the initial setup requires a bit more attention.

The intended workflow is therefore not to write a config from scratch, but to let Maubot generate a valid baseline first. In practice, that means starting the container once with an empty /data volume, allowing it to create its default configuration, and then stopping it again for manual adjustments.

This approach has a clear advantage: you are always working with a complete and syntactically correct configuration. It avoids subtle errors caused by missing fields or outdated examples and ensures that your setup stays aligned with upstream expectations.

Once the initial file has been created, it becomes the central control point of your deployment. Everything relevant — networking, authentication, database layout, plugin handling and logging — is defined here. Treat this file as both configuration and documentation of your setup.

A practical baseline, adapted to a typical self-hosted environment, could look like this:

# Maubot Config

database: sqlite:////data/maubot.db
crypto_database: sqlite:////data/crypto.db

database_opts:
    min_size: 1
    max_size: 10

plugin_directories:
    upload: /data/plugins
    load:
    - /data/plugins
    trash: /data/trash

plugin_databases:
    sqlite: /data/dbs
    postgres:
    postgres_max_conns_per_plugin: 3
    postgres_opts: {}

server:
    hostname: 0.0.0.0
    port: 29316
    public_url: https://domain.ltd # Please adjust
    ui_base_path: /_matrix/maubot
    plugin_base_path: /_matrix/maubot/plugin/
    override_resource_path: /opt/maubot/frontend
    unshared_secret:

homeservers:
    homeserver.ltd: # Please adjust
        url: https://domain.ltd # Please adjust
        secret:

admins:
    root: ''
    user: defineauserpassword # Please adjust

api_features:
    login: true
    plugin: true
    plugin_upload: true
    instance: true
    instance_database: true
    client: true
    client_proxy: true
    client_auth: true
    dev_open: true
    log: true

logging:
    version: 1
    formatters:
        colored:
            (): maubot.lib.color_log.ColorFormatter
            format: '[%(asctime)s] [%(levelname)s@%(name)s] %(message)s'
        normal:
            format: '[%(asctime)s] [%(levelname)s@%(name)s] %(message)s'
    handlers:
        file:
            class: logging.handlers.RotatingFileHandler
            formatter: normal
            filename: /var/log/maubot.log
            maxBytes: 10485760
            backupCount: 10
            level: WARNING
        console:
            class: logging.StreamHandler
            formatter: colored
            level: WARNING
    loggers:
        maubot:
            level: WARNING
        mau:
            level: WARNING
        aiohttp:
            level: WARNING
    root:
        level: WARNING
        handlers: [file, console]

Note: I have comment the parts which must be adjusted to your specific circumstances. Please also define a admin user by adding a user:password as shown above. The cleartext password will be encrypted after you restarted the container with the adjusted settings.

Example:

admins: root: '' 
Bob: Bobspassword

Even at a glance, three aspects are worth calling out.

First, the separation between database and crypto_database. While not strictly required, this separation creates a cleaner structure and makes it easier to reason about state, especially when debugging encryption-related issues or handling backups.

The default config suggests the following:

database: sqlite:/data/maubot.db
crypto_database: default

you should aim for the following:

database: sqlite:////data/maubot.db
crypto_database: sqlite:////data/crypto.db

Second, the server section — in particular ui_base_path. In this example, the interface is exposed under /_matrix/maubot, which follows Matrix conventions but is not particularly convenient in daily use.

This value is fully configurable, and in most setups it makes sense to simplify it:

ui_base_path: /

With this change, the web interface becomes directly accessible via your base domain instead of a nested path. Especially behind a reverse proxy, this reduces friction and keeps the setup easier to reason about.

Third, the defrault logging behavior. Maubot is relatively verbose (INFO) in the default configuration, which is helpful during initial setup but quickly becomes overwhelming in a stable environment. 

Reducing log levels improves readability and helps focus on relevant events:

loggers:
    maubot:
       level: WARNING
    mau:
       level: WARNING
    aiohttp:
       level: WARNING

This keeps operational logs concise while still surfacing important warnings and errors. For troubleshooting, individual components can temporarily be set to DEBUG without flooding the entire log output.

Configuration and Usage of Web Frontend

Once Maubot is running and the configuration has been adjusted, the next important step is the actual onboarding in the web interface. This is the point where the deployment stops being “just another container” and becomes a usable bot platform. It is also the stage where many first-time setups become confusing, because Maubot’s internal model is slightly different from what many users expect from simpler Matrix bots.

If ui_base_path is still set to the default value, the administration interface is usually reachable under a path such as https://domain.ltd/_matrix/maubot. If you changed it earlier to ui_base_path: /, which is generally the more practical choice for a dedicated deployment, the same interface is available directly at https://domain.ltd. From an operational perspective, this is the cleaner setup: it makes reverse proxying easier, avoids awkward path handling and gives the administration interface a predictable entry point.

After opening the web interface, the first thing to understand is that Maubot does not think in terms of “install plugin, bot is ready”. Instead, it separates the entire system into three distinct building blocks: clients, plugins and instances.

A client is the Matrix identity Maubot uses to connect to a homeserver. It consists of the homeserver URL, the Matrix user ID, an access token and a device ID. In other words, the client is the actual bot account from Matrix’s point of view.

A plugin is the functionality. This is the Python-based bot code packaged as an .mbp file. A plugin may provide commands, scheduled jobs, webhooks, moderation features or integrations with external services, but on its own it is just code waiting to be assigned to an identity.

An instance is the running combination of both. It binds one uploaded plugin to one configured client and turns that combination into an active bot. This distinction is important because it is one of Maubot’s biggest strengths. The same plugin can be instantiated multiple times with different clients or different configuration, and a single Maubot installation can manage several independent bot accounts without having to duplicate the plugin logic.

This model is more flexible than many users initially assume. A self-hoster might, for example, use one client for a moderation bot, another for notifications and a third one for experiments, all managed through the same Maubot installation. Likewise, the same plugin can be reused in multiple rooms or contexts without having to maintain separate bot containers. That separation between identity, code and runtime is one of the main reasons Maubot scales so much better than ad-hoc one-bot-per-service setups.

In practical terms, the onboarding sequence inside the web UI usually looks like this: first create a client, then upload one or more plugins, and finally create instances that connect those pieces. Without a client, Maubot has no Matrix identity. Without a plugin, it has no functionality. Without an instance, nothing actually runs.

Creating the first Client

The client setup is the most critical part of the initial onboarding because it defines how Maubot authenticates against your homeserver. When you add a new client in the web interface, Maubot will ask for the homeserver, the Matrix user ID, the access token and the device ID. Depending on the UI version and plugin behavior, you may also be able to define a display name and avatar.

At first glance, this may look like little more than entering bot account credentials. In reality, it is more important than that, because the combination of access token and device ID defines how Matrix sees this bot login internally. This becomes particularly relevant once encrypted rooms are involved.

For the homeserver, you typically use the same server you defined in config.yaml. If Maubot runs in the same Docker network as the homeserver, you may in some situations use the internal service address there as well, but for the client configuration itself it is usually more intuitive to keep the public-facing homeserver URL consistent with your Matrix deployment.

The Matrix user ID is the MXID of the bot account, for example @bot:domain.ltd. This should ideally be a dedicated bot account and not a repurposed personal account. Keeping bot identities separate is cleaner for permissions, easier to audit and much less error-prone when encryption enters the picture.

The access token and device ID are where many tutorials become vague, even though this is exactly the point where precision matters most.

Why Access Tokens and Device IDs deserve special Attention

Many users are initially tempted to take an access token from an existing Matrix client such as Element, paste it into Maubot and move on. That may appear convenient, but it is the wrong approach, especially when end-to-end encryption is involved.

The problem is that a Matrix login is not just a password-less session string. It is tied to a specific device identity on the homeserver. In practice, that means the token and the device ID belong together and define one Matrix device from the server’s perspective. Reusing a token from an existing client means reusing a device context that may already have encryption state, verification state and client-specific assumptions attached to it.

For Maubot, that is undesirable. A bot should have its own dedicated login and its own dedicated device. This keeps the setup predictable, avoids collisions with existing E2EE-capable clients and makes troubleshooting much easier later on. It also gives you a cleaner separation between your actual user sessions and automated bot sessions.

This is why the proper setup is not “extract token from Element”, but “create a fresh login specifically for Maubot”.

Creating a fresh Access Token and Device ID

The cleanest way to do this is to create a new Matrix login directly against the homeserver API. That way, you receive a fresh access token and can assign a dedicated, stable device ID yourself.

A request such as the following is a good pattern:

curl -X POST "https://domain.ltd/_matrix/client/v3/login" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "m.login.password",
    "identifier": {
      "type": "m.id.user",
      "user": "@bot:domain.ltd"
    },
    "password": "PASSWORD",
    "initial_device_display_name": "Maubot",
    "device_id": "MAUBOT_STABLE_ID"
  }'

This login call does several useful things at once. It authenticates the bot account with its password, requests a new session, assigns a human-readable display name to the device and, most importantly, explicitly sets a stable device_id. The result is a fresh Matrix login dedicated to Maubot.

The homeserver will respond with a payload that includes the relevant values:

{
  "access_token": "syt_xxxxxxx",
  "device_id": "MAUBOT_STABLE_ID",
  "user_id": "@bot:domain.ltd"
}

These are the values you then enter in the Maubot client form. The MXID goes into the user field, the returned token into the access token field, and the returned device ID into the corresponding device field.

This explicit creation flow is preferable for several reasons. First, it makes the device identity transparent and reproducible. Second, it ensures that Maubot gets a login that has not already been used in another E2EE-capable client. Third, it makes your homeserver’s device list easier to understand later, because the Maubot login appears as a clearly named, dedicated bot device rather than a recycled generic session.

A stable custom device ID such as MAUBOT_MAIN, MAUBOT_ALERTS or MAUBOT_MODBOT is especially useful in larger environments. It allows you to immediately identify the purpose of a device in the homeserver UI or API and avoids the ambiguity of random device identifiers. This is not just cosmetic. It becomes valuable when you later audit devices, rotate credentials or troubleshoot encryption issues.

Avatar URL and Profile Handling

When configuring a client, you may also want to define an avatar. This detail sounds minor, but it is worth handling properly. Maubot expects an mxc:// URI for the avatar rather than a normal HTTPS image URL. In other words, it wants a Matrix media reference, not a web link to an image file.

The most reliable way to get such a URI is to upload an image to your own Matrix server through a regular Matrix client, then inspect the uploaded image (by clicking on the respective picture and click on show raw data) and copy the corresponding mxc:// reference. This gives you a stable media URI that Maubot can use consistently.

While the web UI may offer an upload button for the avatar as well, relying on that can be less robust in containerized deployments. In some cases, avatars configured that way may not survive a restart as cleanly as expected. Using an existing mxc:// URI from your own homeserver is therefore the safer and more predictable method.

More broadly, this also reflects a useful principle for Maubot administration: where possible, use stable Matrix-native identifiers rather than convenience shortcuts that hide implementation details. That approach usually pays off later when you need to move, rebuild or troubleshoot the setup.

Uploading Plugins and turning them into working Bots

Once the client exists, the next step is to upload plugins. This is where Maubot starts to feel less like a generic administration tool and more like a bot platform. Plugins are uploaded as .mbp packages and then appear in the UI as available functionality.

But even here, Maubot remains deliberately modular: uploading a plugin does not activate it. That only happens when you create an instance. This distinction matters because it allows the same plugin to be reused in multiple ways. You could, for example, upload one notification plugin once, but instantiate it with different Matrix clients or with different per-instance settings for separate rooms and purposes.

That is why the order of operations matters so much during the first setup. The web UI is not asking for arbitrary objects; it is building a structured relationship:

  • the client defines who the bot is,
  • the plugin defines what it can do,
  • the instance defines where and how that functionality actually runs.

Once this clicks, Maubot becomes much easier to reason about. Instead of seeing the interface as a collection of separate menus, it becomes clear that you are assembling a bot from three reusable layers.

Conclusion

For a first deployment, the decisive question is not merely whether Maubot starts successfully, but whether the setup is built on a clean and understandable foundation. You should be able to reach the web frontend through a predictable URL, ideally directly via the base domain if ui_base_path has been set to /. You should create at least one dedicated Matrix bot account, or at minimum a dedicated bot login, and you should generate the required credentials explicitly instead of borrowing them from an existing client. Just as importantly, you need to understand that a bot in Maubot only becomes active once a plugin has been instantiated against a configured client.

These early decisions have long-term consequences. A client that was set up cleanly with its own access token and stable device ID will still be understandable months later, both in Maubot itself and on the homeserver side. A hastily reused token from another client may appear to work at first, but tends to create confusion as soon as encryption, verification or device state become relevant. The same applies to Maubot’s internal model as a whole: once the distinction between clients, plugins and instances is clear, the interface stops feeling abstract and starts to reflect a coherent operating model.

That is precisely why this stage deserves more attention than it often gets in shorter installation guides. The container itself is easy to deploy. The real setup begins when Maubot becomes a proper Matrix participant with its own identity, credentials and runtime structure.

Seen from that perspective, Maubot is deceptively simple to deploy. Its real value only becomes apparent once the underlying concepts are understood and applied correctly. A well-structured config.yaml, clean network integration and a deliberate approach to Matrix authentication are what turn a quick container deployment into a reliable long-term installation.

The separation into clients, plugins and instances is not just an implementation detail, but the central design principle that makes Maubot scalable. It allows you to reuse logic, separate responsibilities and manage multiple bot identities without duplicating infrastructure. At the same time, it requires a different mindset than simpler one-bot solutions, because identity, functionality and execution are intentionally kept apart.

Particular care should therefore be taken when creating Matrix credentials. Access tokens and device IDs should be treated as first-class configuration elements, not as disposable side effects of a login. A dedicated login with a stable device ID is not only cleaner, but also avoids an entire class of issues around encryption and device state that otherwise tend to appear later — and usually when troubleshooting is most inconvenient.

If deployed with these principles in mind, Maubot becomes more than just a bot runner. It turns into a structured automation layer for Matrix that can grow with your requirements, remain maintainable over time and integrate cleanly into a privacy-conscious, self-hosted environment. The next logical step is therefore to look at useful plugins — not only in terms of features, but also with a critical eye on maintainability, external dependencies and data flows. That is where Maubot’s flexibility begins to translate into real practical value.

Leave a Reply

Your email address will not be published. Required fields are marked *