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.

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
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 generatethen 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.
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
# 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:
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):
people's posts I read along the way:




good luck.... lmao




Comments ()