My Experience Self-hosting my Personal Matrix Server
I’ve been self-hosting my own things (perhaps foolishly) for some time now, and I was contemplating running a Matrix server for a little while. When Discord announced the intention to go public, I figured now was as good of a time as any to give it a crack. Here’s how I did it, and what I learned.
Notice
As with anything, exposing services to the public internet is not without risk! Make sure if you’re punching holes in your firewall, you’re doing so intentionally and carefully. Even better, pay for hosting it on a VPS or something. Just be careful, ok?
Some Things I Assume You Know/Have
- Docker
- This can be Docker Desktop for Windows/Mac if you want, but… I wouldn’t recommend it. Trust me.
- A public domain
- I’m going to write this as though I own
my.site
- Everywhere you see
my.site
, please replace with your own domain
- I’m going to write this as though I own
- Networking and firewall rules set up such that
my.site:443
hits a reverse proxy you have control of - That reverse proxy is Caddy, and is listening on port 443 and 80 with automatic HTTPS upgrading
- Caddy’s just what I use, and will therefore write about, but feel free to use traefik or nginx or whatever the new hotness is when you’re reading this.
- Please make sure your TLS certs are valid
- or just use Caddy
… I think that’s about it. Let’s get started.
What I’m Gonna Set Up
- Synapse
- PostgreSQL
- reverse-proxy federation delegation
- Optionally, OpenID Connect configuration
- mautrix-meta
- Double puppeting
- Synapse app registration
- End user authentication w/ messenger.com
In the beginning there was… Synapse
Right. So. Let’s get started setting up Synapse. I chose Synapse as my Matrix server because I know Python, Synapse seemed relatively mature (was the only one marked Stable at the time), and because I wanted to. I know, I know, it’s not written in Golang/Rust/VeryFastLanguage but it’s comfy and it works. My first step was to visit their setup docs, available here. I was already hosting stuff (Caddy) on docker-compose and I knew it would make my networking life easier, so I immediately went there. Following their links, I ended up at the contrib/docker documentation on their github. They also had a Matrix 2.0 compose example available here but at time of writing it looked too immature for what I wanted to get set up. I may steal their WebRTC stuff in the future though. Looks like there’s gonna be a few steps here:
- Run the image to generate config files
- Adjust the config files
- Run the image as a server
First two steps don’t need docker-compose, just docker, so I ran
cd config
mkdir synapse
cd synapse
and
docker run --rm -e SYNAPSE_SERVER_NAME=synapse.my.site -e SYNAPSE_REPORT_STATS=yes docker.io/matrixdotorg/synapse:latest -v ./files:/data generate
and a homeserver.yaml popped out!
ls files
Great, time to get started with step two, configuration. I’ll attach a sample of what my first edits to homeserver.yaml looked like, with the important bits called out.
server_name: "synapse.my.site" # Immutable once you start federating unless you really wanna break stuff
pid_file: /data/homeserver.pid
listeners:
- port: 8008 # Default port, but just use it. Just needs to match docker-compose.yaml and Caddy later.
tls: false # because we're doing TLS termination at Caddy and forwarding as http
type: http # because we're doing TLS termination at Caddy and forwarding as http
x_forwarded: true # needed to run behind a reverse proxy
resources:
- names: [client, federation]
compress: false
database: # This is relevant! We want to set this up to use PostgreSQL! I'll annotate these values below
name: psycopg2 # this is the Python engine used to drive postgres. use it.
txn_limit: 10000
args:
user: synapse # This will be the user inside PG
password: ThisIsNotMyRealPassword!NiceTryThough!! # This is the password for the above user in PG. It is not my password. Really.
dbname: synapse # What database name do you want to use? Kind of arbitrary, as long as the user has permissions to create it and it doesn't conflict
host: db # This is the name of my docker-compose service for PostgreSQL. Arbitrary as long as it matches docker-compose.yaml
port: 5432 # Default port. Nothin' wrong with this.
cp_min: 5 # tweak to your desire. Connection pooling values.
cp_max: 10
log_config: "/data/synapse.my.site.log.config"
media_store_path: /data/media_store
registration_shared_secret: "@............................E"
report_stats: true
macaroon_secret_key: "k......................8"
form_secret: "u................7"
signing_key_path: "/data/synapse.my.site.signing.key"
trusted_key_servers: # basically like a certificate authority for federation
- server_name: "matrix.org"
retention: # this is the lifetime of your messages - this config will delete them after 90 days
enabled: true
default_policy:
min_lifetime: 1d
max_lifetime: 90d
password_config:
enabled: false # If you don't want to use OIDC, set this to true
oidc_providers: # This is optional, if you wanted to set up OIDC for login. I'll talk about this a lil in a bit.
- idp_id: keycloak
idp_name: "my.site"
issuer: "https://keycloak.my.site/realms/my-realm"
client_id: "synapse"
client_secret: "A.........................Y"
scopes: ["openid", "profile"]
user_mapping_provider:
config:
localpart_template: "{{ user.public_username }}"
display_name_template: "{{ user.name }}"
backchannel_logout_enabled: true # Optional
That’s all the configuration Synapse needs immediately, but we still have dependencies that we haven’t set up yet, PostgreSQL, Caddy, and optionally Keycloak.
Hangers-on: PG, Caddy, and OIDC
This will be mostly docker compose setup, so I’ll just do the same thing: post a docker-compose.yaml and explain the important bits.
version: "3.4"
services:
caddy:
container_name: caddy # arbitrary
image: ghcr.io/authp/authp:latest # I'm using this because I use OIDC for other services behind Caddy
restart: unless-stopped # a good idea
environment:
- TZ=${TZ}
- KEYCLOAK_CLIENT_ID=caddy # not important
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} # not important
ports:
- 443:443 # HTTPS default port
- 443:443/udp # HTTPS default port
- 80:80 # HTTP default port, really just upgrades to HTTPS :443
volumes:
- ${ROOT}/config/caddy/Caddyfile:/etc/caddy/Caddyfile # Just mounting my Caddyfile
- ${ROOT}/config/caddy/data:/data/
dns:
- 1.1.1.1 # I don't trust my home DNS enough
- 8.8.8.8
- 8.8.4.4
db: # This is the host from postgres in homeserver.yaml Arbitrary, just needs to match.
image: docker.io/postgres:15-alpine # version is not super important. I know this works though.
restart: unless-stopped
environment:
- POSTGRES_USER=synapse # needs to match homeserver.yaml
- POSTGRES_PASSWORD=ThisIsNotMyRealPassword!NiceTryThough!! # Look, this really isn't my password. It does need to match though.
# ensure the database gets created correctly
# 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:
# remember how I said to trust me about not using Windows?
# Make sure you mount this somewhere for persistence.
- D:\media\config\synapse\pg:/var/lib/postgresql/data
synapse:
image: docker.io/matrixdotorg/synapse:latest # should probably version pin this but whatever
# Since synapse does not retry to connect to the database, restart upon
# failure
restart: unless-stopped
# See the readme for a full documentation of the environment settings
environment:
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml # This will be our homeserver.yaml
volumes:
# You may either store all the files in a single folder
- D:\media\config\synapse\files:/data # again, just make sure you mount /data somewhere locally
# .. or you may split this between different storage points
# - ./files:/data
# - /path/to/ssd:/data/uploads
# - /path/to/large_hdd:/data/media
depends_on:
- db
Once you get all those set up, we should be ready to start our docker compose services and quickly validate everything boots, at a minimum. So, with a quick
docker compose -f docker-compose.synapse.yaml up
The synapse container will probably crash and restart a few times while it waits for the DB to initialize, but after 15 seconds or so it should reach steady state. But we’re not done yet - we haven’t even gotten network access yet! For that, we’ll need to configure Caddy (or nginx or whatever) to proxy traffic for our Synapse server. I’m using “delegation” (incorrectly), but you don’t have to. In any case, the examples here should be enough to help you get your reverse proxy set up.
synapse.my.site {
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `{"m.server": "synapse.my.site:443"}`
respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://synapse.my.site"}}`
reverse_proxy /_matrix/* synapse:8008 # host is from docker-compose service name, and port is from homeserver.yaml
reverse_proxy /_synapse/client/* synapse:8008
}
Finally, I’ll touch on the OpenID Connect configuration from my homeserver.yaml. I personally host Keycloak that I use to administrate my identities for my services.
I quite like OpenID Connect, and I try to use it when it makes sense, like now! You’ll remember I had set password_config.enabled
to false
and had added
oidc_providers
as a top-level yaml key. That config is what Synapse uses to figure out how to connect to your OpenID Connect server. For this example, I’m running
Keycloak on keycloak.my.site
and it has a realm called my-realm
that has my identities in it. There’s some good documentation on using OIDC here.
I won’t go into Keycloak, but the relevant bits are user_mapping_provider.config
. Those templates are used to extract user information from the userinfo endpoint, notably the localpart_template which is
how the immutable localpart:synapse.my.site
discriminator is constructed.
With that, we should be ready to give it a shot!
docker compose -f docker-compose.synapse.yaml down && docker compose -f docker-compose.synapse.yaml up -d
We should be able to sign in to any Matrix client, pointing our homeserver to synapse.my.site
now! Give it a whirl with Element
Episode 5: Meta Strikes Back
I promised we’d get a messenger.com bridge set up by the end of this, and so we will. It is time. First, we need a new database inside our postgres for mautrix to use. The right thing to do is to create a new username and password, and then use that username to create a new database so everything is more isolated. I’m lazy, so I re-used the existing username/password and just created a new database, like so:
docker compose -f docker-compose.synapse.yaml exec db psql -U synapse -d synapse
#> CREATE DATABASE metabridge;
Again, you should really do better than this, but at least it’s a different database.
From our synapse config folder, let’s run
cd ..
mkdir mautrix-meta
cd mautrix-meta
Again, we’re going to be running mautrix-meta on docker compose in order to make the networking easy. We’re going to generally follow their documentation, but I’ll note where we deviate. Generally the config.yaml they generate is well annotated, so I’ll only highlight sections where I change things. You should read it through yourself just to see what you can and want to configure! We’ll generate a sample config and start modifying it, with
docker run --rm -v ./files:/data dock.mau.dev/mautrix/meta:latest
This will generate a config.yaml file that we will now start adjusting. First, we need to set it to work on messenger:
meta:
mode: messenger
network:
mode: messenger
I actually don’t know which of these it needs, but the docs differ from the generated yaml. Do both, can’t hurt. The rest of the network section is fine as-is. We’ll need to add a net-new key within the bridge section, though:
bridge:
management_room_text:
welcome: "Welcome to the Matrix-Messenger Bridge management room!"
I’ve set bridge.permissions
as follows, but feel free to set it as you wish:
permissions:
"*": relay
"synapse.my.site": user
"@admin:synapse.my.site": admin
Now, we definitely need to set up our Postgres settings within the database
key:
database:
type: postgres # obviously
uri: postgres://synapse:ThisIsNotMyRealPassword!NiceTryThough!!@db/metabridge?sslmode=disable # this is the URL for PG.
# it's of the form postgres://{username}:{password}@{host}/{database}
# a Not Lazy Person would make a new DB user/password and create the database with _that_, but...
# The host is our docker compose service name for postgres, db, and we connect to the database metabridge with our user
# We set ssl off since we're just within our compose network
max_open_conns: 5
max_idle_conns: 1
max_conn_idle_time: null
max_conn_lifetime: null
We also need to ensure we set up our homeserver
key properly:
homeserver:
address: http://synapse:8008 # internal address within our compose network
domain: synapse.my.site # should match
I also set homeserver.async_media: true
since synapse supports it.
Another critical section is appserver
, which is what tells synapse
how to talk to the bridge.
appservice:
address: http://metabridge:29319 # again, docker compose hostname we'll be living on + default port
hostname: 0.0.0.0 # important to listen on all inside the container
port: 29319 # matches above
id: meta # just needs to be unique
bot:
username: metabot # will become the bot's username @metabot:synapse.my.site
Everything else you can leave as-is inside appservice
.
I also chose to set matrix.federate_rooms: false
as I know people outside my server won’t be using the bridge, and I don’t want federated users to have any access.
I set backfill.enabled: false
because initially I had it true
and it started importing really old messages, so I stopped that functionality. Learn from my mistakes!
We’ll get to double_puppet
in a moment, but that’s basically the thing that allows the bot to act both as you on messenger but also others on Matrix.
I set encryption up as:
encryption:
allow: true
default: true
require: false
and left the rest alone, more or less. From there, we need to get ready to set up double-puppeting!
Create a new yaml file, probably called doublepuppet.yaml
, and we’ll set it up as follows:
id: meta-doublepuppet
url:
as_token: GENERATETHIS
hs_token: Arbitrary
sender_localpart: arbitrary
rate_limited: false
namespaces:
users:
- regex: '@.*:synapse\.my\.site'
exclusive: false
The as_token should be a relatively long, randomly generated secret string - a password manager is a decent choice here. Copy the value, and also add it to the original config.yaml
like so:
double_puppet:
secrets:
synapse.my.site: as_token:GENERATETHIS
allow_discovery: false # mine only please
Great! Let’s now move doublepuppet.yaml
to ../synapse/files
, and then we’ll generate the main registration file for the bridge appservice:
docker run --rm -v ./files:/data dock.mau.dev/mautrix/meta:latest
It should see our config.yaml
and generate a registration.yaml
. Move registration.yaml
to ../synapse.files
alongside doublepuppet.yaml
and rename it to something obvious, like mautrix-meta.yaml
.
The penultimate step is to tell Synapse where to find our appservice registration files by adding the following lines to homeserver.yaml
:
app_service_config_files:
- /data/doublepuppet.yaml
- /data/mautrix-meta.yaml
Finally, we’ll need to add mautrix-meta to our docker-compose file as the following service:
metabridge: # matches the mautrix config.yaml value
container_name: metabridge
image: dock.mau.dev/mautrix/meta:latest
restart: unless-stopped
depends_on:
- db
volumes:
- D:\media\config\mautrix:/data # this should still contain config.yaml
And with that, a final docker compose -f docker-compose.synapse.yaml down && docker compose -f docker-compose.synapse.yaml up -d
should recreate all the containers
and you should be basically done!
Authenticating as a user on mautrix-meta
Honestly, their documentation is pretty good, they have pictures.
The bot’s name should be @metabot:synapse.my.site
.
If I have one piece of advice, just use JSON for the cookies.
It really seems unreliable to copy, especially for my friends on Chrome.
Thanks
Thanks to the Matrix Foundation for all the hard work they do, and to the Mautrix people, and to all the contributors to all the open-source projects I use every day. And thanks for reading! I hope this helped.