oscarmlage oscarmlage

The bridges of the Matrix

Written by oscarmlage on

Once, I met a robot who shared some wise words with me. It said, «I am a bridge, built to connect worlds that were never meant to meet. On one side, a web of infinite possibilities; on the other, the raw, unfiltered depths of reality. I don’t judge the messages I carry and simply ensure they reach the other side.».

That was my experience with Matrix years ago—a cursed bridge connecting my reality with the rest of the world. One day, I got tired of it. It had become unsustainable because of something called federation. Unsustainable in terms of both traffic and data volume. Too much computational overhead.

These days, I’ve returned to The Matrix. After (hopefully) learning from my mistakes, I realized I missed those white letters on a black background that let me gather all communication into a single terminal.

Note: We’ll be using Docker for this entire setup, so docker and docker compose are a must if you want to follow along with this post.

DNS

What do we need in terms of DNS? Just two domains or subdomains: one for our homeserver to be visible and reachable, and another, optionally, for the web client. For example:

Pointing them to the server where we’ll install this entire stack is enough.

Synapse

We’ll run Synapse with PostgreSQL on port 8008. But before diving deeper, the first step is to generate a base configuration to work with. To do this, execute the following (having already created a directory to store this configuration and passing it as a volume, e.g., mkdir -p data/synapse):

$ sudo docker run -it --rm \
    -v "./data/synapse:/data" \
    -e SYNAPSE_SERVER_NAME=matrix.example.com \
    -e SYNAPSE_REPORT_STATS=yes \
    matrixdotorg/synapse:latest generate

This will create a homeserver.yaml file in the ./data/synapse/ directory, which we’ll modify to include the connection parameters for PostgreSQL:

database:  
    name: psycopg2  
    args:  
    user: postgres  
    password: XXX  
    database: synapse  
    host: postgres  
    cp_min: 5  
    cp_max: 10

Since there are some variables we don’t want to hardcode in the configuration of this entire stack, we’ll create an environment file (touch .env) and add specific values for different components of the stack. For Synapse, it will look like this:

# Synapse
SYNAPSE_HOST=matrix.example.com
SYNAPSE_PORT=8008

Elementweb

We’ll run Elementweb on port 9999, mapped to 80. Like with Synapse, we first need to create an initial configuration. On the official Elementweb website, they provide an example that we only need to tweak slightly by editing the first lines. Save this config.json file, for example, in ./data/elementweb/:

{
    "default_server_config": {
        "m.homeserver": {
            "base_url": "https://matrix.example.com",
            "server_name": "matrix.example.com"
        },
        "m.identity_server": {
            "base_url": "https://vector.im"
        }
    },
    ...
}

Additionally, we’ll add these values to our .env file:

# Elementweb
ELEMENT_HOST=chat.example.com
ELEMENT_PORT=80

PostgreSQL

In my previous experience with this stack, I went through two phases. The first was just to see how everything worked, so I chose SQLite as the database system. I immediately regretted it when I saw the traffic and data generated by federation. The second phase came when I had to migrate everything to PostgreSQL.

With things running more smoothly, the plan now—even without federation in the mix—is to use PostgreSQL from the start. That way, all we need are credentials; Docker will handle the rest. Just add these values to the .env file:

# PostgreSQL
POSTGRES_DB=synapse
POSTGRES_USER=postgres
POSTGRES_PASSWORD=XXX

Docker

Now that we have the configuration ready, it’s time to shape it into a compose file. Let’s get to it. Here’s an overview of the folder structure:

docker-matrix/
├── data/
│   ├── elementweb/
│   │   ├── config.json
│   ├── synapse/
│   │   ├── homeserver.yaml
│   │   ├── ...
│   ├── postgres/
│   ├── backup/
├── docker-compose.yml
├── .env

The docker-compose.yml file would look like this:

version: "3.1"

services:

    postgres:
        image: postgres:14
        env_file:
          - ./.env
        container_name: postgres
        hostname: postgres
        ports:
          - 5432:5433
        environment:
          - POSTGRES_DB=${POSTGRES_DB}
          - POSTGRES_USER=${POSTGRES_USER}
          - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
          - POSTGRES_INITDB_ARGS=--encoding='UTF8' --lc-collate='C' --lc-ctype='C'
        volumes:
          - ./data/postgres/:/var/lib/postgresql/data
        restart: unless-stopped
        networks:
          default:
            ipv4_address: 10.10.10.2

    adminer:
        image: adminer:4.8.1
        container_name: adminer
        hostname: adminer
        ports:
          - 9899:8080
        restart: unless-stopped
        networks:
          default:
            ipv4_address: 10.10.10.3

    elementweb:
        image: vectorim/element-web:latest
        env_file:
          - ./.env
        container_name: elementweb
        hostname: elementweb
        environment:
          VIRTUAL_HOST: ${ELEMENT_HOST}
          VIRTUAL_PORT: ${ELEMENT_PORT}
        ports:
            - 9999:${ELEMENT_PORT}
        volumes:
            - ./data/elementweb/config.json:/app/config.json:rw
        restart: unless-stopped
        networks:
          default:
            ipv4_address: 10.10.10.4

    synapse:
        image: matrixdotorg/synapse:latest
        env_file:
          - ./.env
        container_name: synapse
        hostname: synapse
        environment:
          VIRTUAL_HOST: ${SYNAPSE_HOST}
          VIRTUAL_PORT: ${SYNAPSE_PORT}
          SYNAPSE_SERVER_NAME: ${SYNAPSE_HOST}
          SYNAPSE_REPORT_STATS: "no"
        ports:
            - ${SYNAPSE_PORT}:${SYNAPSE_PORT}
        volumes:
            - ./data/synapse:/data:rw
        depends_on:
            - postgres
        restart: unless-stopped
        networks:
          default:
            ipv4_address: 10.10.10.5

networks:
  default:
    external:
      name: matrix

And here’s the complete .env file:

# General
DOCKER_ENV=production
PROJECT_NAME=matrix

# PostgreSQL
POSTGRES_DB=synapse
POSTGRES_USER=postgres
POSTGRES_PASSWORD=XXX

# Elementweb
ELEMENT_HOST=chat.example.com
ELEMENT_PORT=80

# Synapse
SYNAPSE_HOST=matrix.example.com
SYNAPSE_PORT=8008

Before anything else, we’ll create the required network:

$ docker network create --driver=bridge --subnet=10.10.10.0/24 --gateway=10.10.10.1 matrix

Once everything is set up, run docker compose up -d to start all the services and wait a few minutes for everything to become healthy.

After launching, since our Synapse has registration disabled, we’ll register a user via the console like this:

$ docker exec -it synapse bash
$ register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008
New user locaslopart [root]: theuser
Password: thepass
Confirm password:
Make admin [no]: yes
Sending registration request...
Success

And that’s it! The web interface is now functional, and we can access Elementweb via https://chat.example.com and log in with the credentials created earlier.

The real fun, though, lies in the bridges—those are still to be configured.

Heisenbridge

Heisenbridge, will allow us to communicate with IRC networks directly from the Matrix interface. As always, the first step is to generate its configuration and save it—in this case, in ./data/synapse. We’ll store it here because we also need to share it with Synapse’s volume:

$ docker run --rm \
    -v "./data/synapse:/data" \
    hif1/heisenbridge \
    -c /data/heisenbridge.yaml --generate

This will generate a heisenbridge.yaml file, which we’ll edit to include the URL Synapse will use to connect. Specifically, we’ll need to add the IP address assigned to Heisenbridge in docker-compose.yml. For consistency, we’ll use 10.10.10.6, so:

id: heisenbridge
url: http://10.10.10.6:9898
as_token: ...
hs:token: ...

Next, we need to register heisenbridge as an application service in synapse. To do this, edit synapse’s homeserver.yamland add the following:

app_service_config_files:
  - "/data/heisenbridge.yaml"

Now we’re ready to add another service to our docker-compose.yml. Pay attention to the command field, where we’ll need to specify the Synapse server URL—in this case, http://10.10.10.5:8008—to ensure proper bidirectional communication:

services:
    ...
    heisenbridge:
        image: hif1/heisenbridge:latest
        container_name: heisenbridge
        hostname: heisenbridge
        ports:
            - 9898:9898
        volumes:
            - ./data/synapse/heisenbridge.yaml:/heisenbridge.yaml:rw
        command: ["-c", "/heisenbridge.yaml", "-vvv", "http://10.10.10.5:8008"]
        depends_on:
            - synapse
        restart: unless-stopped
        networks:
          default:
            ipv4_address: 10.10.10.6

After restarting the services (check the logs to ensure everything is running correctly), you should be able to open a private chat with @heisenbridge:chat.example.com and start adding the IRC networks you want:

(heisenbridge) ADDNETWORK Libera.Chat
(heisenbridge) ADDSERVER Libera.Chat irc.libera.chat 6697 --tls
(heisenbridge) OPEN Libera.Chat
(Libera.Chat) CONNECT
(Libera.Chat) NICK mynick
(Libera.Chat) JOIN #my_channel

Other bridges

Other bridges follow a similar process:

  1. Generate the configuration using a docker run command or by downloading an example from the bridge’s documentation. Save it in a shared location accessible to both the bridge and synapse.
  2. Edit the configuration to include the synapse server’s URL or IP address.
  3. Update synapse’s homeserver.yaml to register the new bridge as an application service.
  4. Restart the services.
  5. Open a private chat with the corresponding bot for the bridge (@whateverbot:chat.example.com) and follow its instructions. You can also use the helpcommand for more guidance.

Some bridges have caused issues when they required a dedicated database that wasn’t created automatically. To resolve this, I’ve included an adminer service in docker-compose.yml. You can use it to log in and manually create any necessary databases.

Centralized Messaging via Console

Centralizing everything is great, but the goal is console-based access, right? Being able to manage all your messaging platforms (IRCTelegramWhatsAppSignalDiscordSlack…) from a terminal is possible using weechat and the weechat-matrix client.

Simply install it in whatever way works best for you, and once launched, connect to your Matrix server like this:

/matrix server add matrix matrix.example.com
/set matrix.server.matrix.username theuser
/set matrix.server.matrix.password thepass
/set matrix.server.matrix.autoconnect on
/matrix connect matrix

Once connected, the magic happens. All your conversations, both public and private, will automatically populate your weechat channel list.

Bridges like these are more than tools—they’re connections that unify scattered communication into a single, seamless flow. With Matrix as the backbone and tools like heisenbridge, mautrix-* and weechat, the terminal becomes not just a workspace but a gateway to countless networks. The simplicity of white text on a black screen hides the complexity beneath, but that’s the beauty of it: everything, everywhere, just works.