Plan the domains
Matrix needs two domains-with-meaning:
- server name — the identity domain. Users are
@alice:example.com. This is what appears in their handle. Once chosen, very hard to change — pick the canonical short one. - delegation target — where the actual homeserver runs. Often
matrix.example.com. The server-name domain serves a small JSON at/.well-known/matrix/serverpointing federated requests at the delegation target.
Plus a client-facing URL (matrix.example.com) and an Element web client URL (element.example.com) if you self-host the client UI.
Install on Debian
# Add the Matrix.org apt repository
sudo apt install -y lsb-release wget apt-transport-https
sudo wget -O /usr/share/keyrings/matrix-org-archive-keyring.gpg \
https://packages.matrix.org/debian/matrix-org-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/matrix-org-archive-keyring.gpg] \
https://packages.matrix.org/debian/ $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/matrix-org.list
sudo apt update
sudo apt install matrix-synapse-py3
# During install, prompted for "Server Name" — enter the identity domain (e.g. example.com).
Or docker compose with Postgres:
# docker-compose.yml
services:
synapse:
image: matrixdotorg/synapse:latest
restart: unless-stopped
environment:
SYNAPSE_SERVER_NAME: example.com
SYNAPSE_REPORT_STATS: "no"
volumes:
- ./data:/data
ports:
- "127.0.0.1:8008:8008"
depends_on: [ postgres ]
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: synapse
POSTGRES_PASSWORD: <long-random>
POSTGRES_DB: synapse
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
The Postgres locale settings matter — Synapse fails to initialize against a non-C collation. The default in newer Postgres images is fine, but explicitly setting it avoids subtle issues during a major upgrade.
Configure Synapse
Edit /etc/matrix-synapse/homeserver.yaml (or ./data/homeserver.yaml in the container):
server_name: "example.com"
public_baseurl: "https://matrix.example.com/"
pid_file: /data/homeserver.pid
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ['127.0.0.1', '::1']
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: synapse
password: <long-random>
database: synapse
host: postgres
cp_min: 5
cp_max: 10
registration_shared_secret: "<run: openssl rand -hex 32>"
report_stats: false
macaroon_secret_key: "<run: openssl rand -hex 32>"
form_secret: "<run: openssl rand -hex 32>"
signing_key_path: "/data/example.com.signing.key"
# Disable open registration; manually invite users
enable_registration: false
# Maximum upload file size
max_upload_size: 50M
url_preview_enabled: true
url_preview_ip_range_blacklist:
- '127.0.0.0/8'
- '10.0.0.0/8'
- '172.16.0.0/12'
- '192.168.0.0/16'
- '100.64.0.0/10'
- '169.254.0.0/16'
- '::1/128'
- 'fe80::/64'
- 'fc00::/7'
The url_preview_ip_range_blacklist is a critical SSRF defence — without it, anyone can paste an internal-network URL in a room and trigger Synapse to fetch it.
sudo systemctl restart matrix-synapse
# Or: docker compose up -d
Reverse proxy & federation port
Federation traffic needs port 8448 OR a working /.well-known/matrix/server delegation. The latter is cleaner:
# On example.com (the identity domain)
example.com {
# The well-known delegation file
handle /.well-known/matrix/server {
respond `{"m.server": "matrix.example.com:443"}`
header Content-Type application/json
}
handle /.well-known/matrix/client {
respond `{"m.homeserver": {"base_url": "https://matrix.example.com"}}`
header Content-Type application/json
header Access-Control-Allow-Origin *
}
# ... rest of example.com config
}
# On matrix.example.com (the actual homeserver)
matrix.example.com {
reverse_proxy 127.0.0.1:8008
request_body { max_size 50MB }
}
Verify federation health with the official tester: https://federationtester.matrix.org/?server_name=example.com. All checks should be green before inviting anyone.
Create the first admin user
# Synapse runs a small registration utility that uses the shared secret from homeserver.yaml
register_new_matrix_user \
-u amir -p <password> -a \
-c /etc/matrix-synapse/homeserver.yaml \
http://localhost:8008
-a = admin user. Subsequent users can be invited via the admin API or created from this user's Element session.
Element: the web client
Element is the reference Matrix client. For a polished experience, self-host the web build:
docker run -d \
--name element-web \
--restart unless-stopped \
-p 127.0.0.1:8090:80 \
-v ./element-config.json:/app/config.json:ro \
vectorim/element-web:latest
element-config.json:
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.example.com",
"server_name": "example.com"
}
},
"brand": "Example Chat",
"default_country_code": "CA",
"show_labs_settings": true,
"features": {
"feature_video_rooms": true,
"feature_threadenabled": true
}
}
Reverse-proxy element.example.com to port 8090. Users open it, log in with their @user:example.com handle. iOS / Android Element clients work the same way against the homeserver.
Bridges
Matrix's killer feature is bridges. The two big families:
- Mautrix bridges — one bridge per network (mautrix-telegram, mautrix-discord, mautrix-whatsapp, mautrix-slack, mautrix-signal, mautrix-meta for Facebook/Instagram).
- Matrix App Service bridges — older protocol, used by IRC bridges and Matrix-bridge-bridge.
Each runs as its own service alongside Synapse, registers as an "application service" via app_service_config_files in homeserver.yaml, and exposes a special "puppet" user per connected account. From the user's Element view, a bridged Telegram chat looks exactly like a Matrix room.
End-to-end encryption
E2EE is on by default for direct messages and any new room marked "encrypted." Synapse can never read encrypted content; recovery depends on Secret Storage / Key Backup, which Element walks users through on first login.
Educate users that losing keys = losing history. The "Verify with Security Key" flow at first login generates a recovery key; if the user skips it and later loses their devices, encrypted history is gone.
Calls and video
Matrix uses WebRTC for voice/video, with Synapse's built-in support for 1:1 and small group calls. For larger conferences (10+ participants), pair with a TURN server (coturn) and the LiveKit-backed "Element Call" component.
Backups
- Postgres database — everything except media.
pg_dumpnightly. - Media store —
/data/media_store/. Per-file uploads + remote-fetched URL previews. Restic on the directory. homeserver.yaml+ the signing key. Without the signing key, federation is broken on restore — back it up out-of-band.
What Synapse isn't
- It's not lightweight — Synapse uses several GB of RAM at modest scale, and its database grows over time. Conduit is the lighter Rust alternative.
- It's not a SaaS — you operate a federated server, including upgrades, abuse handling for federated traffic, and database growth management.
- The federation protocol has known scale limits — very large rooms (10k+ members) are slow. For most uses this isn't a problem.
For "I want our team chat on infrastructure we own, with the option to talk to anyone in the wider Matrix network," Synapse is the canonical choice.