Self-Hosting Matrix

Building Your Own Federated Chat Server

Contents

I've been pulling my communications off third-party platforms for years, and messaging was the last big holdout. Initially moved from FB to WhatsApp in 2018, then left WhatsApp for Signal in 2021 following their infamous privacy policy update. Signal has been my daily driver since, but I still actively need to check WhatsApp. This weekend I finally deployed a Matrix homeserver, and it's now handling my personal chat, a couple of family rooms, and bridges into networks I can't convince people to leave.

What is Matrix?

Matrix is an open protocol for decentralised, real-time communication. The easiest way to think of it is "email, but for chat". With email, you can have an account on any provider and still message anyone else, because everyone uses SMTP. Matrix does the same for instant messaging: your account lives on a homeserver, and that homeserver federates with every other homeserver in the world over HTTPS.

A few interesting features:

  • Federation: Rooms are replicated across every server with a participant in them. There's no SPOF and no company that can delete a community overnight.
  • End-to-end encryption: E2EE (based Olm/Megolm, similar to Signal's protocol) is on by default for private conversations.
  • Bridging: Matrix can bridge to WhatsApp, Telegram, Discord, IRC, Signal and more, letting you consolidate everything into one client.
  • Open ecosystem. Multiple servers (Synapse, Dendrite, Conduit), multiple clients (Element, FluffyChat, Cinny), and a spec anyone can implement.

The reference server implementation is Synapse, written in Python and maintained by Element. It's the most feature-complete option and the one I deployed.

Why bother self-hosting it?

  1. Family chat that isn't owned by Meta: Photos of the kids don't need to transit an advertising company's infrastructure.
  2. Homelab notifications: Anything that can send a webhook can post into a Matrix room. My alerts are sent to one place.
  3. Bridging: A single client for the two or three networks I'm stuck on for other people's sake.
  4. A comms channel I control end to end: If a platform changes its terms, raises prices, or vanishes, Matrix rooms and history are unaffected.

The build

My setup: a small VPS (2 vCPU / 4GB RAM works fine), Ubuntu 24.04, Docker Compose, PostgreSQL as the db, and Nginx as the reverse proxy with a Let's Encrypt certificate. The homeserver will live at matrix.example.uk, but user IDs will be the cleaner @zsolt:example.uk thanks to well-known delegation.

How it comes together:

  • Synapse listens on port 8008 (plain HTTP, internal only) and does all the actual Matrix work.
  • PostgreSQL stores everything (events, device keys, media metadata). SQLite technically works but I prefer Postgres.
  • Nginx terminates TLS on 443 for clients and 8448 for federation, proxying both to Synapse.
  • Delegation lets you use @user:example.uk identities while the server actually runs on matrix.example.uk. Other servers fetch https://example.uk/.well-known/matrix/server to discover where to send federation traffic.

Step 1: DNS and prerequisites

Create an A record for matrix.example.uk pointing at your server, then install the essentials:

sudo apt update && sudo apt upgrade -y
sudo apt install -y docker.io docker-compose-v2 nginx certbot python3-certbot-nginx
sudo systemctl enable --now docker

Step 2: Generate the Synapse config

Synapse has its own config generator we need to run once to produce homeserver.yaml and signing keys:

sudo mkdir -p /opt/matrix/synapse
cd /opt/matrix

docker run -it --rm \
  -v /opt/matrix/synapse:/data \
  -e SYNAPSE_SERVER_NAME=example.uk \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest generate

Note that SYNAPSE_SERVER_NAME is your identity domain (example.uk), not the subdomain the server runs on. This is baked into every user ID and room ID permanently and you cannot change it later without starting over.

Step 3: Docker Compose stack

Create /opt/matrix/docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: synapse
      POSTGRES_PASSWORD: change-me-to-something-long
      POSTGRES_DB: synapse
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
    volumes:
      - ./postgres:/var/lib/postgresql/data

  synapse:
    image: matrixdotorg/synapse:latest
    restart: unless-stopped

Now edit /opt/matrix/synapse/homeserver.yaml and swap the default SQLite block for Postgres:

database:
  name: psycopg2
  args:
    user: synapse
    password: change-me-to-something-long
    database: synapse
    host: postgres
    cp_min: 5
    cp_max: 10

Ensure these two settings are set:

enable_registration: false
registration_shared_secret: "generate-a-long-random-string"

Leave open registration off unless you want to host spam bots. The shared secret lets you create accounts from the CLI instead. Spin it up:

cd /opt/matrix
docker compose up -d
curl http://127.0.0.1:8008/_matrix/client/versions

Step 4: Nginx and TLS

Get a certificate and create the vhost:

sudo certbot certonly --nginx -d matrix.example.uk

/etc/nginx/sites-available/matrix:

server {
    listen 443 ssl http2;
    listen 8448 ssl http2 default_server;
    server_name matrix.example.uk;

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

    location ~ ^(/_matrix|/_synapse/client) {
        proxy_pass http://127.0.0.1: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;
    }
}
sudo ln -s /etc/nginx/sites-available/matrix /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Port 8448 is the default federation port. Open 443 and 8448 in your firewall.

Step 5: Delegation

On whatever serves your apex domain (example.uk), publish two JSON files: https://example.uk/.well-known/matrix/server:

{ "m.server": "matrix.example.uk:443" }

https://example.uk/.well-known/matrix/client:

{ "m.homeserver": { "base_url": "https://matrix.example.uk" } }

Serve them with Content-Type: application/json and an Access-Control-Allow-Origin: * header on the client file, this allows you to have short user IDs while the server runs on a subdomain. If you delegate via well-known on 443, you likely don't even need 8448 exposed.

Step 6: Create your account and verify

docker exec -it matrix-synapse-1 register_new_matrix_user \
  -u zsolt -a -c /data/homeserver.yaml http://localhost:8008

The -a flag makes the user admin. Then run your domain through the Matrix Federation Tester. Log in with Element (web, desktop, or mobile), connect at example.uk, and you're federated with the entire Matrix network.

Reflections after a week

The good. It just works great and federation is neat to see in action when someone from matrix.org joins a room hosted on your own server. Resource usage on a personal server is modest with Synapse idling around 400-500MB of RAM for me, spiking only when it first joins large federated rooms. E2EE in Element is works well enough that non-technical family members haven't noticed it's there.

The less good. Joining a huge public room (Matrix HQ, for instance) hammers the server for several minutes while it backfills state. And Synapse's database grows quickly, so schedule media retention and set forgotten_room_retention_period early rather than discovering a 40GB Postgres volume later. Also, you cannot use a CF tunnel for this as federation requires port 8448 to be available. You could potentially front it with Pangolin though, so no port forwarding is needed at all.

Would I recommend it? If you're comfortable running any self-hosted service, absolutely. The stack is three containers and an Nginx vhost, way less complex than a lot of things in the average homelab, and unlike most of them, it replaces something you'd otherwise "rent" from a company whose incentives aren't yours.