[$ xmrhost] _

$ 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-default baseline (see harden-sshd and kernel-hardening-checklist) is in place.
  • A domain name pointing at the VPS — call it chat.example.invalid for 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/server for delegation. This lets you operate users as @alice:example.invalid while running the actual homeserver at chat.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:

sudo -u postgres psql -c '\\l synapse'
                              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:

curl -i https://example.invalid/.well-known/matrix/server
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:

sudo -u postgres psql -d synapse -c "SELECT count(*) FROM pg_stat_activity WHERE datname='synapse'"
 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:

  1. Subscribe operator email to the synapse-announce mailing list — Synapse pushes security advisories there with 48-72h windows.
  2. 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.
  3. Set up postgres backups: pg_dump synapse > /var/backups/synapse-$(date +%F).sql.gz on cron, encrypted to operator key, off-box. The media-store separately needs rsync backup; postgres alone is not a complete backup.
  4. Read provision-tor-hidden-service and 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