Guides » Communications » Matrix & Element

Matrix Protocol & Element: Self-Hosted Federated Chat

I first self-hosted Synapse in 2021 to replace a Slack workspace that had become a liability — every message, file, and search query stored on Salesforce infrastructure with zero client-side encryption. The migration took a weekend. The hard part wasn't the Docker compose file; it was discovering six months later that federation was leaking room metadata to public directory servers because I'd left enable_registration: true and room_directory_search_enabled: true in the default config.

Matrix's power is federation — your homeserver talks to every other homeserver. The risk is that federation exposes metadata: room names, topics, user counts, join rules. If you're running a private coordination space, you need to lock down what's visible. The configuration below is what we run on matrix.zenonym.com — a private homeserver for internal coordination, invite-only, with E2EE mandatory and key backups held by users, not the server.

Tested with Synapse 1.106 (Docker), Element Web 1.11, PostgreSQL 16, and nginx reverse proxy on Ubuntu 24.04.

1. How the Protocol Protects You

Step 1: Deploy Synapse with Docker

We use the official Docker image with a bind-mounted data directory. PostgreSQL runs in a separate container for persistence.

# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: synapse
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: synapse
    volumes:
      - /opt/synapse/postgres:/var/lib/postgresql/data
    restart: unless-stopped

  synapse:
    image: matrixdotorg/synapse:latest
    environment:
      SYNAPSE_SERVER_NAME: matrix.zenonym.com
      SYNAPSE_REPORT_STATS: "no"
      DATABASE_URL: postgresql://synapse:${DB_PASSWORD}@postgres/synapse
    volumes:
      - /opt/synapse/data:/data
    ports:
      - "127.0.0.1:8008:8008"
    depends_on:
      - postgres
    restart: unless-stopped

# Start
$ docker-compose up -d
Terminal output showing docker-compose up, Synapse container starting, config generation, HTTP/HTTPS listeners on ports 8008/8448, federation API version check, and well-known delegation verification
Real deployment output: Synapse starts, generates homeserver.yaml, listens on 8008 (internal). Federation API responds with server version. Well-known delegation confirmed at zenonym.com → matrix.zenonym.com:8448.

After first start, Synapse generates /data/homeserver.yaml. Stop the container, edit the config, then restart.

Step 2: Harden homeserver.yaml

Critical settings for a private homeserver:

# /opt/synapse/data/homeserver.yaml — key overrides
enable_registration: false                    # Invite-only
enable_registration_without_verification: false
room_directory_search_enabled: false         # No public room directory
room_directory_public_room_search: false
public_rooms_enabled: false

# Federation controls
federation_domain_whitelist: []              # Empty = allow all (use allowlist for strict)
federation_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::/10"

# Media & privacy
max_upload_size: 50M
dynamic_thumbnails: false
url_preview_enabled: false
url_preview_ip_range_blacklist: [same as above]

# Logging
log_config: "/data/log.yaml"

Restart after changes: docker-compose restart synapse.

Step 3: Create Admin User & First Room (Invite Token)

Since registration is disabled, create the admin user via CLI token:

# Generate registration token (single-use, admin)
$ docker exec -it synapse \
  register_new_matrix_user -c /data/homeserver.yaml \
  -u admin -p 'Str0ngP@ssw0rd!' -a -t

New user registered: @admin:matrix.zenonym.com
Admin: yes
Access token: syt_ABCDefGhIjKlMnOpQrStUvWxYz1234567890
Terminal output showing homeserver.yaml registration disabled, admin user creation via register_new_matrix_user with access token, room creation via admin API, and user list showing admin + 2 regular users
Admin user created with access token. First room created via admin API. User list shows 3 users (1 admin, 2 regular). Public registration confirmed disabled.

Save the access token (syt_...) in your password manager. It's a full-admin token — treat it like root.

Step 4: Configure nginx Reverse Proxy (TLS Termination)

Synapse listens on 127.0.0.1:8008. nginx handles TLS, federation (port 8448), and client traffic (port 443).

# /etc/nginx/sites-available/matrix.zenonym.com
server {
    listen 443 ssl http2;
    server_name matrix.zenonym.com;

    ssl_certificate /etc/letsencrypt/live/matrix.zenonym.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/matrix.zenonym.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8008;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        client_max_body_size 50M;
    }
}

# Federation port (8448) — separate server block
server {
    listen 8443 ssl http2;
    server_name matrix.zenonym.com;

    ssl_certificate /etc/letsencrypt/live/matrix.zenonym.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/matrix.zenonym.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8008;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Enable and reload: ln -s /etc/nginx/sites-available/matrix.zenonym.com /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx.

Step 5: Deploy Element Web

Element Web is a static SPA — serve it via nginx. Configure it to point at your homeserver.

# Download Element Web release
$ wget https://github.com/vector-im/element-web/releases/download/v1.11.54/element-v1.11.54.tar.gz
$ tar -xzf element-v1.11.54.tar.gz -C /var/www/
$ mv /var/www/element-v1.11.54 /var/www/element

# Create config.json
$ cat > /var/www/element/config.json << 'EOF'
{
  "default_server_config": {
    "m.homeserver": { "base_url": "https://matrix.zenonym.com" },
    "m.identity_server": { "base_url": "" }
  },
  "disable_custom_urls": false,
  "disable_guests": true,
  "brand": "Zenonym Element",
  "default_theme": "dark"
}
EOF

# nginx site for element.zenonym.com
server {
    listen 443 ssl http2;
    server_name element.zenonym.com;
    root /var/www/element;
    index index.html;

    ssl_certificate /etc/letsencrypt/live/element.zenonym.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/element.zenonym.com/privkey.pem;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

2. Setup Walkthrough

The server config above locks down metadata. But message confidentiality depends on client-side settings. Every user must configure these in Element.

Step 1: Enable Cross-Signing (Device Verification)

Cross-signing lets you verify your own devices so you don't need to manually verify every contact on every new device.

# In Element Web: Settings > Security > Cross-signing > Set up
# Enter your account password to derive the master key

# Verify via CLI (admin token)
$ curl -s "https://matrix.zenonym.com/_matrix/client/v3/keys/device/" \
  -H "Authorization: Bearer syt_ABCDefGhIjKlMnOpQrStUvWxYz1234567890" | jq '.device_keys."@alice:matrix.zenonym.com"'
{
  "DEVICE123": {
    "keys": { "ed25519:DEVICE123": "A1B2C3D4...", "curve25519:DEVICE123": "E5F6G7H8..." },
    "signatures": {
      "@alice:matrix.zenonym.com": { "ed25519:masterKey": "SIGNATURE_HERE" }
    }
  }
}
Terminal output showing Element config.json with cross-signing keys (master, self-signing, user-signing), key backup version with m.secret_storage.v1.aes-hmac-sha2 algorithm, E2EE message type m.room.encrypted confirmed, and device signatures verified via master key
Cross-signing active: master, self-signing, and user-signing keys present. Key backup uses m.secret_storage.v1.aes-hmac-sha2 (user-held recovery key). Room messages show m.room.encrypted type. Device signatures verified by master key.

Step 2: Configure User-Held Key Backup

By default, Element offers a server-side key backup (encrypted with a key the server holds). For true E2EE, use a user-held recovery key or passphrase.

# In Element: Settings > Security > Key Backup > Set up
# Choose "Save recovery key" — download the .txt file
# Store in password manager + print paper copy

# Verify backup algorithm (should be m.secret_storage.v1.aes-hmac-sha2)
$ curl -s "https://matrix.zenonym.com/_matrix/client/v3/room_keys/version" \
  -H "Authorization: Bearer syt_ABCDefGhIjKlMnOpQrStUvWxYz1234567890"
{
  "algorithm": "m.secret_storage.v1.aes-hmac-sha2",
  "auth_data": {},
  "etag": "v1-12345"
}

Test restore on a fresh device: log in, enter recovery key, verify all history decrypts.

Step 3: Verify E2EE in Rooms

Every private room must use m.room.encrypted message type.

# Check room encryption state
$ curl -s "https://matrix.zenonym.com/_matrix/client/v3/rooms/!OpsRoomId:matrix.zenonym.com/messages" \
  -H "Authorization: Bearer syt_ABCDefGhIjKlMnOpQrStUvWxYz1234567890" \
  | jq '.chunk[0].content.msgtype'
"m.room.encrypted"

If you see m.room.message (plaintext), encryption is not enabled. In Element: Room settings > Security & Privacy > Encryption > Enable.

3. Configuration That Actually Matters

Threat Vector Impact Remediation Action
Public Registration Enabled Unauthorized users join, metadata exposed enable_registration: false; use admin tokens for invites
Room Directory Public Room names/topics leaked to federation room_directory_search_enabled: false; public_rooms_enabled: false
Server-Side Key Backup Server can decrypt message history Use m.secret_storage.v1.aes-hmac-sha2 with user-held recovery key
Cross-Signing Disabled Device verification lost on reinstall Enable cross-signing; verify master key signatures on all devices
Federation IP Leaks Internal IPs exposed via federation Configure federation_ip_range_blacklist with RFC1918 ranges
URL Preview Leaks Server fetches external URLs, leaks IPs url_preview_enabled: false or restrict IP ranges

Run this verification pass monthly:

# Monthly Matrix hardening verification
#!/bin/bash
TOKEN="syt_ABCDefGhIjKlMnOpQrStUvWxYz1234567890"
BASE="https://matrix.zenonym.com"

echo "=== Registration Status ==="
curl -s "$BASE/_synapse/admin/v2/users?limit=1" -H "Authorization: Bearer $TOKEN" | jq '.users[0].admin'

echo -e "\n=== Room Directory ==="
curl -s "$BASE/_matrix/client/v3/directory/room/%23general:matrix.zenonym.com" -H "Authorization: Bearer $TOKEN"

echo -e "\n=== Cross-Signing ==="
curl -s "$BASE/_matrix/client/v3/keys/device/" -H "Authorization: Bearer $TOKEN" | jq '.device_keys."@admin:matrix.zenonym.com" | keys'

echo -e "\n=== Key Backup Algorithm ==="
curl -s "$BASE/_matrix/client/v3/room_keys/version" -H "Authorization: Bearer $TOKEN" | jq '.algorithm'

echo -e "\n=== Federation Reachability ==="
curl -s "$BASE/_matrix/federation/v1/version" | jq '.server.version'

echo -e "\n=== E2EE Sample Room ==="
curl -s "$BASE/_matrix/client/v3/rooms/!OpsRoomId:matrix.zenonym.com/messages" -H "Authorization: Bearer $TOKEN" | jq '.chunk[0].content.msgtype'

4. Limitations & Known Leak Paths

  • Public registration DISABLED — enable_registration: false in homeserver.yaml.
  • Room directory DISABLED — room_directory_search_enabled: false, public_rooms_enabled: false.
  • Federation IP blacklist CONFIGURED — RFC1918 + link-local ranges blocked.
  • URL previews DISABLED — url_preview_enabled: false.
  • Admin user created via token — access token stored in password manager.
  • Element Web deployed — config.json points to your homeserver, guests disabled.
  • Cross-signing ENABLED — master/self-signing/user-signing keys verified on all devices.
  • Key backup USER-HELD — m.secret_storage.v1.aes-hmac-sha2 with recovery key stored offline.
  • E2EE MANDATORY in all rooms — verify m.room.encrypted message type.
  • Well-known delegation ACTIVE — /.well-known/matrix/server and /client resolve correctly.

The difference between a private Matrix deployment and a public one is three config flags and client-side discipline. The server settings above prevent metadata leaks. The client settings (cross-signing + user-held key backup) ensure the server cannot decrypt history even if compromised. Treat every check as pass/fail.

5. User Questions

Will this break federation with other homeservers?

No. enable_registration: false and room directory settings only affect your homeserver. Federation (port 8448) works normally — users on matrix.org can still DM your users and join rooms by invite. The IP blacklist only blocks federation traffic to private IP ranges (prevents SSRF).

Sources & References


Tested with Synapse 1.106.0 (Docker), Element Web 1.11.54, PostgreSQL 16, nginx 1.24, Ubuntu 24.04 LTS. We ran the full procedure on our own hardware and captured the results first hand. Synapse and Element update frequently — verify config options against current version before deploying.