LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Matrix and Synapse: Self-Hosted Chat

matrixsynapseself-hostingchatfederation

Matrix is not a chat application. It is a protocol — an open standard for federated, real-time communication — and that distinction matters enormously when you are deciding whether to run it. Slack is a product, Discord is a product, and Signal is a product. Matrix is infrastructure, more analogous to email than to any of those things. When you deploy a Matrix homeserver, you are not just spinning up a private chat app for your family or team; you are joining a global, decentralized mesh of servers that all speak the same protocol, federate state with each other, and collectively guarantee that no single entity controls the conversation. That is the promise, and for a certain class of operator it is a genuinely compelling one. The caveat is that the reference implementation — Synapse — is a Python service with a reputation for eating RAM, the encryption UX still has sharp edges that confuse non-technical users, and running bridges to WhatsApp, Signal, or Discord introduces fragility that compounds over time. This post covers all of it honestly: how the protocol works, how to deploy a production-grade homeserver, what the lighter alternatives look like in 2026, and when the entire stack is simply not worth the effort.

The audience here is someone who has already self-hosted a few services, is comfortable with Docker and a reverse proxy, and wants to know what Matrix actually costs in time and hardware before committing. If you want a five-minute setup, you want something else. If you want to understand what you are running and why, read on.


The Matrix Protocol and Federation Model

Matrix defines rooms as replicated state machines. Every room has a state — membership lists, topic, permissions, encryption configuration — and that state is replicated across every homeserver whose users participate in the room. Each event (a message, a join, a permission change) is a JSON object that is signed by the originating server, chained into a directed acyclic graph of previous events, and gossiped to all other participating homeservers via the federation API.

Federation traffic between homeservers travels over HTTPS on port 8448 by default. Client traffic — from Element, FluffyChat, Cinny, or any other Matrix client — hits the homeserver’s client-server API, conventionally on port 443 through a reverse proxy. The distinction matters for firewall rules: if you want your server to federate with the wider Matrix network, port 8448 must be reachable from the public internet (or you must use .well-known delegation to map federation to your 443 port).

                         Matrix Federation Network
                         ┌──────────────────────────────┐
                         │                              │
   ┌─────────────────┐   │   ┌─────────────────┐        │
   │  Your Server    │◄──┼──►│  matrix.org      │        │
   │  matrix.example │   │   │  (homeserver)    │        │
   │  .com           │   │   └─────────────────┘        │
   │                 │   │          ▲                    │
   │  Port 443       │   │          │                    │
   │  (clients)      │   │   ┌─────────────────┐        │
   │  Port 8448      │   │   │  another.chat    │        │
   │  (federation)   │◄──┼──►│  (homeserver)    │        │
   └────────┬────────┘   │   └─────────────────┘        │
            │            └──────────────────────────────┘
            │
   ┌────────▼────────┐
   │   Your Clients  │
   │  Element Web    │
   │  FluffyChat     │
   │  Cinny          │
   └─────────────────┘

Well-Known Delegation

Most operators want Matrix IDs like @alice:example.com but do not want to run the homeserver directly on example.com. The .well-known delegation mechanism solves this. You serve a JSON file at https://example.com/.well-known/matrix/server that tells other homeservers where to actually find your server, and a second file at https://example.com/.well-known/matrix/client that tells clients where to find the client-server API.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// https://example.com/.well-known/matrix/server
{
  "m.server": "matrix.example.com:443"
}

// https://example.com/.well-known/matrix/client
{
  "m.homeserver": {
    "base_url": "https://matrix.example.com"
  },
  "m.identity_server": {
    "base_url": "https://vector.im"
  }
}

With this delegation in place, your homeserver listens only on matrix.example.com, and you do not need to expose port 8448 at all — federation traffic arrives at 443 on the subdomain. This is the recommended setup for most self-hosters because it avoids opening an extra port and keeps the TLS certificate management simple.


Deploying Synapse

Synapse is the reference homeserver. It is written in Python on Twisted, it is the most feature-complete implementation, and it accounts for the vast majority of the Matrix network. It is also the heaviest implementation by a significant margin. For any real deployment — meaning anything beyond a quick test — you must use PostgreSQL as the database backend. SQLite is only appropriate for development and will exhibit increasingly painful performance degradation at any meaningful load; state resolution queries hit the database hard and SQLite simply cannot keep up.

Docker Compose Stack

A minimal but production-ready Synapse stack with Postgres looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# docker-compose.yml
version: "3.8"

services:
  synapse:
    image: matrixdotorg/synapse:latest
    container_name: synapse
    restart: unless-stopped
    volumes:
      - ./data:/data
    environment:
      - SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
    depends_on:
      db:
        condition: service_healthy
    networks:
      - synapse-internal
      - proxy

  db:
    image: postgres:16-alpine
    container_name: synapse-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: synapse
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: synapse
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
    volumes:
      - synapse-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U synapse"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - synapse-internal

volumes:
  synapse-db:

networks:
  synapse-internal:
  proxy:
    external: true

Generate the initial configuration with:

1
2
3
4
5
docker run --rm \
  -v $(pwd)/data:/data \
  -e SYNAPSE_SERVER_NAME=matrix.example.com \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest generate

homeserver.yaml Key Fragments

After generating the base config, the database section must be replaced and several other settings configured:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Database — replace the sqlite default entirely
database:
  name: psycopg2
  args:
    user: synapse
    password: your_password_here
    database: synapse
    host: db
    port: 5432
    cp_min: 5
    cp_max: 10

# Disable open registration — required for any public-facing server
enable_registration: false

# If you want to allow registration with a shared secret
registration_shared_secret: "generate-a-long-random-string-here"

# Limit federation to known servers if running a closed community
# federation_domain_whitelist:
#   - example.com

# Media store — local by default, S3 recommended for anything persistent
media_store_path: /data/media_store
max_upload_size: 50M

# Logging
log_config: /data/log.config

Reverse Proxy Configuration

The Caddy reverse proxy guide covers the general pattern, but Matrix has specific requirements. Synapse needs large request body limits (for file uploads), long timeouts (federation requests can be slow), and the X-Forwarded-For header must be set correctly — Synapse uses it for rate limiting. Here is an nginx snippet that handles both client and federation traffic via the 443 port delegation approach:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
server {
    listen 443 ssl http2;
    server_name matrix.example.com;

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

    # Client-server API and federation via well-known
    location ~* ^(\/_matrix|\/_synapse\/client) {
        proxy_pass http://localhost:8008;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $host;

        client_max_body_size 50M;
        proxy_read_timeout   600;
        proxy_send_timeout   600;
    }
}

# Serve .well-known delegation from your base domain
server {
    listen 443 ssl http2;
    server_name example.com;

    location /.well-known/matrix/ {
        root /var/www/matrix-well-known;
        default_type application/json;
        add_header Access-Control-Allow-Origin *;
    }
}

Note the warning in the official documentation: do not add a trailing slash after the port in proxy_pass. Nginx will canonicalize the URI, which breaks federation signature verification.

Registration control deserves explicit attention. With enable_registration: false, new accounts can only be created by an administrator using the admin API or the register_new_matrix_user command. For a family or small team this is exactly what you want — you are not running a public service. If you do want to allow self-registration, use the registration_requires_token: true option to require invitation tokens rather than opening registration to the world, which will attract spam bots within days on a public server.

For exposing the server publicly, Cloudflare Tunnels can handle the ingress without opening ports directly, though federation latency through a tunnel is occasionally worse than a direct connection.


The Lighter Alternatives: Dendrite, Conduit, and What Comes After

The Matrix ecosystem has more homeserver implementations than most people realize, and in 2026 the landscape has shifted considerably.

Implementation Language Status (2026) Relative Resource Use Federation Maturity
Synapse Python Production, actively maintained High Full
Dendrite Go Largely unmaintained Medium Partial
Conduit Rust Active (original) Low Good
conduwuit Rust Hard fork of Conduit, now abandoned (mid-2025) Low Good
Continuwuity Rust Active fork of conduwuit Low Growing

Dendrite was supposed to be the next-generation replacement for Synapse — written in Go, architecturally cleaner, more resource efficient. Funding issues at Element led to it being deprioritized, and as of 2025 it is not actively developed. It works, but you should not build new deployments on it.

The Rust-based lineage (Conduit -> conduwuit -> Continuwuity) is the interesting alternative for resource-constrained operators. The appeal is concrete: Continuwuity runs in tens of megabytes of RAM rather than hundreds, its SQLite-backed storage actually performs acceptably for small deployments (though Postgres is still recommended for anything serious), and startup time is measured in seconds rather than tens of seconds. The trade-off is feature completeness — some admin APIs, certain module hooks, and a few edge-case federation behaviors that Synapse handles are missing or different. For a family server or a small private community that just wants text chat and does not need bridges or workers, Continuwuity is a legitimate choice. For anything that needs the full mautrix bridge stack or Synapse-specific modules, you are back to Synapse.


Clients: Element and Its Alternatives

Element is the flagship Matrix client, available as a web app, Electron desktop app, and native iOS and Android applications. It is the most feature-complete client and the one that receives the most testing, but it is also the heaviest. The Electron desktop app in particular is a memory consumer in the same tradition as Synapse itself.

The web client can be self-hosted — you pull the element-web Docker image, point it at your homeserver, and serve it from your domain. This matters if you want to ensure your users are on a known version and you want full control over the config.

Alternatives worth knowing about:

  • Cinny — a web-only client with a clean, Discord-adjacent interface. Significantly lighter than Element Web, popular for team/community deployments. Self-hostable.
  • FluffyChat — cross-platform (Android, iOS, Linux, web), consumer-friendly UI, good for non-technical family members. Strong on mobile.
  • Nheko — native desktop client (Qt/C++), fast, keyboard-driven, popular among power users.
  • Hydrogen — ultra-minimal web client developed by the Element team, designed for low-power devices and slow connections. No fancy features, just chat that works.
  • SchildiChat — a fork of Element Web/Android with a more IRC/Slack-like multi-account panel layout. Popular among people who manage multiple Matrix accounts.

For a family deployment, FluffyChat on mobile and Element Web (self-hosted) on desktop is a practical combination. For a technical team, Cinny or Element Web with self-hosting and the full admin controls is common.


Bridges: Connecting to the Outside World

Bridges are what turn Matrix from “another chat protocol” into a practical consolidation hub. The mautrix family, maintained by Tulir Asokan, covers the major platforms:

Bridge Platform Language Status
mautrix-whatsapp WhatsApp Go Active, production use
mautrix-signal Signal Go Active, requires Signal device
mautrix-discord Discord Go Active
mautrix-telegram Telegram Python/Go Active
mautrix-slack Slack Go Active
matrix-appservice-irc IRC Node.js Active (matrix.org maintains)

Double Puppeting

The standard bridge model creates a “ghost” user on your Matrix homeserver for each contact on the remote platform — so your WhatsApp contacts appear as Matrix users. Messages arrive from these ghost accounts. The limitation is that your own messages, sent from the native WhatsApp app, appear from a ghost account representing you rather than from your actual Matrix account.

Double puppeting solves this: you authenticate the bridge with your real Matrix credentials, and the bridge is authorized to send messages impersonating your actual Matrix account. The result is that when you send a WhatsApp message from your phone, it appears in Matrix from your real @you:example.com identity, not from a ghost. The technical mechanism is MSC4350 in the current protocol spec, which defines explicit permission for bridge bots to impersonate users.

Without double puppeting:
  [WhatsApp message from Alice] -> @alice_bridge_ghost:example.com -> Matrix room
  [Your reply from WhatsApp]    -> @you_bridge_ghost:example.com   -> Matrix room

With double puppeting:
  [WhatsApp message from Alice] -> @alice_bridge_ghost:example.com -> Matrix room
  [Your reply from WhatsApp]    -> @you:example.com                -> Matrix room

Setting up double puppeting requires creating a shared secret or using a registration file that grants the bridge permission to impersonate your account. In homeserver.yaml this means registering the bridge as an application service.

Bridge Reality Check

Bridges are fragile in a way that is proportional to how aggressively the remote platform changes its API or app. WhatsApp, in particular, has repeatedly pushed updates that broke mautrix-whatsapp temporarily. Signal’s bridge requires you to have Signal installed as a linked device, which means if you rotate your Signal keys or reinstall Signal, the bridge breaks and needs re-registration. Discord aggressively terms-of-services against automation, so mautrix-discord operates in a gray area and periodically breaks when Discord changes its gateway.

The maintenance burden is real: you need to keep each bridge up to date (they release frequently), monitor for breakage, and be prepared to re-authenticate when remote services rotate their protocols. For a homelab operator who enjoys tinkering, this is manageable. For a family deployment where you promised your relatives “this will just work,” it is a serious liability.


End-to-End Encryption: The UX Gap

Matrix’s E2EE is based on the Double Ratchet algorithm (the same underlying mechanism as Signal) and is called Olm/Megolm. Olm handles pairwise device-to-device key exchange; Megolm is the room encryption protocol that creates a shared group session for efficiency.

E2EE is enabled by default in direct messages in modern clients and can be enabled per-room. The cryptographic foundation is sound. The UX is where things get complicated.

Cross-Signing

Cross-signing addresses the problem of multiple devices. Without it, every time you add a new device (new phone, new laptop), every other user who shares encrypted rooms with you must verify the new device individually. Cross-signing creates a hierarchy: you verify your own devices using a master signing key, and other users only need to verify your identity once. When they see a green shield on your name, it means your master key has been verified and all your devices are signed by that master key.

The practical friction: cross-signing requires that you have your Security Key (or Security Phrase) accessible when adding a new device, or that you verify the new device interactively from an existing trusted device using QR code scanning or emoji comparison. If you log in on a new device and skip verification, that device is untrusted, other users see a yellow warning on your messages, and encrypted room history from before you added the device may not be accessible.

Key backup provides a server-side encrypted backup of your room keys, protected by a recovery key. If you lose all your devices simultaneously, key backup means you can recover your message history. The backup itself is encrypted before upload, so the homeserver operator cannot read it — but you must not lose the recovery key.

For a technical team, this is manageable with some documentation and onboarding. For family members who do not understand why their messages show “unable to decrypt” after they reinstalled their phone, it creates support overhead that should not be underestimated. The verification step that feels straightforward to an engineer — “just scan the QR code” — can be a complete blocker for a non-technical user who does not have another trusted device available.


Performance Realities and Database Growth

Synapse’s performance profile is unlike most services you run in a homelab. The RAM usage is not primarily a function of how many users you have or how many messages per day are sent. It is primarily a function of which rooms your users participate in, specifically whether those rooms are large, highly federated rooms that have accumulated complex state histories.

State Resolution

Each Matrix room event is validated against the room’s current state. For small rooms with few participants and a short history, this is trivially fast. For a large public room — say #matrix:matrix.org, which has tens of thousands of members across hundreds of homeservers — the state DAG contains millions of events, and joining that room requires your server to download, authenticate, and resolve the entire state. This is not a one-time cost: every time a state event arrives (a user joins, a permission changes), Synapse’s state resolution algorithm v2 must walk the DAG and load significant portions of it into memory.

The practical implication: if any of your users joins #matrix:matrix.org or similar large public rooms, your small homeserver will experience a sustained memory spike and elevated CPU load for hours after the join. The official Synapse documentation acknowledges that joining these rooms on resource-constrained hardware (including single-board computers) can fail entirely due to OOM conditions.

Rough resource expectations for a small private server (10-50 users, no large federated rooms):

  • RAM: 1-2 GB resident, with spikes during state resolution
  • CPU: idle most of the time, spikes on joins and federation bursts
  • Disk: media store grows continuously; 50 active users with image sharing can accumulate gigabytes per month

For the Postgres database itself, the state_events and room_state tables grow without bound. Regular use of Synapse’s built-in media purge and retention policy features is mandatory, not optional, if you want to keep disk usage controlled. The S3 storage provider (synapse-s3-storage-provider) allows offloading media to object storage, which is strongly recommended for anything beyond a small family server — local media directories that grow to 50GB are not unusual for active servers.

Running for Different Audiences

Family (5-15 users, private server, no federation): Feasible on modest hardware (4 cores, 4GB RAM). Disable federation entirely with federation_domain_whitelist: [] or allow_public_rooms_over_federation: false. Avoid the large-room problem entirely. Set conservative registration controls. This is the most maintainable configuration and the one where the resource overhead is most predictable.

Small team (15-50 users, selective federation): Workable but requires real hardware or a small VPS. Budget 4GB RAM minimum, 8GB if users will join any external rooms. A managed Postgres instance separate from the Synapse container is worth considering. Back up the database and the media store separately. The self-hosting vs cloud calculus applies here — at this scale, a hosted solution like Element Cloud or even a third-party Matrix server might have lower total cost of ownership.

Public community (open registration, full federation): You are running infrastructure now. Synapse workers, a Redis instance for inter-worker coordination, separate media repository, rate limiting, spam moderation — the complexity grows substantially. This is not a weekend project.


Honest Trade-offs

The comparison to Slack or Discord is obvious but worth making explicit:

Factor Matrix + Synapse Slack Discord
Data ownership Full — you control the server Slack owns the data Discord owns the data
Federation Yes — interoperate across servers No No
E2EE Yes (with UX friction) Optional (paid) No (voice only)
Setup burden High Zero Zero
Ongoing maintenance High Zero Zero
Bridge availability Yes, with fragility N/A N/A
Cost at small scale VPS + time Free tier Free
Mobile UX Good (FluffyChat/Element) Excellent Excellent
Search in large history Acceptable Good Good

Against simpler self-hosted chat alternatives, the comparison is also instructive. Mattermost has a more familiar Slack-like interface, requires less operational complexity, and has no federation overhead. Zulip is excellent for threaded discussion. Rocket.Chat covers similar ground to Matrix for teams. None of these give you protocol-level federation or the bridge ecosystem, but all of them are easier to operate and explain to users.

For identity management and SSO integration, you can front Synapse with Authentik via OIDC, allowing users to authenticate with their existing SSO credentials rather than managing a separate Matrix account. Synapse supports the oidc_providers configuration block for exactly this purpose, and it reduces the password management burden for users who already have Authentik for other services.

The docker-compose-homelab patterns apply directly here — Synapse, Postgres, the bridge application services, and an optional Element Web container are all straightforward Docker Compose services, and keeping them in a single compose file with proper health checks and restart policies is the right operational approach.


Verdict

Matrix and Synapse are the right answer for a specific operator: someone who needs genuine data sovereignty and protocol-level federation, is comfortable maintaining a moderately complex service stack, and has users who can tolerate some E2EE friction in exchange for real privacy guarantees. For a technical team or homelab operator who fits that profile, a small private Synapse instance on Postgres, behind a reverse proxy, with registration locked down, is a genuinely solid deployment.

The wrong use cases are equally clear. Running Matrix as a convenience replacement for Discord for a gaming community is operational overkill for a social benefit that Slack’s free tier delivers without infrastructure work. Promising non-technical family members that Matrix will “just work like WhatsApp” is setting expectations that will be disappointed the first time cross-signing confusion causes “unable to decrypt” messages. Deploying bridges to multiple platforms and expecting them to remain stable without active maintenance is a setup for weekend incidents.

If you are in the right use case, start small: one homeserver, Postgres, no federation to large rooms, registration by shared secret, Element Web self-hosted, and no bridges for the first month. Get comfortable with the admin API, set up media purging, monitor the database size. Add bridges one at a time once the base is stable. Conduwuit’s successor Continuwuity is worth evaluating for small private deployments where Synapse’s resource overhead feels excessive.

The protocol is genuinely good. The ecosystem is maturing. The operational reality is that you are running infrastructure, and infrastructure requires care.


Sources

Comments