Setting up Matrix + Meta

Keaton Cross | Mar 19, 2025 min read

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

  1. Docker
    1. This can be Docker Desktop for Windows/Mac if you want, but… I wouldn’t recommend it. Trust me.
  2. A public domain
    1. I’m going to write this as though I own my.site
    2. Everywhere you see my.site, please replace with your own domain
  3. Networking and firewall rules set up such that my.site:443 hits a reverse proxy you have control of
  4. That reverse proxy is Caddy, and is listening on port 443 and 80 with automatic HTTPS upgrading
    1. 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.
    2. Please make sure your TLS certs are valid
    3. or just use Caddy

… I think that’s about it. Let’s get started.

What I’m Gonna Set Up

  1. Synapse
    1. PostgreSQL
    2. reverse-proxy federation delegation
    3. Optionally, OpenID Connect configuration
  2. mautrix-meta
    1. Double puppeting
    2. Synapse app registration
    3. 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:

  1. Run the image to generate config files
  2. Adjust the config files
  3. 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.