Who should run a homeserver, and what it costs you
Matrix is a federated chat network with end-to-end encryption by default in private rooms and a client (Element) that works everywhere. Your account lives on a homeserver, and the homeserver stores your room history, your device keys and the media you upload. Running your own means @you:example.com is yours, nobody can delete the account, and the metadata about who you talk to stays on your machine rather than on matrix.org. It also lets you give accounts to family, a team or a community under your rules.
Be honest with yourself about the cost before starting. Synapse is heavier than an XMPP or SimpleX server: Python plus PostgreSQL, 1 to 2 GB of RAM at idle, and disk that grows with every federated room you join because your server keeps a copy of that room’s state and media. A homeserver is also a long-term commitment; if it disappears, your rooms and history on it go too. The prepaid-days model fits an experiment (run it for two weeks), but a server you intend to keep should be renewed well before expiry.
Paying for it without an account or identity is worth more here than with most services, because a homeserver is a durable, public identity (your domain and IP are visible to every server you federate with). Decoupling the hosting bill from your name is the one layer you can add cheaply.
Sizing: 4 GB of RAM is the floor
The 2 GB plans will boot Synapse and then fall over the first time you join a large room, because state resolution for busy rooms happens in memory. Postgres needs its own share too. Treat 4 GB as the minimum and 8 GB as comfortable for a server that federates widely.
| Plan | Specs | From (EU) | Fits |
|---|---|---|---|
| BVCX23 | 2 vCPU / 4 GB / 40 GB | $11/mo | A few users, private rooms, light federation; disk will be the limit within a year |
| BVA11 | 2 vCPU Arm / 4 GB / 40 GB | $12/mo | Same as BVCX23; the matrix.org apt packages are amd64-only, so use the Docker image on Arm |
| BVX21 | 3 vCPU / 4 GB / 80 GB | $18/mo | The recommended starting point: apt packages, twice the disk |
| BVCX33 / BVA21 | 4 vCPU / 8 GB / 80 GB | $16 / $20/mo | A community of dozens, several large federated rooms |
| BVX31 / BVA31 | 4-8 vCPU / 8-16 GB / 160 GB | $32 / $40/mo | Hundreds of users, bridges, media-heavy rooms |
Disk growth is the thing people underestimate. A personal server that joins Matrix HQ and a handful of other large public rooms can accumulate 10 to 20 GB of Postgres data in a few months; media from remote servers is cached locally as well. The operations section covers retention settings that cap this. Pick a location near your users; the matrix.org packages and the Docker image behave the same in every datacenter.
Prepare the server
Everything below assumes a fresh Ubuntu 24.04 image and a root shell (prefix sudo if you created a user). Patch first, then lock down SSH and the firewall before anything else is listening.
apt update && apt full-upgrade -y
apt install -y ufw unattended-upgrades curl gnupg
# SSH: keys only. Put your public key in /root/.ssh/authorized_keys first and
# confirm you can log in with it before disabling passwords.
cat > /etc/ssh/sshd_config.d/00-keys-only.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
EOF
sshd -t && systemctl restart ssh
# Firewall: deny everything inbound except SSH for now.
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw --force enablePostgreSQL and the Synapse package
Synapse supports SQLite for testing only; use Postgres from the start (Ubuntu 24.04 ships PostgreSQL 16, which is within Synapse’s supported range). The database must be created with the C collation and UTF-8 encoding or Synapse refuses to start.
apt install -y postgresql
sudo -u postgres psql <<'EOF'
CREATE USER synapse_user WITH PASSWORD 'CHANGE_ME_LONG_RANDOM';
CREATE DATABASE synapse
ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C'
TEMPLATE=template0 OWNER synapse_user;
EOFInstall Synapse from the matrix.org repository rather than Ubuntu’s own matrix-synapse package, which is old and carries known vulnerabilities. The repository signing key fingerprint is AAF9 AE84 3A75 84B5 A3E4 CD2B CF45 A512 DE2D A058; compare it with the one printed in the Synapse installation docs. The package asks two debconf questions (server name and anonymous stats); answer them on the command line so the install is non-interactive.
apt install -y lsb-release wget apt-transport-https
wget -O /usr/share/keyrings/matrix-org-archive-keyring.gpg https://packages.matrix.org/debian/matrix-org-archive-keyring.gpg
gpg --show-keys /usr/share/keyrings/matrix-org-archive-keyring.gpg | grep -A1 pub
echo "deb [signed-by=/usr/share/keyrings/matrix-org-archive-keyring.gpg] https://packages.matrix.org/debian/ $(lsb_release -cs) main" \
> /etc/apt/sources.list.d/matrix-org.list
apt update
# server_name is the part after the colon in user IDs. It cannot be changed later.
echo "matrix-synapse-py3 matrix-synapse/server-name string example.com" | debconf-set-selections
echo "matrix-synapse-py3 matrix-synapse/report-stats boolean false" | debconf-set-selections
apt install -y matrix-synapse-py3
systemctl stop matrix-synapseNow point Synapse at Postgres and tell it that it sits behind a reverse proxy. Edit /etc/matrix-synapse/homeserver.yaml: replace the database block, and set the listener and the public URL. Everything else in the generated file can stay at its defaults for now.
# /etc/matrix-synapse/homeserver.yaml (edit these keys; leave the rest)
public_baseurl: https://matrix.example.com/
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ['127.0.0.1']
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: synapse_user
password: CHANGE_ME_LONG_RANDOM
database: synapse
host: localhost
cp_min: 5
cp_max: 10
# Keep registration closed; create users from the shell.
enable_registration: false
# Cap uploads and cache growth (see the operations section).
max_upload_size: 50M
media_retention:
remote_media_lifetime: 30d
retention:
enabled: falsesystemctl enable --now matrix-synapse
journalctl -u matrix-synapse -n 30 --no-pager
curl -s http://127.0.0.1:8008/_matrix/client/versions | head -c 200nginx, Let’s Encrypt and .well-known delegation
Clients and other servers will talk to matrix.example.com over 443, while user IDs stay @user:example.com. That works through delegation: https://example.com/.well-known/matrix/server tells other homeservers where federation lives, and /.well-known/matrix/client tells apps where the client API is. Both files are tiny JSON served from example.com. If example.com is hosted elsewhere, create the two files there; if it is this server, nginx serves them. Create A and AAAA records for matrix.example.com (and example.com if it points here) before running certbot.
apt install -y nginx certbot python3-certbot-nginx
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 8448/tcp # only needed if some remote server ignores .well-known; harmless to open
cat > /etc/nginx/sites-available/matrix <<'EOF'
server {
listen 80;
listen [::]:80;
server_name matrix.example.com example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
listen 8448 ssl http2 default_server;
listen [::]:8448 ssl http2 default_server;
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;
# Only the client and federation APIs. Never proxy /_synapse/admin to the internet.
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass http://127.0.0.1:8008; # no trailing slash: nginx must not rewrite the URI
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host:$server_port;
proxy_http_version 1.1;
client_max_body_size 50M; # match max_upload_size
}
}
# Delegation files on the bare domain (skip this block if example.com lives elsewhere).
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
location = /.well-known/matrix/server {
default_type application/json;
return 200 '{"m.server": "matrix.example.com:443"}';
}
location = /.well-known/matrix/client {
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://matrix.example.com"}}';
}
}
EOF
ln -sf /etc/nginx/sites-available/matrix /etc/nginx/sites-enabled/matrix
rm -f /etc/nginx/sites-enabled/default
# Get the certificate first with the sites disabled, then enable and reload.
systemctl stop nginx
certbot certonly --standalone --agree-tos --no-eff-email -m [email protected] -d matrix.example.com -d example.com
nginx -t && systemctl start nginxDrop -d example.com from the certbot line (and the third server block) if the bare domain is hosted elsewhere. certbot installs a systemd timer for renewal; add a deploy hook that runs systemctl reload nginx, or use certbot --nginx next time so it manages the reload itself. Test federation from the outside with the Matrix Federation Tester (federationtester.matrix.org): it checks DNS, the .well-known file, the certificate and the server keys in one go.
# Create the first (admin) user. Uses registration_shared_secret from homeserver.yaml.
register_new_matrix_user -c /etc/matrix-synapse/homeserver.yaml http://127.0.0.1:8008
# New user localpart: you
# Password:
# Make admin [no]: yesOptional: Element Web on its own hostname
You do not need to host a client; Element’s apps and app.element.io can log into any homeserver by entering matrix.example.com. Hosting Element Web yourself is convenient for people you invite and keeps the client code under your control. It must live on a different origin from the homeserver (element.example.com, not matrix.example.com), because serving user-uploaded media and a web app from the same origin is an XSS risk the Synapse docs warn about explicitly.
VER=$(curl -s https://api.github.com/repos/element-hq/element-web/releases/latest | grep -oP '"tag_name": "\K[^"]+')
cd /tmp
curl -LO https://github.com/element-hq/element-web/releases/download/$VER/element-$VER.tar.gz
curl -LO https://github.com/element-hq/element-web/releases/download/$VER/element-$VER.tar.gz.asc
# Import the Element release key from https://packages.element.io/element-release-key.gpg and verify:
curl -s https://packages.element.io/element-release-key.gpg | gpg --import
gpg --verify element-$VER.tar.gz.asc element-$VER.tar.gz
mkdir -p /var/www/element
tar xzf element-$VER.tar.gz -C /var/www/element --strip-components=1
cat > /var/www/element/config.json <<'EOF'
{
"default_server_config": {
"m.homeserver": { "base_url": "https://matrix.example.com", "server_name": "example.com" }
},
"disable_custom_urls": false,
"brand": "Element"
}
EOFAdd a plain static nginx server block for element.example.com with root /var/www/element, extend the certbot command with -d element.example.com, and reload. Upgrading is the same download-verify-extract sequence; keep config.json outside the tarball’s files so it survives.
Federation tradeoffs and disk growth
Federation is the point of Matrix and also where the resource use comes from. Every room you join is replicated to your server: its full state (membership, power levels, history for as far back as you can see) plus a cache of media that your users view. Joining a room with 50,000 members from a 4 GB server works, but the join can take minutes and the Postgres tables for that room will be gigabytes. Decide what kind of server you are running:
- Private server for a family or team that talks mostly to each other: set federation_domain_whitelist in homeserver.yaml to the handful of servers your users need, or leave it unset but tell users to avoid the giant public rooms. Resource use stays flat.
- Personal server that participates in big communities: keep 8 GB of RAM and 80 GB or more of disk, enable the media retention and purge jobs below, and expect Postgres to be the largest thing on the box.
- Community server with public rooms: add the Synapse admin API to your toolbox (room purges, user deactivation), consider a Sliding Sync-capable client setup, and watch the rate limiting defaults. This is BVX31 or larger territory.
Three settings keep disk predictable. media_retention.remote_media_lifetime (set to 30d above) deletes cached media from other servers after a month; local uploads are kept unless you also set local_media_lifetime. The Purge History admin API deletes old events from rooms you no longer care about. And the Delete Room admin API, used after everyone has left a large room, is the only way to reclaim that room’s state from Postgres. Run VACUUM in Postgres occasionally after large purges so the space is actually returned.
Operations: updates, monitoring, backups, firewall
- Updates: Synapse releases roughly every two weeks; the apt repository delivers them through unattended-upgrades if you allow the matrix.org origin, or apt upgrade manually. Read the upgrade notes for the rare breaking change; the database migrates automatically on restart. On Docker, pull the new tag and recreate the container.
- Monitoring: journalctl -u matrix-synapse -f for errors, the Federation Tester after any DNS or certificate change, and an uptime check on https://matrix.example.com/_matrix/client/versions. Synapse exposes Prometheus metrics if you enable the metrics listener on localhost.
- Backups: nightly pg_dump -Fc of the synapse database plus /var/lib/matrix-synapse/media and /etc/matrix-synapse (signing key included; losing the signing key breaks federation trust). Encrypt and ship off-box with restic. The automated backups add-on is a convenient extra for full-server rollback but not a substitute.
- Firewall: ufw status should list OpenSSH, 80, 443 and optionally 8448. Port 8008 and Postgres are bound to localhost and must stay that way.
- Abuse: keep enable_registration false; use registration tokens (registration_requires_token) if you want self-service signups for invitees. Synapse rate limits logins and messages by default; leave those on.
- Disk: df -h weekly, and du -sh /var/lib/matrix-synapse/media and the Postgres data directory monthly, so you move up a plan before the disk fills rather than after.
Which plan to pick
Start with BVX21 (3 vCPU, 4 GB RAM, 80 GB NVMe, from $18/mo) for a personal or family homeserver using the apt packages, or BVA11 ($12/mo, Arm) with the Docker image if 40 GB is enough and you are in Europe. A server that will join big public rooms or host a community should start at 8 GB: BVCX33 ($16/mo), BVA21 ($20) or BVX31 ($32, 160 GB). Configure a server with Ubuntu 24.04, pay in Bitcoin or Monero, set the DNS records while it provisions, and remember that server_name is permanent: choose the domain before you run the install.