Skip to content
Serverküche
Search

Loading search …

Applications Difficulty: Advanced

Matrix: Your Own Chat Server with Synapse (Part 1)

Synapse 1.164 with Postgres 18 behind Traefik: server name, delegation, closed registration – and the federation test that has to come back green.

· 12 min read ·Duration: approx. 90 minutes
Table of contents

Your own chat server that can talk to the rest of the Matrix world: your messages live on your server, and you still write to people on matrix.org or on their own server. The price is a couple of decisions you make at the beginning and can hardly change later.

What are we building?

We run Synapse 1.164.2 with PostgreSQL 18.6 behind Traefik. At the end you have a Matrix homeserver with registration closed, one account of your own and working federation. The official federation tester confirms that it works.

This tutorial is part 1 and deliberately covers only the server. Element Web as the interface, daily life with rooms, and administration come in part 2, otherwise this would turn into a book.

One decision that sticks

The server_name becomes part of every user ID (@you:YOUR_DOMAIN) and every room address (#cooking-night:YOUR_DOMAIN). Changing it afterwards is practically impossible: every account would be a different one and federation would lose every mapping. Think about which domain you want permanently before the first start. Ideally short and pleasant, because it will sit behind every name later.

Requirements

  • Traefik as a reverse proxy with a working Let’s Encrypt resolver
  • A domain pointing at the server (an A record and ideally an AAAA record)
  • At least 2 GB of memory. Freshly started and idle, Synapse needs a good 100 MB and Postgres around 60 MB; as soon as you join rooms with many participants, the appetite grows noticeably
🍳 Recommendation Ad

VPS 2000 G12.5

8 vCores · 16 GB RAM · 256 GB SSD

from €26.92/month

Matrix grows with the rooms you join – the larger VPS is not a waste here.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 2000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

Step by step

Step 1: Telling server name, host and delegation apart

Matrix knows two different addresses, and that is the most common confusion at the start:

  • The server_name appears in the IDs: @cook:YOUR_DOMAIN. It is pure naming.
  • The host Synapse actually runs on can be an entirely different one, matrix.YOUR_DOMAIN, say.

So that foreign servers find the second when they only know the first, there is delegation: at https://YOUR_DOMAIN/.well-known/matrix/server sits a small JSON file pointing at the real host. In the same way a chat client finds out where to connect via https://YOUR_DOMAIN/.well-known/matrix/client.

In this guide server_name and host are the same domain. That is the simplest case, and Synapse can then serve both files itself, so you need no additional web server. If you later move to matrix.YOUR_DOMAIN, the server_name stays the same and only the delegation file changes.

Step 2: Let Synapse generate the configuration

Synapse writes its own base configuration, including the secrets and the signing key:

Terminal
mkdir -p /opt/matrix/data && cd /opt/matrix
docker run --rm -v /opt/matrix/data:/data \
  -e SYNAPSE_SERVER_NAME=YOUR_DOMAIN \
  -e SYNAPSE_REPORT_STATS=no \
  ghcr.io/element-hq/synapse:v1.164.2 generate
Ausgabe
Creating log config /data/YOUR_DOMAIN.log.config
Setting ownership on /data to 991:991
Generating config file /data/homeserver.yaml
Generating signing key file /data/YOUR_DOMAIN.signing.key
A config file has been generated in '/data/homeserver.yaml' for server name 'YOUR_DOMAIN'. Please review this file and customise it to your needs.

The signing key is irreplaceable

The file YOUR_DOMAIN.signing.key is your server’s identity towards every other Matrix server. Lose it and the rest of the network treats you as an impostor and rejects your events. It belongs in your backup from day one, not once there is data in the system. The same goes for registration_shared_secret, macaroon_secret_key and form_secret from homeserver.yaml.

The generated file uses SQLite. For anything beyond a single-seat test that is too little; we switch homeserver.yaml to PostgreSQL and add the settings that concern this server:

YAML
# /opt/matrix/data/homeserver.yaml (excerpt – leave the secrets untouched)
listeners:
  - port: 8008
    tls: false
    type: http
    x_forwarded: true
    bind_addresses: ['0.0.0.0']
    resources:
      - names: [client, federation]
        compress: false

database:
  name: psycopg2
  args:
    user: synapse
    password: YOUR_DB_PASSWORD
    dbname: synapse
    host: db
    port: 5432
    cp_min: 5
    cp_max: 10

public_baseurl: https://YOUR_DOMAIN/
serve_server_wellknown: true
enable_registration: false
suppress_key_server_warning: true
max_upload_size: 50M
url_preview_enabled: false

Four lines deserve an explanation:

  • x_forwarded: true: Synapse reads the real visitor IP from the X-Forwarded-For header that Traefik sets. Without it every log line shows the proxy’s address. The generated file already has the switch set; leave it in place when you rework the file.
  • public_baseurl: Synapse builds the client delegation and all links in invitations from it.
  • serve_server_wellknown: true: Synapse serves /.well-known/matrix/server itself. That is why we need no second web server.
  • url_preview_enabled: false: Link previews make your server fetch every URL somebody pastes into a room. That is a nice feature and at the same time a way to abuse your server as a fetching tool for other people’s addresses. Leave it off to begin with.

Step 3: PostgreSQL with the right collation

Synapse requires a database with collation C. The reason is unspectacular and the consequence annoying: with a language-dependent collation such as de_DE.utf8, Postgres compares strings differently, and Synapse therefore refuses to start at all.

In the Docker image you get there via POSTGRES_INITDB_ARGS – on the very first start, because after that the collation is fixed:

YAML
    environment:
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"

You can check it any time:

Terminal
docker compose exec db psql -U synapse -d synapse \
  -c "SELECT datname, pg_encoding_to_char(encoding), datcollate FROM pg_database WHERE datname='synapse';"
Ausgabe
 datname | pg_encoding_to_char | datcollate
---------+---------------------+------------
 synapse | UTF8                | C

Step 4: The stack

YAML
# /opt/matrix/compose.yaml
services:
  db:
    image: postgres:18.6-alpine
    container_name: matrix-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: synapse
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: synapse
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
    volumes:
      - pgdata:/var/lib/postgresql
    networks: [matrix-internal]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U synapse"]
      interval: 10s
      timeout: 5s
      retries: 6

  synapse:
    image: ghcr.io/element-hq/synapse:v1.164.2
    container_name: synapse
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
      TZ: Europe/Berlin
    volumes:
      - ./data:/data
    networks: [matrix-internal, proxy]
    labels:
      - traefik.enable=true
      - traefik.http.routers.synapse.rule=Host(`YOUR_DOMAIN`)
      - traefik.http.routers.synapse.entrypoints=websecure
      - traefik.http.routers.synapse.tls.certresolver=le
      - traefik.http.services.synapse.loadbalancer.server.port=8008

volumes:
  pgdata:

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

The database sits in a network with internal: true – unreachable from outside and from the proxy network. The database password lives in a .env next to it and has to match the value in homeserver.yaml.

Note

The database mount is on /var/lib/postgresql, not on /var/lib/postgresql/data. From PostgreSQL 18 on, the container refuses to start with the old path, even with an empty volume.
Terminal
docker compose up -d
docker compose ps
Ausgabe
NAME        STATUS
matrix-db   Up 55 seconds (healthy)
synapse     Up 45 seconds (healthy)

On first start Synapse creates the schema and then runs background migrations. The logs show lines such as Running background update 'threads_backfill' for minutes. That is normal and does not affect operation.

Step 5: The four endpoints that have to be right

Before you touch any chat client, check four addresses from the outside. First, whether the client API answers at all:

Terminal
curl -s https://YOUR_DOMAIN/_matrix/client/versions
Ausgabe
{"versions":["r0.0.1","r0.1.0", … ,"v1.16","v1.17"],"unstable_features":{ … }}

Then the two delegation files that Synapse serves itself thanks to serve_server_wellknown:

Terminal
curl -s https://YOUR_DOMAIN/.well-known/matrix/server
curl -s https://YOUR_DOMAIN/.well-known/matrix/client
Ausgabe
{"m.server":"YOUR_DOMAIN:443"}
{"m.homeserver":{"base_url":"https://YOUR_DOMAIN/"}}

And finally the key endpoint that foreign servers use to verify your server’s signatures:

Terminal
curl -s https://YOUR_DOMAIN/_matrix/key/v2/server
Ausgabe
{"old_verify_keys":{},"server_name":"YOUR_DOMAIN","signatures":{"YOUR_DOMAIN":{"ed25519:a_udcw":" … "}}, … }

Lastly the check that the door is shut:

Terminal
curl -s -X POST https://YOUR_DOMAIN/_matrix/client/v3/register -d '{}'
Ausgabe
{"errcode":"M_FORBIDDEN","error":"Registration has been disabled"}

Open registration is not an option

A Matrix server with open registration is reliably abused for spam within days. Then your server_name lands on other servers’ block lists, and getting off those again is hard. enable_registration: false stays, and you create accounts by hand.

Step 6: Create the first account

Because registration is closed, you use the bundled tool. It authenticates via the registration_shared_secret from the configuration, and -a makes the user an administrator:

Terminal
docker compose exec synapse register_new_matrix_user \
  -u YOUR_USERNAME -p YOUR_PASSWORD -a \
  -c /data/homeserver.yaml http://localhost:8008
Ausgabe
Sending registration request...
Success!

Step 7: Counter-test through the client API

Now the actual functional test, with no chat client and just curl. First log in:

Terminal
curl -s -X POST https://YOUR_DOMAIN/_matrix/client/v3/login \
  -H "Content-Type: application/json" \
  -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"YOUR_USERNAME"},"password":"YOUR_PASSWORD"}'

The response contains user_id, device_id and an access_token. The user ID shows that the server_name is in place:

Ausgabe
"user_id":"@YOUR_USERNAME:YOUR_DOMAIN"

With the token you create a room, send a message into it and read it back again. That proves the whole chain of client API, database and event store:

Terminal
curl -s -X POST https://YOUR_DOMAIN/_matrix/client/v3/createRoom \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Cooking night","preset":"private_chat"}'
Ausgabe
{"room_id":"!68AeABE_Q59hjgJkh8wIl3zdGbp_n7k4a9uOf4yDrF0"}

The room ID carries no server name: Synapse creates new rooms in room version 12, whose IDs are plain hashes. Part 2 explains what that means.

Terminal
curl -s -X PUT "https://YOUR_DOMAIN/_matrix/client/v3/rooms/YOUR_ROOM_ID/send/m.room.message/txn1" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"msgtype":"m.text","body":"Onion tart at 19:15"}'
Ausgabe
{"event_id":"$WHceSNXyP34qT0CoQjq0E97dm7p7ZVLArH7mZdKftyA"}

Step 8: Check federation

Everything so far concerned your server alone. Whether the rest of the Matrix world can talk to it is answered by the official federation tester. It resolves your delegation, opens a connection, checks the certificate and compares the server name against the signature:

Terminal
curl -s "https://federationtester.matrix.org/api/report?server_name=YOUR_DOMAIN" \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print('FederationOK:', d['FederationOK'])"
Ausgabe
FederationOK: True

The checker lists both addresses (IPv4 and IPv6) separately; both have to show ValidCertificates: True and MatchingServerName: True. That is exactly what you want to see before you hand anyone your Matrix address. The same check exists as a web interface at federationtester.matrix.org; the API is handier for repeating it occasionally later.

Step 9: Walling off the admin API

Synapse ships its own administration API under /_synapse/admin/. With your administrator account’s token you can, for instance, list every account:

Terminal
curl -s "https://YOUR_DOMAIN/_synapse/admin/v2/users?from=0&limit=5" \
  -H "Authorization: Bearer YOUR_TOKEN"
Ausgabe
total: 1
   @YOUR_USERNAME:YOUR_DOMAIN | admin: True | deactivated: False

Without a token you are turned away, as expected:

Ausgabe
{"errcode":"M_MISSING_TOKEN","error":"Missing access token"}

One endpoint, however, is different:

Terminal
curl -s https://YOUR_DOMAIN/_synapse/admin/v1/server_version
Ausgabe
{"server_version":"1.164.2"}

This endpoint answers without any authentication. Synapse does that on purpose, but it tells everyone which version you run and therefore which known issues are interesting for you. If you do not need the admin path from outside at all, the cleanest move is to block it at the reverse proxy: an IPAllowList middleware on /_synapse/admin costs two lines and settles the matter. The pattern for that is in making services reachable only over VPN.

When it doesn’t work

Synapse refuses to start and reports IncorrectDatabaseSetup: Database has incorrect collation. The database was created with a language-dependent collation such as de_DE.utf8; Synapse expects C. That cannot be changed afterwards, because the collation is fixed when the database is created. Delete the volume and start again with POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C". The error message offers allow_unsafe_locale as a way out; do not take it, because wrongly sorted indexes will bite you later during searches.

The federation tester reports FederationOK: false even though the site loads in a browser. Then the delegation is usually wrong. Check curl -s https://YOUR_DOMAIN/.well-known/matrix/server. The answer has to contain the host including the port, something like {"m.server":"YOUR_DOMAIN:443"}. If you get a 404 here, serve_server_wellknown: true is missing from the configuration or another service intercepts the path. Also common: the server name in the delegation points at a host whose certificate is issued for a different name.

The logs show Traefik’s IP for every access instead of the visitor’s. x_forwarded: true is missing from the listeners block. Synapse only evaluates the forwarding headers when you explicitly allow it. Without the real source address you can neither trace abuse nor use fail2ban sensibly.

Logging in fails with M_FORBIDDEN even though the password is right. Check that you really are logging in at the right server: the user ID is @name:server_name, not @name:host. If server_name and host differ and the client delegation is missing, your client ends up in the wrong place. curl -s https://YOUR_DOMAIN/.well-known/matrix/client has to return the homeserver’s base URL.

The server runs but memory gets tight. Synapse uses little while idle (a good 100 MB) but grows noticeably once you join large, heavily federated rooms, because it keeps track of their entire history. When it gets tight, the first remedy is to leave such rooms and then remove old events with the admin API’s cleanup functions. More memory is the other answer; a homeserver that takes part in real rooms is not happy on 1 GB.

Your problem is not listed? Search all error messages →

Maintenance & backups

Updates come often: Synapse publishes a new version roughly every two weeks, and security fixes are among them. That makes a fixed rhythm necessary:

Terminal
cd /opt/matrix
docker compose exec -T db pg_dump -U synapse synapse > before-update-$(date +%F).sql
sed -i 's/synapse:v1.164.2/synapse:vNEW_VERSION/' compose.yaml
docker compose pull && docker compose up -d
docker compose logs synapse --tail 20

Read the release notes before every jump, especially the “deprecations” section: Synapse regularly drops old configuration keys, and the server then refuses to start with a clear but unexpected message. Database migrations run automatically at startup; on large instances the first start after an update can therefore take several minutes.

Four things need backing up, and the first is the most important:

Ausgabe
/opt/matrix/data/YOUR_DOMAIN.signing.key   # your server's identity
/opt/matrix/data/homeserver.yaml           # contains the three secrets
pg_dump output                              # rooms, messages, accounts
/opt/matrix/data/media_store/               # images and files from the rooms

Without the signing key you cannot restore the server. You could only set up a new one, and every other server would treat it as a new, unknown server. All of it belongs encrypted in your Restic backup.

The media_store grows unnoticed: every image from every federated room you join ends up as a copy on your disk. Check every few months with du -sh /opt/matrix/data/media_store; Synapse ships an admin API for cleaning up that deletes old media.

Realistically, setting it up costs an evening. After that it is two appointments a month: one update including a look at the release notes, one for disk space and the federation test. Matrix is not a service you set up and forget; the protocol moves too fast for that.

What's next?

You might also like