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?
- Family chat that isn't owned by Meta: Photos of the kids don't need to transit an advertising company's infrastructure.
- Homelab notifications: Anything that can send a webhook can post into a Matrix room. My alerts are sent to one place.
- Bridging: A single client for the two or three networks I'm stuck on for other people's sake.
- 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.ukidentities while the server actually runs onmatrix.example.uk. Other servers fetchhttps://example.uk/.well-known/matrix/serverto 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.