how to create a matrix server with external app bridges

how to create a matrix server with external app bridges

you know what - I'm going to share what I did in order to create my synapse (matrix protocol) server, along with docker compose files and NPM configs.


I am kind of a noob to everything, so full illiteracy in this is a recent memory. I think that may be useful for other noobs since many guides expect that you have a ton of base knowledge... that said, some more advanced users may cringe at my incomplete understanding.


do you know about docker? its containerized software where dependencies are all packaged together nicely, apps network together, and you can break things without too much consequence.

Home
Docker Documentation is the official Docker library of resources, manuals, and guides to help you containerize applications.

you can use something called a docker compose file in order to build all environment variables, network and directory mappings, where to pull the image, and basic container settings for an entire stack of apps.

make a folder for your stack, and put the docker compose file in it. there will be other subdirectories to make as well, which we'll get to later.

my docker-compose.yml

services:
  # this is the main synapse server
  synapse:
    container_name: synapse
    image: docker.io/matrixdotorg/synapse:latest
    restart: unless-stopped
    environment:
      - SYNAPSE_REPORT_STATS=yes
      # youre going to create a homeserver.yaml
      - SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
    volumes:
      - ./data:/data
    depends_on:
      matrix-postgres:
        condition: service_healthy
    ports:
      - 8008:8008/tcp
    networks:
      - matrix-internal
      - matrix-proxy
  # this is the database
  matrix-postgres:
    container_name: matrix-postgres
    image: docker.io/postgres:15-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s
    environment:
      - POSTGRES_DB=synapsedb
      - POSTGRES_USER=synapse
      # generate a random string w openssl rand -hex 32
      - POSTGRES_PASSWORD=foooooooooooooo
      # https://element-hq.github.io/synapse/latest/postgres.html#set-up-database
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
    volumes:
      - ./schemas:/var/lib/postgresql/data
      # this is a script to create additional DBs for the mautrix-bridges
      - ./init-db:/docker-entrypoint-initdb.d 
    networks:
      - matrix-internal

  matrix-turnify:
    # this is an older calling tech as fallback
    container_name: matrix-turnify
    image: ghcr.io/bpbradley/matrix-turnify:latest
    env_file:
      - ./turnify.env
    restart: unless-stopped
    ports:
      - 4499:4499/tcp
    networks:
      - matrix-proxy
  # this is a service that provides tokens to initiate calls on livekit
  matrix-jwt:
    container_name: matrix-jwt
    image: ghcr.io/element-hq/lk-jwt-service:latest
    restart: unless-stopped
    # livekit key and secret can be generated
    # key: openssl rand -hex 12
    # secret: openssl rand -hex 32
    # or, you can run livekit first and generate them
    # with livekit-server generate-keys
    environment:
      - LIVEKIT_JWT_BIND=0.0.0.0:8080
      - LIVEKIT_URL=wss://rtc.domain.name
      - LIVEKIT_KEY=foo
      - LIVEKIT_SECRET=bar
      - LIVEKIT_FULL_ACCESS_HOMESERVERS=rootdomain.name
      - MATRIX_HOMESERVER=https://matrix.domain.name
    networks:
      - matrix-proxy
  # this is the newer calling tech - jwt passes this a token
  matrix-livekit:
    container_name: matrix-livekit
    image: livekit/livekit-server:latest
    command: --config /etc/livekit.yaml
    ports:
      - 7888:7888/tcp
      - 7889:7889/udp
      - 3478:3478/udp
      - 5349:5349/tcp
    restart: unless-stopped
    volumes:
      # I had to map my ssl certs from npm into the container for auth
      - /root/server/path/to/NPM/certdir:/certs:ro
      # you have to create this too (more info on that below)
      - ./livekit.yaml:/etc/livekit.yaml:ro
    networks:
      - matrix-proxy
  # this is a bridge for meta applications - ig fb whatsapp
  # since fb allows encryption and ig does not
  # i'd make separate containers for each
  # with separate configs
  # I havent created anything but instagram but I will later
  mautrix-meta:
    container_name: mautrix-meta
    image: dock.mau.dev/mautrix/meta:latest
    command: sh -c "sleep 60 && /usr/bin/mautrix-meta"
    restart: unless-stopped
    depends_on:
      - synapse
      - matrix-postgres
    volumes:
      - ./mautrix-meta:/data
    networks:
      - matrix-internal
      - matrix-proxy
  # this is the bridge for signal
  mautrix-signal:
    container_name: mautrix-signal
    image: dock.mau.dev/mautrix/signal:latest
    command: sh -c "sleep 60 && /usr/bin/mautrix-signal"
    restart: unless-stopped
    depends_on:
      - synapse
      - matrix-postgres
    volumes:
      - ./mautrix-signal:/data
    networks:
      - matrix-internal
      - matrix-proxy

networks:
  matrix-internal:
    driver: bridge
  matrix-proxy:
    external: true
      

you deploy it with:
docker compose up -d

you take it down with
docker compose down

you can wipe the docker images for a full reset with
docker compose down -v

you can start / stop / restart individual containers from the compose without bringing down the entire stack:
docker compose [docker command] [container name]

but dont deploy it yet!

there are several configs to create before you deploy - in order on the compose:

  • ./data/homeserver.yaml
  • ./init-db/create-multiple-dbs.sh
  • ./turnify.env
  • ./livekit.yaml
  • ./mautrix-meta/config.yaml
  • ./mautrix-signal/config.yaml
  • ./data/doublepuppet.yaml

keep in mind you can just make more and more mautrix bridges if you want - theres so many


./data/homeserver.yaml

homeserver.yaml is generated for you initially, then you customize it.

btw i wont take it personally if you read the documentation
Configuration Manual - Synapse

To generate: be in the dir where you have your docker compose and run this just to pull synapse and generate the config.

important: set your SYNAPSE_SERVER_NAME in this command (should be just ur root domain):

docker run -it --rm --mount type=volume,src=infra_synapse_data,dst=/data -e SYNAPSE_SERVER_NAME=example.org -e SYNAPSE_REPORT_STATS=yes matrixdotorg/synapse:v1.63.0 generate

then you will get a homeserver.yaml in data that you can further customize:

# Configuration file for Synapse.
#
# This is a YAML file: see [1] for a quick introduction. Note in particular
# that *indentation is important*: all the elements of a list or dictionary
# should have the same indentation.
#
# [1] https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html
#
# For more information on how to configure Synapse, including a complete accounting of
# each option, go to docs/usage/configuration/config_documentation.md or
# https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html
server_name: "rootdomain.name"
public_baseurl: "https://matrix.domain.name"
pid_file: /data/homeserver.pid
listeners:
  - port: 8008
    tls: false
    type: http
    x_forwarded: true
    resources:
      - names: [client, federation]
        compress: false
#psycopg2 is postgres
database:
  name: psycopg2
  args:
    database: synapsedb
    host: matrix-postgres
    port: 5432
    cp_min: 5
    cp_max: 10
    user: synapse
    password: #whatyougenerated

#this wipes media from db
#local media is stuff in your bridge and your user/rooms chats
##but if its federated with another person's server, they may keep a copy!
#remote media means servers you or your users joined outside your node
media_retention:
  local_media_lifetime: 365d
  remote_media_lifetime: 30d

#i currently have these commented out:
#allow_public_rooms_over_federation: true
#allow_public_rooms_without_auth: false
#restrict_public_rooms_to_local_users: false
#these are good settings imo:
require_auth_for_profile_requests: true
limit_profile_requests_to_users_who_share_rooms: false

experimental_features:
  # MSC3266: Room summary API. Used for knocking over federation
  msc3266_enabled: true
  # MSC4222: allow clients to correctly track the state of the room.
  msc4222_enabled: true
  # NEW: Enables the delayed event logic for MatrixRTC signaling
  msc4140_enabled: true
  #sliding sync
  msc4186_enabled: true
  #connectivity-to-mautrix
  msc2409_to_device_messages_enabled: true
  msc3202_device_masquerading: true
  msc3202_transaction_extensions: true

# The maximum allowed duration by which sent events can be delayed
max_event_delay_duration: 24h

rc_message:
  per_second: 0.5
  burst_count: 30

rc_delayed_event_mgmt:
  per_second: 1
  burst_count: 20

#this should already exist in your generated config:
log_config: "/data/domain.name.log.config"
media_store_path: /data/media_store

#i keep reg off - my friends join me from the matrix.org node
#there is a matrix registration bot (I didnt add it on this implementation)
#or you can just manually register people in synapse shell,
#which is what i prefer

enable_registration: false
registration_requires_token: true
#use pwgen -s 128 1 or openssl rand -hex 32
registration_shared_secret: "<YOUR_SECURE_RANDOM_STRING>"
report_stats: true
macaroon_secret_key: "<ANTOTHER_STRING>"
form_secret: "<YET_ANOTHER_STRING>"
#pretty sure the key path is generated for you initially
signing_key_path: "/data/domain.name.signing.key"
trusted_key_servers:
  - server_name: "matrix.org"

#this is where you declare your apps
#doublepuppeting is a feature of the mautrix bots that makes the #experience on the bridge much more fluid - i dont really know 
#what its like without it but I heard its better
app_service_config_files:
  - "/data/doublepuppet.yaml"
  - "/data/matrix-registration.yaml"
  - "/data/signal-registration.yaml"

#this is something i commented out and was going to use once
#it kinda passes shared secrets around thru ur bridge apps i dunno

#modules:
#  - module: shared_secret_authenticator.SharedSecretAuthProvider
#    config:
#      shared_secret: "redacted"
# vim:ft=yaml

./init-db/create-multiple-dbs.sh

#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE DATABASE mautrix_meta;
    CREATE DATABASE mautrix_signal;
EOSQL

if you want more mautrix bridges, just make more DBs. this is just on the deploy, if you want to do it later, (in postgres) you can just do:

psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE DATABASE mautrix_foo;"


on that note, did I mention you can shell into any container with

docker exec -it [container] /bin/bash

./turnify.env

SYNAPSE_BASE_URL=http://synapse:8008
CF_TURN_TOKEN_ID=redacted
CF_TURN_API_TOKEN=redacted
TURN_CREDENTIAL_TTL_SECONDS=86400
LOG_LEVEL=info

I set it up with cloudflare TURN tokens, its probably pretty rare that TURN is utilized... its the old way to call, but i have it as a fallback. you could ignore the entire turnify section if you wanted.


./livekit.yaml

livekit is the new calling method for matrix

port: 7880
rtc:
  tcp_port: 7888
  udp_port: 7889
  use_external_ip: true
  node_ip: yourpublicip
room:
  auto_create: true

#as you can see TURN is also in livekit, its like a fallback for livekit's 
#calling if theres some negotiation issue - i barely understand it
#forward these ports if you want turn to work
turn:
  enabled: true
  domain: rtc.domain.name
  tls_port: 5349
  udp_port: 443
  external_tls: false
  cert_file: /certs/fullchain3.pem
  key_file: /certs/privkey3.pem

#generate these in livekit with
##livekit-server generate-keys
#or use the two methods I mentioned in the compose under matrix-jwt
keys:
  foo: bar

logging:
  level: info

./mautrix-meta/config.yaml & ./mautrix-signal/config.yaml

these configs are pulled from mautrix's documentation and I customized them. they have really thorough commentary on every item.

Initial bridge config - mautrix-bridges

FYI, these configs are never touched by synapse. Did you notice: synapse is just looking at something called *-registration.yaml (referenced in the homeserver.yaml)??

What happens is, you create your mautrix config, you run the service for the first time, and the registration file generates. If you make changes to the config later, you'll (potentially) need to regenerate the registration file. Some changes don't need you to, though.

IN MY CASE - the registration.yaml is created in the individual mautrix-service subdirectory, and I rename it (to the names declared in homeserver.yaml), move it into the data dir, and set the correct user:grp and permissions (btw as an unraid user its 991:991 and 664).

There are ways to simply declare the registration file name / path in the config, set the user:group correctly on the mautrix-service compose, and running the service will plop it right into your data folder automatically... but I didn't do that.


./data/doublepuppet.yaml

btw, docs

Double puppeting - mautrix-bridges
# The ID doesn't really matter, put whatever you want.
id: doublepuppet
# The URL is intentionally left empty (null), as the homeserver shouldn't
# push events anywhere for this extra appservice. If you use a
# non-spec-compliant server, you may need to put some fake URL here.
url:
# Generate random strings for these three fields. Only the as_token really
# matters, hs_token is never used because there's no url, and the default
# user (sender_localpart) is never used either.
# once again this should do the trick for these:
# openssl rand -hex 32
as_token: redacted
hs_token: redacted
sender_localpart: redacted
# Bridges don't like ratelimiting. This should only apply when using the
# as_token, normal user tokens will still be ratelimited.
rate_limited: false
namespaces:
  users:
  # Replace your\.domain with your server name (escape dots for regex)
  - regex: '@.*:domain.name'
    # This must be false so the appservice doesn't take over all users completely.
    exclusive: false
    ```

once you have synapse running, you can run

register_new_matrix_user -c homeserver.yaml

guess I should mention this, after your server is online and totally tested, you'll have bot users for mautrix bridges (DM them and they'll give you instructions on how to connect to your services). You defined the bot user names in the various mautrix-bridge config files.

You can test if doublepuppeting is working by sending them "ping-matrix", and you can login by sending them "login".

Remember doublepuppeting only needs to be set up once for all your bridges.


NGINX PROXY MANAGER CONFIGS

If you didn't have any reverse proxy set up, you could roll it all into this compose but I already had NPM running with lets encrypt certs etc.

You're going to want to attach your reverse proxy to the docker network we created in the compose (matrix-proxy).

I use unraid, and I installed NPM in the gui. So for that I just add:

--network matrix-proxy

to "extra parameters" under the advanced settings on NPM in unraid.

on to the NPM setup...


The integration and handoff of a single user across these apps requires CORS on everything - thats not a GUI option in nginx-proxy-manager, it all has to be declared in the headers.


First you need some instructions on the root domain. The goal of this is so that your user name in matrix looks like:

@coolguy:hackerman.com

instead of a disgusting name like:

@lameguy:matrix.hackerman.com

root domain

gui - main domain goes to my blog (this website), i have block common exploits and websockets on
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
client_max_body_size 64M;
proxy_intercept_errors on;

proxy_ssl_server_name on;
proxy_ssl_name $host;
proxy_ssl_session_reuse off;


location = /.well-known/matrix/server {
    default_type application/json;
    add_header Access-Control-Allow-Origin * always;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    return 200 '{ "m.server": "matrix.your.domain:443" }';
}

location = /.well-known/matrix/client {
  default_type application/json;
  add_header Access-Control-Allow-Origin * always;
  return 200 '{
  "m.homeserver":
    {"base_url": "https://matrix.your.domain"},
  "org.matrix.msc4143.rtc_foci": [
    {"type": "livekit",
      "livekit_service_url": "https://rtc.your.domain"}]
  }';
}

what you're seeing here are instructions for the matrix clients, pointing them to certain services at other domains - so from the user perspective they just connect to your domain name.


matrix.your.domain

gui: http synapse:8008 - i have websockets off here in the ui but block common exploits on, and cache assets off
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;

proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
client_max_body_size 64M;

location = /.well-known/matrix/server {
    default_type application/json;
    add_header Access-Control-Allow-Origin * always;
    return 200 '{"m.server": "matrix.your.domain:443"}';
}

location = /.well-known/matrix/client {
    default_type application/json;
    add_header Access-Control-Allow-Origin * always;
    return 200 '{
      "m.homeserver": {"base_url": "https://matrix.your.domain"},
      "org.matrix.msc4143.rtc_foci": [
        {"type": "livekit", "livekit_service_url": "https://rtc.your.domain"}
      ]
    }';
}

location ^~ /_matrix/client/api/v1/voip/turnServer {
    add_header Access-Control-Allow-Origin * always;
    proxy_pass http://matrix-turnify:4499;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;
}

location ^~ /_matrix/client/r0/voip/turnServer {
    add_header Access-Control-Allow-Origin * always;
    proxy_pass http://matrix-turnify:4499;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;
}

location ^~ /_matrix/client/v3/voip/turnServer {
    add_header Access-Control-Allow-Origin * always;
    proxy_pass http://matrix-turnify:4499;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;
}

location ^~ /_matrix/client/unstable/voip/turnServer {
    add_header Access-Control-Allow-Origin * always;
    proxy_pass http://matrix-turnify:4499;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;
}

location /_matrix {
    proxy_pass http://synapse:8008;
    proxy_http_version 1.1;
}

location /_synapse/client {
    proxy_pass http://synapse:8008;
    proxy_http_version 1.1;
}

you see all these different locations going to the same proxy_pass ... i did have them all in one entry with a regex but it kept breaking so i just broke them up explicitly. Could be a me problem.


rtc.your.domain

gui: http to matrix-livekit:7880, block common exploits and websockets on
location = /.well-known/matrix/server {
    default_type application/json;
    add_header Access-Control-Allow-Origin * always;
    return 200 '{"m.server":"matrix.your.domain:443"}';
}

location = /.well-known/matrix/client {
    default_type application/json;
    add_header Access-Control-Allow-Origin * always;
    add_header Cache-Control "no-cache" always;
    return 200 '{"m.homeserver":{"base_url":"https://matrix.your.domain"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.your.domain"}]}';
}

location ~ ^/(livekit/jwt|sfu/get|get_token|healthz|rtc/) {
    # Handle CORS preflight
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin * always;
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token" always;
        add_header Content-Length 0;
        return 204;
    }
    proxy_hide_header Access-Control-Allow-Origin;
    add_header Access-Control-Allow-Origin * always;
    add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token" always;
    proxy_pass http://matrix-jwt:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
    proxy_pass http://matrix-livekit:7880;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 7200s;
    proxy_send_timeout 7200s;
    proxy_buffering off;
    proxy_cache off;
    proxy_ignore_client_abort on;
}

Are these NPM configs cringe? Could be. Maybe they could be cleaned up.. even though many posts I saw did not tell me to explicitly define .well-known/matrix/server and .well-known/matrix/client at every level, I found it kept breaking over and over again without that declared on every subdomain.


THE WAN

Now how does the internet get in? well this is my set up, and maybe you want to do it differently.

I have zerotrust cloudflare tunnels sending the root domain, matrix.domain and rtc.domain to Nginx's docker hostname:port, and a docker network between the "cloudflared" docker image and my NPM.

Cloudflare edge –> cloudflared -> NPM -> services

I tried so hard not to port forward but its impossible if you want to host the calls locally, cloudflare tunnels just handle http(s), once the request to create a call comes in, a connection is made between your server/network and the user.

Forwards you need based on the configs we shared in this post are 80, 443, 3478(udp), 5349(tcp), 7888(tcp), 7889(udp), 8448(tcp).

actually, you may not need 8448 for federation in this case, that may be from my old matrix set up... listen, I really tried not to expose any ports, but considering my last set up had like 1 thousand ports open for livekit, I call it a win.


testing tools that have saved my sanity:

Matrix Federation Tester
testmatrix
Matrix Sanity Tester

a pretty successful test on testmatrix will prompt you to go to the livekit tester for a final test (testmatrix will give you your url and key):

LiveKit | Build voice, video, and physical AI agents
An open source framework and developer platform for building, testing, deploying, scaling, and observing agents in production.

people's posts I read along the way:

Run your own Matrix chat server, a guide to installing Synapse on Ubuntu 22.04
Having recently dived deeper into the world of Matrix, I’ve set up my own home server by installing Synapse. It’s been a fun journey! Here’s a guide on how to do just that, including improvements and an example starting configuration
Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose
Learn how to install Element Call alongside your Synapse instance. Enhance your matrix stack with next-gen VOIP and encrypted video calling
MatrixRTC aka Element-call setup (Geek warning) – Spaetzblog
Enable mautrix-imessage Double-Puppeting with Synapse Shared Secret Auth
A guide to configure double-puppeting for mautrix-imessage on modern Synapse versions using the Shared Secret Authenticator module for legacy compatibility.

good luck.... lmao