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/server pointing 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_dump nightly.
  • 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.