The bridges of the Matrix
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:
- matrix.example.com
- chat.example.com
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.yaml
and 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:
- 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. - Edit the configuration to include the synapse server’s URL or IP address.
- Update synapse’s
homeserver.yaml
to register the new bridge as an application service. - Restart the services.
- Open a private chat with the corresponding bot for the bridge (
@whateverbot:chat.example.com
) and follow its instructions. You can also use thehelp
command 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 (IRC
, Telegram
, WhatsApp
, Signal
, Discord
, Slack
…) 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.