Self-Hosting a Matrix Server: What Actually Worked

Self-Hosting a Matrix Server: What Actually Worked
Photo by Octavian-Dan Craciun / Unsplash

I recently set up a private Matrix server on a 4GB VPS for my family. Not because I enjoy pain, but because I wanted a self-hosted alternative. So many platforms are asking for too much personal information. Then storing that information in an insecure fashion and it ends up being leaked (I'm looking at you Discord).

Though not specifically related; when Google shut off API access to the Nest thermostat that I paid for it made me realize that platform dependency sucks. Since I have the ability I wanted to try self-hosting more systems in my day to day.

The Stack

The core is Synapse (the Matrix reference homeserver) running behind Caddy as a reverse proxy, all wired together with Docker Compose. I also added a Synapse Admin container for GUI-based user management.

Coturn and Livekit for voice and video calling as well as the Livekit JWT service.

Caddy was the right call for the reverse proxy. It handles SSL certificates for your domain automatically; no Certbot configuration, no cron jobs. You point it at a domain and it figures out the rest.

The Synapse Admin UI sits at under 30MB of RAM, which on a 8GB VPS is practically free. It means I can reset passwords, manage users, and deactivate accounts without touching the terminal every time.

I can run all this alongside my existing portfolio, blog, and N8N systems with room to spare.

a group of purple cubes hanging from a metal bar
Photo by Shubham Dhage / Unsplash

Federation

Federation lets your server talk to the wider Matrix network — so your users can join rooms on matrix.org or message people on other homeservers. Getting this working required adding .well-known headers in the Caddyfile so other servers could find mine correctly.

One thing to know: joining a large public room for the first time kicks off a State Resolution process. For a smaller VPS, this can spike CPU and RAM hard enough to cause real problems. Join big rooms one at a time. Give the server time to settle before adding another.

Getting the First Admin

This is the part that catches everyone. The Synapse Admin API requires an admin user to authenticate — but you can't promote a user to admin through the API until you already have one. Classic chicken-and-egg.

Two reliable paths out of that loop:

  1. register_new_matrix_user — a CLI script included with Synapse that can create an admin user directly.
  2. Direct SQLite update — modify the database on the mapped volume from the VPS host.

Important: modern Synapse Docker images are distroless. There's no sqlite3 or curl inside the container. Run all database operations from the host machine using the mapped volume path.

After promoting a user via the database, don't expect it to work immediately. You have to log out and back in. The active session holds an old access token that doesn't reflect the new permissions. I wasted a while on this one.

Also: Matrix user IDs are case-sensitive in the database. If you run a SQL update with the wrong casing, it silently updates zero rows. Double-check the exact username as it appears in the registration record.

Open padlock with combination lock on keyboard
Photo by Sasun Bughdaryan / Unsplash

Keeping It Private

Synapse has an option for registration tokens — you generate a token, share the link with family, and anyone without it can't create an account. The server stays closed to the public without completely disabling registration. Disabling public registration keeps folks out that you would potentially need to verify yourself should the laws change.

Performance Tuning

One config change made a noticeable difference: disabling global presence.

use_presence: false

When you federate with large servers, presence tracking (knowing who's online/offline) generates a constant stream of traffic. Turning it off cuts a significant chunk of idle workload. For a small private server where everyone knows each other anyway, it's an easy trade.

Nuance

When setting up the voice/video calls make sure you pay attention to the tokens that go between the JWT Livekit service, Livekit, and the homeserver.yaml files. That tripped me up more than once.

Quick Reference: Gotchas

Issue   What to do
Admin API not working after DB promotion   Log out and back in to get a fresh token
sqlite3 not found in container   Run from VPS host using mapped volume
SQL update affected 0 rows   Check username casing — it's case-sensitive
CPU spike joining a room   Join large rooms one at a time
High idle load from federation   Set use_presence: false in config

This setup has been running cleanly since I got it sorted. My friends that have used it say that the voice chat quality is just as good as Discord's. Video chat works fine as well. If you're standing up something similar and hit a wall, feel free to drop a comment below.

Hopefully I won't regret all this later.