$ xmrhost-cli docs show --topic=setup-matrix-homeserver
[$ ] doc: setup-matrix-homeserver
// Set up a Synapse Matrix homeserver on a XMRHost VPS — federation, registration policy, media retention
// published=2026-04-29 · updated=2026-04-29 · diff=advanced · read=26min · tags=[matrix, synapse, federation, hardening]
// ABSTRACT
abstract
End-to-end procedure for bringing up a self-hosted Synapse homeserver on a Monero-paid XMRHost VPS. Covers the well-known delegation, the closed-registration workflow with email-token verification, the federation allowlist that a small operator actually wants (NOT a global federation), the media-store retention policy that keeps disk usage bounded, the postgres schema setup that scales past 2 GiB of message history, and the four post-launch checks that catch the most common misconfigurations.
Scope and assumptions
This is the brand’s procedure for bringing up a single-purpose Synapse homeserver — federated by default with a small allowlist of partner servers, closed-registration, behind an nginx reverse proxy with a Let’s Encrypt certificate. It is NOT a procedure for running a public homeserver open to general signup, which has a different operational profile (anti-abuse rate limiting, bot detection, GDPR data-deletion workflows, etc.) outside the scope of this doc.
Baseline assumptions:
- Debian 12 (bookworm) on a XMRHost VPS in Iceland or Romania.
- The brand’s
hardened-by-defaultbaseline (seeharden-sshdandkernel-hardening-checklist) is in place. - A domain name pointing at the VPS — call it
chat.example.invalidfor the rest of this doc. Federation requires a real DNS name; an IP-only deployment is not meaningfully federatable. [Matrix Specification — server discovery via .well-known and SRV] - A second hostname
example.invalid(the bare domain) under operator control, where we’ll publish.well-known/matrix/serverfor delegation. This lets you operate users as@alice:example.invalidwhile running the actual homeserver atchat.example.invalid— a common and recommended deployment shape.
If the bare-domain delegation is not feasible (e.g. you don’t control example.invalid), skip step 2 and run users as @alice:chat.example.invalid directly. Federation still works; the user IDs just look uglier.
Step 1 — install postgres, NOT sqlite
The default Synapse install on Debian routes synapse-py3 to a sqlite database. This works for a 5-user homeserver and absolutely will not work for anything past that — federation traffic alone, with one or two active rooms, can write hundreds of events per minute and sqlite’s writer-locks the entire DB during each insertion. Switch to postgres before bringing up Synapse, not after.
apt update
apt install -y postgresql postgresql-contrib
systemctl enable --now postgresql
Create the synapse database with the C collation that Synapse requires (UTF8 + the C locale — NOT en_US.UTF-8 — for predictable string comparison ordering):
sudo -u postgres psql <<'SQL'
CREATE USER synapse WITH PASSWORD 'CHANGE_ME_TO_A_LONG_RANDOM_VALUE';
CREATE DATABASE synapse
ENCODING 'UTF8'
LC_COLLATE='C'
LC_CTYPE='C'
TEMPLATE template0
OWNER synapse;
SQL
The LC_COLLATE='C' and LC_CTYPE='C' are NOT optional. Synapse’s schema migrations rely on byte-wise string ordering; running with en_US.UTF-8 produces silent data corruption that surfaces months later as “events out of order in this room”. The Synapse docs are blunt about this. [matrix-org/synapse — postgres setup docs]
Verify the database is reachable:
List of databases Name | Owner | Encoding | Collate | Ctype | Access privileges ----------+---------+----------+-----------+-----------+------------------- synapse | synapse | UTF8 | C | C | (1 row)
Step 2 — well-known delegation on the bare domain
Synapse running at chat.example.invalid can host users @alice:example.invalid if the bare domain publishes a .well-known/matrix/server JSON pointer at the homeserver. This is the recommended Matrix-spec delegation shape — federation servers ask https://example.invalid/.well-known/matrix/server, get back the homeserver hostname + port, and route there.
Set up the bare-domain web server (assumed already running on a separate VPS or the same one with a different vhost) with this file at /.well-known/matrix/server:
{
"m.server": "chat.example.invalid:443"
}
And at /.well-known/matrix/client (used by Element clients to find the homeserver):
{
"m.homeserver": {
"base_url": "https://chat.example.invalid"
},
"m.identity_server": {
"base_url": "https://vector.im"
}
}
Both files MUST be served with Content-Type: application/json AND Access-Control-Allow-Origin: * (the second is needed for browser-based Matrix clients to read the file). Test from a third box:
HTTP/2 200
content-type: application/json
access-control-allow-origin: *
{"m.server":"chat.example.invalid:443"} If the Access-Control-Allow-Origin header is missing, Element web clients silently fall back to “couldn’t find homeserver” with no useful error. [Matrix Specification — well-known client config]
Step 3 — install Synapse from the matrix.org apt repo
The Debian-shipped Synapse package lags upstream by 6-12 months and the upstream developers explicitly recommend the matrix.org apt repo over distro packaging. Add the upstream repo:
apt install -y wget gnupg
wget -qO- https://packages.matrix.org/debian/matrix-org-archive-keyring.gpg \
| tee /usr/share/keyrings/matrix-org-archive-keyring.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/matrix-org-archive-keyring.gpg] https://packages.matrix.org/debian/ bookworm main" \
> /etc/apt/sources.list.d/matrix-org.list
apt update
apt install -y matrix-synapse-py3
The package install prompts for the homeserver name. Enter example.invalid (the bare domain — the user-suffix domain), NOT chat.example.invalid. This locks the homeserver’s server_name config to the right value; it cannot be changed later without losing every user.
Step 4 — the canonical homeserver.yaml
The package writes a default /etc/matrix-synapse/homeserver.yaml. Most of it is fine; the brand override goes into /etc/matrix-synapse/conf.d/00-xmrhost.yaml:
# /etc/matrix-synapse/conf.d/00-xmrhost.yaml — XMRHost hardened Synapse.
# ---------- listen ports ---------------------------------------------------
# Bind to localhost only; nginx in front terminates TLS and proxies to here.
# 8008 = client-server API, 8448 = federation API. We keep them on the
# same listener with port-resource separation — nginx routes by Host header.
listeners:
- port: 8008
type: http
tls: false
bind_addresses: ["127.0.0.1", "::1"]
x_forwarded: true
resources:
- names: [client, federation]
compress: true
# ---------- database (postgres from step 1) --------------------------------
database:
name: psycopg2
args:
user: synapse
password: CHANGE_ME_TO_THE_VALUE_FROM_STEP_1
database: synapse
host: 127.0.0.1
port: 5432
cp_min: 5
cp_max: 10
# ---------- registration policy --------------------------------------------
# Closed registration. Manual account creation only via `register_new_matrix_user`
# (the CLI tool the package installs). Public signup is OFF.
enable_registration: false
# Even when registration is enabled by toggling above, require a CAPTCHA AND
# email verification. Belt-and-braces against the day someone fat-fingers
# the toggle.
enable_registration_captcha: true
registrations_require_3pid: ["email"]
# ---------- federation policy ----------------------------------------------
# Federation enabled, but with an allowlist. The allowlist is the brand
# default — federate with matrix.org and a couple of large public homeservers
# the operator deems trustworthy, no one else. Every other federation
# request is silently dropped (NOT 403'd, to avoid leaking the existence of
# the homeserver to indiscriminate scanners).
federation_domain_whitelist:
- matrix.org
- mozilla.org
- kde.org
# Add operator-curated peer servers here.
# Federation IP blacklist — never connect to these CIDRs. Default Synapse
# blocks RFC1918 + link-local; we extend with the well-known scanner ranges
# the brand maintains in its threat-model notes.
federation_ip_range_blacklist:
- "127.0.0.0/8"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
- "100.64.0.0/10"
- "169.254.0.0/16"
- "::1/128"
- "fe80::/64"
- "fc00::/7"
# ---------- media store ----------------------------------------------------
# Bound media-store growth. Synapse keeps every file ever uploaded by
# default; on a federated homeserver this is unbounded. Cap at 100 MiB per
# upload, retain remote media for 90 days.
max_upload_size: 100M
max_image_pixels: 32M
media_retention:
remote_media_lifetime: 90d
local_media_lifetime: 365d
# ---------- logging --------------------------------------------------------
# WARN level — Synapse at INFO floods the journal. WARN gives federation
# errors and serious problems; INFO is for dev only.
log_config: "/etc/matrix-synapse/log.yaml"
# ---------- federation rate limiting ---------------------------------------
# The brand defaults are tighter than Synapse's defaults. Tune up if you
# see legitimate federation peers being rate-limited (visible in the logs).
rc_federation:
window_size: 1000
sleep_limit: 10
sleep_delay: 500
reject_limit: 50
concurrent: 3
# ---------- presence -------------------------------------------------------
# Disable presence (typing indicators, last-seen times). Big reduction in
# federation chatter. Operators of small federated servers almost never
# want presence; users adapt.
presence:
enabled: false
# ---------- url previews ---------------------------------------------------
# Disable URL preview generation. URL previews are an SSRF surface (the
# homeserver fetches arbitrary URLs on behalf of authenticated users) and
# the threat model on a private homeserver doesn't justify them.
url_preview_enabled: false
# ---------- secrets --------------------------------------------------------
# Both of these are auto-generated on package install — don't edit.
# (Listed here for documentation only; the actual values live in
# /etc/matrix-synapse/homeserver.yaml.)
# registration_shared_secret: ...
# macaroon_secret_key: ...
# form_secret: ...
Reload Synapse:
systemctl restart matrix-synapse
journalctl -u matrix-synapse -n 50 --no-pager
The first start with the new postgres backend takes 60-180 seconds — Synapse runs the schema migrations from scratch. Watch for Synapse now listening on TCP port 8008 in the journal; that’s the success line.
Step 5 — nginx reverse proxy with TLS
Synapse listens on 127.0.0.1:8008 only. nginx in front terminates TLS, proxies HTTP, and routes the federation port (8448) and the client-server port (443/HTTPS) to the same upstream. Install nginx and certbot:
apt install -y nginx certbot python3-certbot-nginx
Initial certificate issuance (Synapse must NOT be running on port 80; the --standalone plugin will bind it briefly):
certbot certonly --standalone \
-d chat.example.invalid \
--agree-tos --no-eff-email --email operator@example.invalid
Drop the brand vhost into /etc/nginx/sites-available/synapse.conf:
# /etc/nginx/sites-available/synapse.conf
# XMRHost Synapse fronting — terminates TLS for both the client-server
# API on 443 and the federation API on 8448. Same upstream both times.
upstream synapse {
server 127.0.0.1:8008;
keepalive 16;
}
# Redirect 80 → 443 for the client-server API.
server {
listen 80;
listen [::]:80;
server_name chat.example.invalid;
return 301 https://$host$request_uri;
}
# Client-Server API (Element web, Matrix mobile clients) on 443.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name chat.example.invalid;
ssl_certificate /etc/letsencrypt/live/chat.example.invalid/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.invalid/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
# Synapse proxy. /_matrix/ + /_synapse/client are the only paths
# Synapse serves; everything else 404s on this vhost.
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass http://synapse;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# Synapse may stream long-poll connections; bump the timeout.
proxy_read_timeout 600s;
# Allow large file uploads up to the homeserver.yaml cap.
client_max_body_size 100M;
}
}
# Federation API on 8448.
server {
listen 8448 ssl http2;
listen [::]:8448 ssl http2;
server_name chat.example.invalid;
ssl_certificate /etc/letsencrypt/live/chat.example.invalid/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.invalid/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://synapse;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
}
Symlink, test, reload:
ln -sf /etc/nginx/sites-available/synapse.conf /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
Open the firewall (assuming nftables — see kernel-hardening-checklist for the full ruleset):
nft add rule inet filter input tcp dport { 80, 443, 8448 } accept
Step 6 — create the first user
Closed registration means no signup form. The package installs register_new_matrix_user for the operator to create accounts manually:
register_new_matrix_user -c /etc/matrix-synapse/homeserver.yaml http://localhost:8008
Interactive prompts: username (e.g. alice), password (use a passphrase generator, NOT a typed password — Synapse hashes with bcrypt but the user has to type it once), admin (yes for the operator account, no for everyone else).
The user’s full Matrix ID is @alice:example.invalid (the bare domain via well-known delegation, NOT chat.example.invalid).
Step 7 — four post-launch checks
Check 1 — federation tester
The Matrix Federation Tester is the canonical “is this homeserver federatable” check:
curl -s 'https://federationtester.matrix.org/api/report?server_name=example.invalid' | python3 -m json.tool | head -30
The output’s Success field should be true, and the WellKnownResult.m.server should match chat.example.invalid:443. If Success is false, the JSON identifies which sub-check failed (commonly: well-known not served, well-known has wrong content type, federation cert mismatch).
Check 2 — postgres connection holding
Synapse opens 5-10 long-lived postgres connections per worker. Confirm:
count ------- 6 (1 row)
If this is 0, Synapse is using sqlite still (the database: block in homeserver.yaml didn’t override the package default). If it’s >20, you have a connection leak — restart Synapse and report a bug.
Check 3 — disk usage of the media store
du -sh /var/lib/matrix-synapse/media_store
This grows. The retention policy from step 4 caps remote media at 90 days, but local media (uploads from your own users) is retained 365 days. Plan for ~100 MiB per active user per month if they share images casually.
Check 4 — federation log clean
journalctl -u matrix-synapse -n 200 --no-pager | grep -i 'federation' | grep -i 'error'
Expected: zero errors. Common false positives: federation request to <server> failed: SSL: CERTIFICATE_VERIFY_FAILED from peers using self-signed certs that aren’t on the operator allowlist (those drop silently elsewhere; here they show because we whitelisted them). Real federation errors are typically wrong well-known content types or DNS lookup failures.
Closing — what to do next
The homeserver is operational. Order of next steps:
- Subscribe operator email to the
synapse-announcemailing list — Synapse pushes security advisories there with 48-72h windows. - Configure
synapse-admin(the web UI for the admin API) on a separate vhost behind a basic-auth gate. Don’t expose it to the public internet. - Set up postgres backups:
pg_dump synapse > /var/backups/synapse-$(date +%F).sql.gzon cron, encrypted to operator key, off-box. The media-store separately needsrsyncbackup; postgres alone is not a complete backup. - Read
provision-tor-hidden-serviceand consider exposing an onion-only Synapse listener for at-risk users — the federation API stays clearnet (federation over Tor is poorly supported) but client-server can be onion-only. Property of the homeserver, not the federation.
Federation is a federation: peer servers can subpoena your message metadata via their own logs even when your end is encrypted. The brand assumption is that anything sent to a non-allowlisted homeserver leaves the operator’s threat envelope.
// END OF DOC
$ cd /docs # back to the manual