Matrix: Synapse Server
Introduction and Architectural Overview
The Synapse server represents the reference implementation for the open Matrix protocol, the standard for federated instant messaging with end-to-end encryption (E2EE). In a federated ecosystem, communication secrecy is guaranteed through cryptographic payloads, while self-hosting the homeserver ensures complete sovereignty over transit metadata, message retention policies, and administrative access logs.
Note: Operational Scope & High Availability: This guide details the standard deployment of a multi-worker Synapse instance on a single host node, focusing on decoupling sync, federation, and authentication workloads. For enterprise high-availability (HA) requirements, this architecture can be scaled horizontally by deploying a redundant PostgreSQL cluster (e.g. PgBouncer/Patroni) and distributing workers across dedicated nodes.
Taxonomy
Everything will be set up like this:
Cloudflare Tunnel
`
|
OIDC Provider
|
`-- Server Nginx
` .- worker
| |
+-- Matrix server +-- worker
| | |
MAS | `-- worker
|
+--- Database Postgres
|
|
`--- Bridges- OIDC Provider: handles user registration
- MAS, aka Matrix Authentication Service: the connector between OIDC and Matrix
- Workers: used to balance loads for scalability
- Nginx Server: the balancer, and it is crucial to configure it to distribute the load among the various workers
- Bridges: for interoperability with other protocols (such as WhatsApp, Signal, Telegram, ...)
NOTE: we will use a SPLIT domain. This means that:
- the Synapse server will listen on: matrix.example.org
- the handle will be: user:example.org and not user:matrix.example.org
- When you find something marked as [HIDDEN], it means it was either configured by the application or generated with
openssl rand -hex 32.
WARNING: you must already have the OIDC provider installed! How to do this has already been covered, so I will not cover it on this page. Alternatively, you can use MAS as a full OIDC provider for your Synapse server by enabling registration.
Installing Synapse: the foundation where everything starts
Let's start by creating the working directory:
sudo mkdir -p /opt/matrix && cd opt/matrixGenerating the configuration file
The initial configuration requires the generate command:
docker run -it --rm \
-v $(pwd)/data:/data \
-e SYNAPSE_SERVER_NAME=example.org \
-e SYNAPSE_REPORT_STATS=no \
ghcr.io/element-hq/synapse:develop generateThis command will generate the configuration file under data/homeserver.yaml, and this is the file that must be modified to add the public_baseurl.
server_name: "example.org"
public_baseurl: "matrix.example.org" # <-- To add
pid_file: /data/homeserver.pid
listeners:
- port: 8008
resources:
- compress: false
names:
- client
- federation
tls: false
type: http
x_forwarded: true
database:
name: sqlite3
args:
database: /data/homeserver.db
log_config: "/data/example.org.log.config"
media_store_path: /data/media_store
registration_shared_secret: "[HIDDEN]"
report_stats: false
macaroon_secret_key: "[HIDDEN]"
form_secret: "[HIDDEN]"
signing_key_path: "/data/example.org.signing.key"
trusted_key_servers:
- server_name: "matrix.org"Creating the docker network
We will create a docker network specifically for Synapse:
docker network create synapse
be636df73d51c5275918e9f8f07fbc748d0da6b4ddd250a2cba8c408a1f609ab
The Docker Compose File
Given the complexity of the deployment we are attempting, it's appropriate to create a docker-compose file. It will be modified over time to build the final implementation.
Let's start with the environment: .env (DB password generated with openssl);
# Synapse Config
SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
SYNAPSE_SERVER_NAME=example.org
SYNAPSE_REPORT_STATS=yes
VIRTUAL_HOST=example.org
VIRTUAL_PORT=8008
# Database Config
POSTGRES_USER=synapse
POSTGRES_DB=synapse
POSTGRES_PASSWORD=[HIDDEN]The docker-compose.yml instead will be:
services:
synapse:
image: ghcr.io/element-hq/synapse:develop
restart: unless-stopped
networks:
- synapse
environment:
- SYNAPSE_CONFIG_PATH=${SYNAPSE_CONFIG_PATH}
- VIRTUAL_HOST=${VIRTUAL_HOST}
- VIRTUAL_PORT=${VIRTUAL_PORT}
- SYNAPSE_SERVER_NAME=${SYNAPSE_SERVER_NAME}
- SYNAPSE_REPORT_STATS=${SYNAPSE_REPORT_STATS}
volumes:
- ./data:/data
ports:
- 8008:8008/tcp
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- synapse
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- ./redis:/data
db:
image: docker.io/postgres:16-alpine
restart: unless-stopped
networks:
- synapse
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
synapse:
external: trueNo docker compose up yet: we need to tell Synapse to use Postgres instead of SQLite. We must edit data/homeserver.yaml, replacing:
database:
name: sqlite3
args:
database: /data/homeserver.dbwith:
database:
name: psycopg2
args:
user: synapse
password: [HIDDEN]
host: db
database: synapse
cp_min: 5
cp_max: 10Obviously the DB password is the one generated with openssl, and which was written in the .env file. Only at this point can we proceed with the next step:
docker compose up -d; docker compose logs -fIs it usable then? Not at all, but at least we can see there are no errors.
Reverse proxy with Nginx (and Cloudflare dances)
Now there's a distinction:
- matrix.example.org
- example.org While the first one only handles Synapse, the second one must contain the well-known files to allow handle resolution. So, while the first one can be containerized here, the second one will have to be modified manually as it is assumed to be elsewhere, serving the domain directly, for example. Consequently, in this container we will only manage
matrix.example.org.
Being behind Cloudflare, it is necessary to go and get hands dirty directly on the domain's admin panel.
In the domain management, on the right: SSL/TLS -> Origin Server. I use the ECC key and set it for *.example.org and example.org.
Let's go back to our machine leaving the certificate page visible:
mkdir -p nginx/conf.d
mkdir nginx/certsLet's save the private and public keys in:
- nginx/certs/origin.key
- nginx/certs/origin.crt
Again on the Cloudflare page, let's go to the Zero Trust section and create a docker connector: let's note down the TUNNEL_TOKEN.
Then let's populate nginx/conf.d/matrix.example.org.conf:
server {
listen 443 ssl http2;
server_name matrix.example.org;
# We use Cloudflare Origin CA certificates
ssl_certificate /etc/nginx/certs/origin.crt;
ssl_certificate_key /etc/nginx/certs/origin.key;
# Security optimizations
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 100M;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
location / {
proxy_pass http://synapse:8008;
proxy_read_timeout 600s;
}
# Matrix Federation
location /.well-known/matrix/server {
return 200 '{"m.server": "matrix.example.org:443"}';
add_header Content-Type application/json;
add_header Access-Control-Allow-Origin *;
}
location /.well-known/matrix/client {
return 200 '{"m.homeserver": {"base_url": "https://matrix.example.org"}}';
add_header Content-Type application/json;
add_header Access-Control-Allow-Origin *;
}
}Let's modify the docker-compose file to also include Nginx by adding:
nginx:
image: nginx:alpine
restart: unless-stopped
networks:
- synapse
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/certs:/etc/nginx/certs:ro # We mount the directory with origin.crt and origin.key
tunnel:
image: cloudflare/cloudflared:latest
restart: unless-stopped
networks:
- synapse
command: tunnel run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
- TUNNEL_ORIGIN_CERT_CHECK=falseLet's also add the necessary part for Nginx to the .env file:
CLOUDFLARE_TUNNEL_TOKEN=TUNNEL_TOKEN_SEGNATO_PRIMALet's start up both Nginx and the tunnel:
docker compose up -d nginx tunnelAnd let's go back to Cloudflare to configure the newly created connector by adding the published application:
- Subdomain: matrix
- Domain: example.org
- Service: https
- URL: nginx:443
- Other application settings:
- TLS
- No TLS verification: on
- HTTP2 connection: on
- TLS
At this point, visiting https://matrix.example.org will show the basic Synapse page on the Matrix protocol.
Since we want the handles to be different from the actual domain name, it is necessary to also modify the Nginx configuration serving the main domain, namely example.org. Specifically, we need to add:
location = /.well-known/matrix/server {
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.server": "matrix.example.org:443"}';
}
location = /.well-known/matrix/client {
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://matrix.example.org"}, "m.identity_server": {"base_url": "https://vector.im"}}';
}Now we can see if everything is correctly configured for the split domain by going to https://federationtester.matrix.org/ and entering example.org in the test.
The workers
A rather devastating problem is that if the main process does everything (synchronization, federation, exchanges, etc.), everything gets overloaded and runs slow. We need to scale. That's why we create multiple workers, each specialized in something and doing only that.
Let's modify docker-compose.yml to include them, adding:
generic-federation-presence:
image: "ghcr.io/element-hq/synapse:develop"
container_name: generic-federation-presence
restart: unless-stopped
entrypoint: ["/start.py", "run", "--config-path=/data/homeserver.yaml", "--config-path=/data/workers/generic-federation-presence.yaml"]
volumes:
- ./data:/data
environment:
SYNAPSE_WORKER: synapse.app.generic_worker
depends_on:
- db
- redis
- synapse
networks:
- synapse
generic-sync-1:
image: "ghcr.io/element-hq/synapse:develop"
container_name: generic-sync-1
restart: unless-stopped
entrypoint: ["/start.py", "run", "--config-path=/data/homeserver.yaml", "--config-path=/data/workers/generic-sync-1.yaml"]
volumes:
- ./data:/data
environment:
SYNAPSE_WORKER: synapse.app.generic_worker
depends_on:
- db
- redis
- synapse
networks:
- synapse
generic-sync-2:
image: "ghcr.io/element-hq/synapse:develop"
container_name: generic-sync-2
restart: unless-stopped
entrypoint: ["/start.py", "run", "--config-path=/data/homeserver.yaml", "--config-path=/data/workers/generic-sync-2.yaml"]
volumes:
- ./data:/data
environment:
SYNAPSE_WORKER: synapse.app.generic_worker
depends_on:
- db
- redis
- synapse
networks:
- synapse
generic-sync-initial:
image: "ghcr.io/element-hq/synapse:develop"
container_name: generic-sync-initial
restart: unless-stopped
entrypoint: ["/start.py", "run", "--config-path=/data/homeserver.yaml", "--config-path=/data/workers/generic-sync-initial.yaml"]
volumes:
- ./data:/data
environment:
SYNAPSE_WORKER: synapse.app.generic_worker
depends_on:
- db
- redis
- synapse
networks:
- synapse
event-persister:
image: "ghcr.io/element-hq/synapse:develop"
container_name: event-persister
restart: unless-stopped
entrypoint: ["/start.py", "run", "--config-path=/data/homeserver.yaml", "--config-path=/data/workers/event-persister.yaml"]
volumes:
- ./data:/data
environment:
SYNAPSE_WORKER: synapse.app.generic_worker
depends_on:
- synapse
- db
- redis
networks:
- synapse
For each of these, a worker file must be created inside data/workers/, so:
mkdir data/workersInside we will have: event-persister.yaml
worker_app: synapse.app.generic_worker
worker_name: event-persister
# Listener for internal communication (Replication)
worker_listeners:
- port: 8086
type: http
resources:
- names: [replication]
# Redis config (must point to the service name in compose)
redis:
enabled: true
host: redis
port: 6379then generic-federation-presence.yaml
worker_app: synapse.app.generic_worker
worker_name: generic-federation-presence
worker_types:
- federation_sender
- federation_reader
- federation_event_auth
- presence
- typing
- to_device
worker_listeners:
- port: 8083
type: http
tls: false
resources:
- names: [federation]
compress: true
read_only: true
redis:
enabled: true
host: redisthen generic-sync-1.yaml
worker_app: synapse.app.generic_worker
worker_name: generic-sync-1
worker_types:
- sync
worker_listeners:
- port: 8082
type: http
tls: false
resources:
- names: [client]
compress: true
read_only: true
redis:
enabled: true
host: redis
and also generic-sync-2.yaml
worker_app: synapse.app.generic_worker
worker_name: generic-sync-2
worker_types:
- sync
worker_listeners:
- port: 8084
type: http
tls: false
resources:
- names: [client]
compress: true
read_only: true
redis:
enabled: true
host: redis
and finally (for now) generic-sync-initial.yaml
worker_app: synapse.app.generic_worker
worker_name: generic-sync-initial
worker_types:
- sync
worker_listeners:
- port: 8085
type: http
tls: false
resources:
- names: [client]
compress: true
read_only: true
redis:
enabled: true
host: redis
let's fix the permissions:
chown 991:991 data/workers -Rand modify the mapping in data/homeserver.yaml:
server_name: "example.org"
public_baseurl: "https://matrix.example.org"
pid_file: /data/homeserver.pid
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, replication]
compress: true
worker_types:
- frontend_proxy
- federation_sender
instance_map:
main:
host: synapse
port: 8008
generic-sync-1:
host: generic-sync-1
port: 8082
generic-federation-presence:
host: generic-federation-presence
port: 8083
generic-sync-2:
host: generic-sync-2
port: 8084
generic-sync-initial:
host: generic-sync-initial
port: 8085
event-persister:
host: event-persister
port: 8086
stream_writers:
events:
- event-persister
federation_rr_transactions_per_second: 100
federation_rr_concurrent_transmissions: 50
redis:
enabled: true
host: redis
port: 6379
database:
name: psycopg2
[...rest of configuration ...]
Technically, we could now proceed with the Nginx configuration to balance across the workers, but we won't do it yet. We first need to install the Matrix Authentication Service or MAS.
Matrix Authentication Service
The Matrix Authentication Service (MAS) is a reference implementation of an Authorization Server compliant with OAuth 2.0 and OpenID Connect (OIDC) standards, specifically designed to act as an identity provider (IdP) in the Matrix protocol ecosystem.
Formal Architecture Characteristics
- Authentication Decoupling (Separation of Concerns)
Traditionally, authentication in Matrix was integrated directly into the Homeserver (for example, in Synapse). MAS moves this responsibility externally. Formally, this transforms the Homeserver into a Resource Server (RS) according to the OAuth 2.0 framework, delegating identity validation and token issuance to the MAS (Authorization Server).
- Standardization of Protocols
MAS implements industry-standard specifications to eliminate custom Matrix authentication APIs (defined as "Legacy"):
RFC 6749 (OAuth 2.0): For resource access authorization.
OpenID Connect Core 1.0: For the identity layer on top of OAuth 2.0.
Backend protocols: Supports interfacing with relational databases (PostgreSQL) for data persistence and protocols such as LDAP or other upstream IdPs for federated authentication.
Session Lifecycle Management MAS does not limit itself to login, but formally manages:
- Token Issuance and Revocation: Management of short-lived Access Tokens and Refresh Tokens.
- Device Management: Unique association between OIDC sessions and Matrix protocol device_ids.
- Compatibility (Compatibility Layer): Provides a translation interface for clients that do not yet natively support OIDC, simulating legacy Matrix login APIs.
Installing MAS
We are still under /opt/matrix. Let's create the "virgin" configuration file:
mkdir mas-config
docker run ghcr.io/element-hq/matrix-authentication-service config generate > mas-config/config.yamlLet's see what this incredible file looks like: cat mas-config/config.yaml
http:
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: compat
- name: graphql
- name: assets
binds:
- address: '[::]:8080'
proxy_protocol: false
- name: internal
resources:
- name: health
binds:
- host: localhost
port: 8081
proxy_protocol: false
trusted_proxies:
- 192.168.0.0/16
- 172.16.0.0/12
- 10.0.0.0/10
- 127.0.0.1/8
- fd00::/8
- ::1/128
public_base: http://[::]:8080/
issuer: http://[::]:8080/
database:
uri: postgresql://
max_connections: 10
min_connections: 0
connect_timeout: 30
idle_timeout: 600
max_lifetime: 1800
email:
from: '"Authentication Service" <root@localhost>'
reply_to: '"Authentication Service" <root@localhost>'
transport: blackhole
secrets:
encryption: [HIDDEN]
keys:
- key: |
-----BEGIN RSA PRIVATE KEY-----
[HIDDEN]
-----END RSA PRIVATE KEY-----
- key: |
-----BEGIN EC PRIVATE KEY-----
[HIDDEN]
-----END EC PRIVATE KEY-----
- key: |
-----BEGIN EC PRIVATE KEY-----
[HIDDEN]
-----END EC PRIVATE KEY-----
- key: |
-----BEGIN EC PRIVATE KEY-----
[HIDDEN]
-----END EC PRIVATE KEY-----
passwords:
enabled: true
schemes:
- version: 1
algorithm: argon2id
minimum_complexity: 3
matrix:
kind: synapse
homeserver: localhost:8008
secret: [HIDDEN]
endpoint: http://localhost:8008/Here there are some modifications to make, as is easy to imagine.
WARNING: according to the specifications, MAS must reside on a separate domain from Synapse. Consequently, we will use auth.example.org.
The file we see needs to be heavily customized. Here is how it should look:
http:
public_base: https://auth.example.org
issuer: https://auth.example.org
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: compat
- name: graphql
- name: assets
binds:
- host: 0.0.0.0
port: 8080
proxy_protocol: false
database:
uri: postgres://synapse:[HIDDEN]@mas-db/synapse
max_connections: 10
min_connections: 0
connect_timeout: 30
idle_timeout: 600
max_lifetime: 1800
email:
from: '"Authentication Service" <[email protected]>'
reply_to: '"Authentication Service" <[email protected]>'
transport: blackhole
secrets:
encryption: [HIDDEN]
keys:
- key: |
-----BEGIN RSA PRIVATE KEY-----
[HIDDEN]
-----END RSA PRIVATE KEY-----
- key: |
[HIDDEN]
- key: |
[HIDDEN]
-----END EC PRIVATE KEY-----
- key: |
[HIDDEN]
passwords:
enabled: true
schemes:
- version: 1
algorithm: argon2id
minimum_complexity: 3
matrix:
kind: synapse
homeserver: example.org
secret: [HIDDEN]
endpoint: http://synapse:8008
upstream_oauth2:
providers:
- id: 01HFRQFT5QFMJFGF01P7JAV2ME
human_name: Authentik
issuer: "https://auth.example.org/application/o/matrix-synapse/" #
client_id: "[HIDDEN]"
client_secret: "[HIDDEN]"
token_endpoint_auth_method: client_secret_basic
scope: "openid profile email"
claims_imports:
localpart:
action: require
template: "{{ "{{" }} user.preferred_username }}"
displayname:
action: suggest
template: "{{ "{{" }} user.name }}"
email:
action: suggest
template: "{{ "{{" }} user.email }}"
avatar:
action: suggest
template: "{{ "{{" }} user.picture }}"Okay, this will be a lot to digest, let's analyze the key lines:
- 2:
public_base: https://auth.example.org - 3:
issuer: https://auth.example.org: these two must point to MAS as they indicate who will act as the OIDC provider for Synapse - 18: the password must be generated with
openssl rand -hex 32and must correspond to what we will set for the MAS DB in docker-compose (now we ran it with docker run) - [54-75] (or appropriate range): this is the OIDC provider configuration. Correct. You must have configured a Provider + App in confidential mode on Authentik: issuer, client_id, client_secret are provided by Authentik. The fields have suggest instead of require to allow the user to populate the fields or not. If they exist, it suggests them, but they are editable.
- 56: This ID must strictly be a valid ULID, which can also be generated online at https://ulidgenerator.com/
If you want to enable registrations and/or not use the external OIDC provider, you must add/replace the upstream_oauth2 block with:
account:
password_registration_enabled: true
email_change_allowed: true
displayname_change_allowed: true
password_change_allowed: true
password_recovery_enabled: trueIn this case, you should also enable outgoing emails (for example with Brevo), completely deleting the block:
email:
from: '"Authentication Service" <[email protected]>'
reply_to: '"Authentication Service" <[email protected]>'
transport: blackholeand placing:
email:
smtp_host: "smtp-relay.brevo.com"
smtp_port: 587
smtp_user: "[HIDDEN]"
smtp_pass: "[HIDDEN]"
require_transport_security: true
notif_from: "[email protected]"
app_name: multihopnetworkmatrix
client_base_url: https://matrix.example.org
invite_client_location: https://example.org
Modifying the docker-compose file
Let's add the MAS service and its DB to docker-compose.yml:
mas-db:
image: docker.io/postgres:16-alpine
restart: unless-stopped
networks:
- synapse
environment:
- POSTGRES_USER=${MAS_POSTGRES_USER}
- POSTGRES_DB=${MAS_POSTGRES_DB}
- POSTGRES_PASSWORD=${MAS_POSTGRES_PASSWORD}
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${MAS_POSTGRES_USER} -d ${MAS_POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./postgres-mas:/var/lib/postgresql/data
mas:
image: ghcr.io/element-hq/matrix-authentication-service:latest
restart: unless-stopped
networks:
- synapse
command: ["server"]
volumes:
- "./mas-config/config.yaml:/config.yaml:ro"
depends_on:
mas-db:
condition: service_healthy
synapse:
condition: service_startedIt goes without saying that in .env you must add:
MAS_POSTGRES_USER=synapse
MAS_POSTGRES_DB=synapse
MAS_POSTGRES_PASSWORD=[HIDDEN]Modifying the homeserver
Also data/homeserver.yaml must be adjusted. We need to add:
matrix_authentication_service:
enabled: true
endpoint: http://mas:8080
secret: "[HIDDEN]"Instead of the secret, we must write the secret found in the file mas-config/config.yaml at line 52.
Nginx for auth
Having now this additional domain to manage, namely auth.example.org, it is necessary to serve it just like we did with matrix.example.org. Let's create the file nginx/conf.d/auth.example.org.conf:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name auth.example.org;
ssl_certificate /etc/nginx/certs/origin.crt;
ssl_certificate_key /etc/nginx/certs/origin.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_http_version 1.1;
# Docker service name for MAS
proxy_pass http://mas:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_redirect off;
}
}
server {
listen 80;
server_name auth.example.org;
return 301 https://$host$request_uri;
}Also in this case, we must publish the connector, the same one as before, on Cloudflare so that it points to https://nginx:443 for the domain auth.example.org
Optimized Nginx Configuration for Worker Dispatching
The efficiency of a multi-worker Synapse infrastructure depends entirely on the precision of the reverse proxy layer. Nginx acts as an application load balancer (Layer 7), routing every HTTP request to its dedicated specialized worker.
The optimized configuration adopted in production implements three key engineering principles:
- Persistent connections (
keepalive): keeps TCP sockets open to backend containers, eliminating handshake latency and preventing exhaustion of kernel file descriptors. - User-based Consistent Hashing (
$mxid_localpart): extracts the user identifier from the access token (both from theAuthorizationHTTP header and theaccess_tokenURL query parameter), deterministically directing all sync requests for the same account to the same worker, thereby maximizing in-memory cache hit rates. - Deterministic routing on
$uriand Buffering-free Long-Polling: path pattern matching is performed against$uri(without query parameters), while disabling buffering (proxy_buffering off;) and extending timeouts (proxy_read_timeout 3600s;) guarantees stability for mobile push notifications and device sync.
Let us update nginx/conf.d/matrix.example.org.conf:
# ==============================================================================
# UPSTREAMS - SYNAPSE & WORKER DISPATCH MATRIX
# ==============================================================================
# 1. Master Synapse (Critical writes, Events, Device management, and Administration)
upstream synapse_master {
server synapse:8008;
keepalive 32;
}
# 2. MAS (Matrix Authentication Service - OIDC / SSO)
upstream synapse_mas {
server mas:8080;
keepalive 32;
}
# 3. Federation Worker (Inbound federation and key exchange)
upstream synapse_federation {
server generic-federation-presence:8083;
keepalive 32;
}
# 4. Presence Worker
upstream synapse_presence_worker {
server generic-presence:8087;
keepalive 16;
}
# 5. Typing Worker
upstream synapse_typing {
server generic-typing:8088;
keepalive 16;
}
# 6. Client Sync & Read Pool (Consistent hashing per user)
upstream synapse_client_pool {
hash $mxid_localpart consistent;
server generic-sync-1:8082;
server generic-sync-2:8084;
server generic-sync-initial:8085;
keepalive 64;
}
# ==============================================================================
# USER EXTRACTION FOR CONSISTENT HASHING
# ==============================================================================
map $arg_access_token $accesstoken_from_urlparam {
default $arg_access_token;
"~syt_(?<username>.*?)_.*" $username;
}
map $http_authorization $mxid_localpart {
default $http_authorization;
"~Bearer syt_(?<username>.*?)_.*" $username;
"" $accesstoken_from_urlparam;
}
# ==============================================================================
# SYNAPSE PATH ROUTING MAP (Evaluated on $uri without query string)
# ==============================================================================
map $uri $synapse_backend {
# Default to master: unclassified endpoints or state-changing writes go to master
default synapse_master;
# --- AUTHENTICATION & OIDC (MAS) ---
~*^/_matrix/client/(r0|v3|unstable)/(login|logout|refresh)$ synapse_mas;
~*^/_matrix/client/v3/auth_issuer$ synapse_mas;
~*^/_matrix/client/unstable/org.matrix.msc2965/auth_issuer$ synapse_mas;
~*^/_matrix/client/unstable/org.matrix.msc3861/ synapse_mas;
# --- INBOUND FEDERATION ---
~*^/_matrix/federation/ synapse_federation;
~*^/_matrix/key/ synapse_federation;
# --- PRESENCE & TYPING ---
~*^/_matrix/client/(api/v1|r0|v3|unstable)/presence/ synapse_presence_worker;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/rooms/.*/typing/ synapse_typing;
# --- CRITICAL WRITES (Explicitly pinned to Master) ---
~*^/_matrix/client/(api/v1|r0|v3|unstable)/rooms/.*/(send|state|redact) synapse_master;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/rooms/[^/]+/(join|invite|leave|ban|unban|kick)$ synapse_master;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/(createRoom|join/) synapse_master;
~*^/_matrix/client/(r0|v3|unstable)/user/[^/]+/account_data/ synapse_master;
~*^/_matrix/client/(r0|v3|unstable)/sendToDevice/ synapse_master;
~*^/_matrix/client/(r0|v3|unstable)/keys/upload synapse_master;
~*^/_matrix/client/(r0|v3|unstable)/room_keys/ synapse_master;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/profile/ synapse_master;
~*^/_matrix/client/(r0|v3|unstable)/devices synapse_master;
# --- SYNC & HIGH-FREQUENCY READS (Distributed across the balanced client pool) ---
~*^/_matrix/client/(r0|v3)/sync$ synapse_client_pool;
~*^/_matrix/client/(api/v1|r0|v3)/events$ synapse_client_pool;
~*^/_matrix/client/(api/v1|r0|v3)/initialSync synapse_client_pool;
~*^/_matrix/client/(api/v1|r0|v3)/rooms/[^/]+/initialSync$ synapse_client_pool;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/rooms/.*/(context|members|joined_members|state|messages) synapse_client_pool;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/rooms/.*/event/ synapse_client_pool;
~*^/_matrix/client/v1/rooms/.*/hierarchy$ synapse_client_pool;
~*^/_matrix/client/(r0|v3|unstable)/joined_rooms$ synapse_client_pool;
~*^/_matrix/client/(api/v1|r0|v3|unstable)/publicRooms$ synapse_client_pool;
~*^/_matrix/client/(r0|v3|unstable)/keys/(claim|query|changes)$ synapse_client_pool;
~*^/_matrix/client/(r0|v3|unstable)/user_directory/search$ synapse_client_pool;
~*^/_matrix/client/(r0|v3|unstable)/account/(whoami|3pid)$ synapse_client_pool;
~*^/_matrix/client/(r0|v3|unstable)/.*/tags$ synapse_client_pool;
~*^/_matrix/client/versions$ synapse_client_pool;
}
# ==============================================================================
# HTTPS SERVER
# ==============================================================================
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name matrix.example.org;
# Restore real client IP behind perimeter proxy / Cloudflare
set_real_ip_from 172.16.0.0/12;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
# TLS Cryptographic Parameters
ssl_certificate /etc/nginx/certs/origin.crt;
ssl_certificate_key /etc/nginx/certs/origin.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=15768000" always;
access_log /var/log/nginx/matrix.access.log;
error_log /var/log/nginx/matrix.error.log;
# RFC-compliant proxy forwarding headers
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
client_max_body_size 50M;
# 1. Static well-known
location ^~ /.well-known {
root /var/www/html/matrix;
}
# 2. Matrix Core Endpoints (Sync, API, Admin)
location ~ ^(/_matrix|/_synapse) {
proxy_pass http://$synapse_backend;
# Disable buffering and enable long timeouts for mobile long-polling
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 3. Fallback root location
location / {
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Content-Security-Policy "frame-ancestors 'none'";
proxy_pass http://$synapse_backend;
proxy_read_timeout 300s;
}
}
# Permanent HTTP -> HTTPS redirect
server {
listen 80;
server_name matrix.example.org;
return 301 https://$server_name$request_uri;
}Initial BACKUP
Now: a nice backup of everything. And let's keep it.
cd /opt/matrix
sudo tar cvf vector.iniziale.tar vector/
cd vectorCreating the admin
Manipulating MAS is not exactly user-friendly. To begin with, all authentication is delegated to it, so it's not possible to create users directly on Synapse. I created this script to avoid having to write the entire docker command by hand every time. It should be placed inside the directory containing the docker-compose file, and I named it mas-cli.sh:
#!/usr/bin/env bash
docker_command="docker run --network synapse -p 59062:8080 -v \"./mas-config/config.yaml:/config.yaml\" ghcr.io/element-hq/matrix-authentication-service"
for param in "$@"; do
docker_command="$docker_command $param"
done
# Execute full docker command
echo "Executing command: $docker_command" # Useful for debugging
eval "$docker_command"So, let's create the admin:
./mas-cli.sh manage register-user admin \
--password 'SuperPass1234!' \
--admin \
--email [email protected] \
--ignore-password-complexity \
--yes
Executing command: docker run --network synapse -p 59062:8080 -v "./mas-config/config.yaml:/config.yaml" ghcr.io/element-hq/matrix-authentication-service manage register-user admin --password SuperPass1234! --admin --email [email protected] --ignore-password-complexity --yes
User attributes
Username: admin
Matrix ID: @admin:localhost:8008
Password: ********
Email: [email protected]
Can request admin: true
2026-04-13T20:50:42.782269Z INFO mas_cli::commands::manage:936 User registered user.id=01KP49RQYDZJHQC31KF8RKGSN6Element
Perfect. At this point, we can easily start Element and enjoy the new homeserver by pointing it to matrix.example.org
Ketesa ((Synapse Admin))
Ketesa is the de facto tool for web-based Synapse administration. Let's add it to docker-compose.yml (feel free to publish it under another Cloudflare domain):
Ketesa:
hostname: ketesa
build:
context: https://github.com/etkecc/ketesa.git
dockerfile: docker/Dockerfile.build
args:
- BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
- REACT_APP_SERVER=https://matrix.example.org
ports:
- "9000:8080"
restart: unless-stopped
networks:
- synapseTuning?
Redis
Let's start by improving the Redis situation, replacing its service in docker-compose.yml with:
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- synapse
mem_limit: 1024
mem_reservation: 256m
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- ./redis:/data
command: >
redis-server
--appendonly yes
--appendfsync everysec
--maxmemory 768mb
--maxmemory-policy noeviction
--auto-aof-rewrite-percentage 100
--auto-aof-rewrite-min-size 64mb
Pgbouncer
Let's use it to limit connections to PostgreSQL. Add the service to docker-compose.yml:
pgbouncer:
image: edoburu/pgbouncer:latest
restart: unless-stopped
networks:
- synapse
environment:
- DB_HOST=db
- DB_USER=${POSTGRES_USER}
- DB_PASSWORD=${POSTGRES_PASSWORD}
- POOL_MODE=transaction
- DEFAULT_POOL_SIZE=10
- MAX_CLIENT_CONN=50
- AUTH_TYPE=scram-sha-256
- RESERVE_POOL_SIZE=5
- SERVER_RESET_QUERY=DISCARD ALL
- IGNORE_STARTUP_PARAMETERS=extra_float_digitsand modify the data/homeserver.yaml file to use the new configuration:
database:
name: psycopg2
args:
user: synapse
password: [HIDDEN]
#host: db
host: pgbouncer
port: 5432
database: synapse
cp_min: 1
cp_max: 5The password remains unchanged; host, cp_min, and cp_max change.
Postgresql
We are on an N150, so let's optimize Postgres as well:
mkdir postgres-configand then:
cat > /opt/matrix/postgres-config/postgresql.conf << 'EOF'
listen_addresses = '*'
shared_buffers = '1.5GB'
effective_cache_size = '3GB'
work_mem = '4MB'
maintenance_work_mem = '128MB'
max_connections = 50
wal_buffers = '4MB'
checkpoint_completion_target = 0.9
checkpoint_timeout = '15min'
max_wal_size = '2GB'
min_wal_size = '512MB'
autovacuum = on
autovacuum_max_workers = 2
autovacuum_naptime = '30s'
autovacuum_vacuum_scale_factor = 0.02
autovacuum_analyze_scale_factor = 0.01
autovacuum_vacuum_cost_limit = 1500
autovacuum_vacuum_cost_delay = '5ms'
max_worker_processes = 2
max_parallel_workers = 1
max_parallel_workers_per_gather = 1
log_min_duration_statement = '1000ms'
log_checkpoints = off
log_autovacuum_min_duration = '1000ms'
EOFThis file must be mounted in the Postgres container in docker-compose.yml, so let's modify the section:
db:
image: docker.io/postgres:16-alpine
restart: unless-stopped
networks:
- synapse
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./postgres:/var/lib/postgresql/data
- ./postgres-config/postgresql.conf:/etc/postgresql/postgresql.conf:ro
command: postgres -c config_file=/etc/postgresql/postgresql.confLiveKit aka Native Videoconferencing
[ To Be Continued ]
[May the big mole be with you]
[EOF]
Discussion and support channel. To discuss configurations or other topics, join us on fluxer.dreadful.work.

