Your Chat, Your Rules: Running a Private Matrix Server on Debian 13
Table of Contents
If your team is using Slack, Discord, or Microsoft Teams, you’re renting your communication infrastructure from someone else. You’re subject to their pricing, their data policies, their uptime, and their decisions about what you can and can’t do. Slack’s free tier famously hides messages older than 90 days — your team’s knowledge, decisions, and history locked behind a paywall. Discord stores everything on their servers. Teams sends your data to Microsoft. And if any of these services go down, your team goes dark.
Why Matrix? — Image generated via Nano banana
Matrix is a different model entirely.
Matrix is an open standard and protocol for real-time communication — think of it like email, but for instant messaging. Anyone can run a server, servers can talk to each other (federation), and users on different servers can still communicate. It’s decentralised by design, which means no single company controls it, no single server is a point of failure, and no one can take it away from you.
The privacy and ownership story is compelling:
- Your data, your server. When you self-host Matrix with federation disabled, every message, every file, every conversation stays within your own infrastructure. No third party can read it, sell it, or subpoena it without going through you.
- No artificial limits. Full message history, always. No 90-day cutoff. No per-seat pricing that makes you think twice before adding a contractor. No storage limits beyond your own disk.
- Open source. Synapse, the reference Matrix homeserver, is fully open source (Apache 2.0). Element, the most popular client, is open source. The protocol itself is an open standard. You can audit every line of code that handles your communications.
- Full control over features. End-to-end encryption, file sharing, voice and video calls, bots, integrations — all configurable by you, not by a vendor’s pricing tier.
- No vendor lock-in. Your users connect with any Matrix-compatible client — Element, Cinny, Fluffychat, Beeper, and dozens more. If you want to migrate to a different homeserver implementation tomorrow, you can.
For companies, startups, research groups, and small teams, this is the Slack or Discord experience — rooms, direct messages, threads, file sharing, reactions — but running entirely within your own ecosystem, under your own control, at your own cost (which is just the cost of a small VM).
This guide walks you through setting up a fully private, self-hosted Matrix homeserver using Synapse on a fresh Debian 13 (Trixie) VM or bare-metal machine — no Docker required. By the end you’ll have a working Matrix server with PostgreSQL, token-based registration, and a web client accessible via a custom domain.
I previously ran a federated Matrix homeserver in Docker on a Raspberry Pi, but federation and Synapse’s memory footprint eventually outgrew the Pi. This setup takes the opposite approach: no Docker, no federation, running natively on a proper VM — leaner, more private, and easier to reason about.
What you’ll have at the end
- Synapse Matrix homeserver running natively on Debian 13
- PostgreSQL as the database backend
- Token-based registration — only people you invite can sign up
- TLS terminated at a reverse proxy (nginx on your gateway)
- Federation disabled — your data stays on your server
- A working Matrix room you can chat in
Prerequisites
- A Debian 13 Trixie VM or bare-metal machine (2 vCPU, 4GB RAM, 40GB disk is sufficient)
- A domain name with a subdomain pointing to your server — e.g.
matrix.yourdomain.com - An nginx reverse proxy handling TLS termination (Let’s Encrypt or your own certs)
- Root access to the Matrix machine
- All commands starting with
#assumes root user, while$indicates regular user (on the same matrix VM/machine).
High level architecture
High-level architecture of running Matrix on Debian 13 behind a reverse proxy (Image generated via Nano banana)
Phase 0: System preparation
Update your system to get the latest apps. Then install dependencies.
# apt update && apt upgrade -y
# apt install -y \
curl wget gnupg lsb-release \
ca-certificates apt-transport-https \
sudo ufw \
python3 python3-pip \
build-essential git
Firewall setup
Here, we use ufw for setting up a basic firewall
# ufw allow ssh
# ufw allow 8008/tcp # Synapse — gateway nginx connects here
# ufw enable
# ufw status
Note: Port 8448 (Matrix federation) is intentionally NOT opened. This is a private server — no external Matrix servers can connect.
Once Synapse is running, tighten the firewall to only allow your gateway:
# ufw delete allow 8008/tcp
# ufw allow from YOUR_GATEWAY_IP to any port 8008
Phase 1: Install PostgreSQL
1.1 — Install
# apt install -y postgresql postgresql-contrib python3-psycopg2
# systemctl enable postgresql
# systemctl start postgresql
1.2 — Create Synapse database and user
# sudo -u postgres psql << 'EOF'
CREATE USER synapse WITH PASSWORD 'YOUR_STRONG_DB_PASSWORD';
CREATE DATABASE synapse
ENCODING 'UTF8'
LC_COLLATE='C'
LC_CTYPE='C'
TEMPLATE=template0
OWNER synapse;
GRANT ALL PRIVILEGES ON DATABASE synapse TO synapse;
EOF
1.3 — Verify
$ sudo -u postgres psql -c "\l"
# Should show the synapse database in the list
Phase 2: Install Synapse
2.1 — Add the Matrix.org apt repository
Important: The Debian Trixie package is not available in Debian’s own repositories. Use the official Matrix.org repository instead.
# sudo wget -O /usr/share/keyrings/matrix-org-archive-keyring.gpg \
https://packages.matrix.org/debian/matrix-org-archive-keyring.gpg
# echo "deb [signed-by=/usr/share/keyrings/matrix-org-archive-keyring.gpg] \
https://packages.matrix.org/debian/ $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/matrix-org.list
# apt update
2.2 — Install Synapse
# apt install -y matrix-synapse-py3
During install you will be prompted for:
- Server name: enter
matrix.yourdomain.com - Report statistics: enter
no
2.3 — Verify Synapse installed
# dpkg -l matrix-synapse-py3
Should show 'ii' status and the installed version number
Phase 3: Configure Synapse
Synapse’s main config lives at /etc/matrix-synapse/homeserver.yaml. The apt install already created two files in /etc/matrix-synapse/conf.d/:
server_name.yaml— containsserver_name: matrix.yourdomain.comreport_stats.yaml— containsreport_stats: false
Do not redefine these keys in any additional config files — duplicates cause startup errors. Use additional conf.d/ files for everything else.
Note:
matrix-synapseuser and group are automatically created by the apt package during install. This is the dedicated system account Synapse runs as.
3.1 — Server config
# tee /etc/matrix-synapse/conf.d/server.yaml << 'EOF'
#Disable federation — private server, no external Matrix servers
federation_domain_whitelist: []
federation_enabled: false
#Disable presence tracking - biggest CPU hog, not needed for private use
use_presence: false
# Registration - token required
enable_registration: true
registration_requires_token: true
# Media storage
media_store_path: /var/lib/matrix-synapse/media
# Logging
log_config: "/etc/matrix-synapse/matrix.yourdomain.com.log.config"
EOF
3.2 — Listeners config
The default homeserver.yaml tries to bind Synapse to both ::1 (IPv6) and 127.0.0.1 (IPv4). If IPv6 is not enabled on your machine, Synapse will fail to start with Address family not supported by protocol. We also need Synapse to listen on the network interface so your gateway nginx can reach it. Override the listeners:
# tee /etc/matrix-synapse/conf.d/listeners.yaml << 'EOF'
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ['0.0.0.0']
resources:
- names: [client, federation]
compress: false
EOF
# chown matrix-synapse:matrix-synapse /etc/matrix-synapse/conf.d/listeners.yaml
3.3 — Database config
# tee /etc/matrix-synapse/conf.d/database.yaml << 'EOF'
database:
name: psycopg2
txn_limit: 10000
args:
user: synapse
password: YOUR_STRONG_DB_PASSWORD
database: synapse
host: localhost
port: 5432
cp_min: 5
cp_max: 10
EOF
3.4 — Generate a registration shared secret
This is used to create admin users from the command line:
# SHARED_SECRET=$(openssl rand -base64 32)
# echo "registration_shared_secret: \"$SHARED_SECRET\"" | \
sudo tee /etc/matrix-synapse/conf.d/secrets.yaml
Save this secret somewhere safe
# echo "Your shared secret: $SHARED_SECRET"
3.5 — Suppress warnings and set macaroon secret
Two warnings appear on first start that need fixing:
trusted_key_serverswarning — noisy but harmless since federation is disabledmacaroon_secret_keymissing — without it, all user sessions are invalidated every time Synapse restarts
# SECRET=$(openssl rand -hex 32)
# tee /etc/matrix-synapse/conf.d/misc.yaml << EOF
suppress_key_server_warning: true
macaroon_secret_key: "$SECRET"
EOF
# chown matrix-synapse:matrix-synapse /etc/matrix-synapse/conf.d/misc.yaml
# chmod 600 /etc/matrix-synapse/conf.d/misc.yaml
3.6 — Set correct permissions
# chown -R matrix-synapse:matrix-synapse /etc/matrix-synapse/
# chmod 600 /etc/matrix-synapse/conf.d/secrets.yaml
# chmod 600 /etc/matrix-synapse/conf.d/database.yaml
3.7 — Tune rate limits for private use
Synapse ships with rate limits designed to protect large, public homeservers from the open internet — spam bots, abusive clients, and federation-based denial of service. On a private, non-federated server like ours, those defaults are unnecessarily restrictive and will get in your way the moment you do any bulk administration (clearing message history, removing users, redacting messages).
Create a dedicated config file for rate limits:
# tee /etc/matrix-synapse/conf.d/ratelimiting.yaml << 'EOF'
rc_message:
per_second: 50 # default: 0.2
burst_count: 500 # default: 10
rc_admin_redaction:
per_second: 50 # default: 1
burst_count: 2000 # default: 50
rc_joins:
local:
per_second: 10 # default: 0.1
burst_count: 100 # default: 10
remote:
per_second: 10 # default: 0.01
burst_count: 100 # default: 10
rc_invites:
per_room:
per_second: 10 # default: 0.3
burst_count: 100 # default: 10
per_user:
per_second: 10 # default: 0.003
burst_count: 100 # default: 5
rc_registration:
per_second: 5 # default: 0.17
burst_count: 20 # default: 3
rc_login:
address:
per_second: 5 # default: 0.17
burst_count: 20 # default: 3
account:
per_second: 5 # default: 0.17
burst_count: 20 # default: 3
failed_attempts:
per_second: 5 # default: 0.17
burst_count: 20 # default: 3
rc_federation:
window_size: 1000 # default: 1000
sleep_limit: 100 # default: 10
sleep_delay: 500 # default: 500
reject_limit: 200 # default: 50
concurrent: 10 # default: 3
EOF
The one to pay attention to is rc_admin_redaction — with burst_count: 2000 you can bulk-redact an entire room’s history without hitting a wall. With the default of 50, you’d get throttled almost immediately doing the same task.
Note:
per_secondcontrols how fast your action budget refills after a burst.burst_countis how many actions you can fire instantly before that throttling kicks in. For admin operations on a private server, both should be high enough that you never notice them.
Phase 4: Start Synapse
$ sudo systemctl enable matrix-synapse
$ sudo systemctl start matrix-synapse
$ sudo systemctl status matrix-synapse
Verify it’s listening
# curl http://127.0.0.1:8008/_matrix/client/versions
Should return a JSON list of supported Matrix spec versions
Check logs
# journalctl -u matrix-synapse -f
Apr 08 11:44:39 matrix systemd[1]: Stopped matrix-synapse.service - Synapse Matrix homeserver.
Apr 08 11:44:39 matrix systemd[1]: matrix-synapse.service: Consumed 10min 57.251s CPU time, 103M memory peak.
Apr 08 11:44:39 matrix systemd[1]: Starting matrix-synapse.service - Synapse Matrix homeserver...
Apr 08 11:44:42 matrix systemd[1]: Started matrix-synapse.service - Synapse Matrix homeserver.
Phase 5: Create admin user
# register_new_matrix_user \
-c /etc/matrix-synapse/homeserver.yaml \
-c /etc/matrix-synapse/conf.d/secrets.yaml \
http://localhost:8008
# Follow prompts:
# Username: youradminusername
# Password: (strong password)
# Admin: yes
Phase 6: Gateway nginx config
On your gateway machine, add this nginx server block. Replace MATRIX_VM_IP with the internal IP of your Matrix VM:
(replace the domain and the MATRIX_VM_IP according to your setup)
server {
listen 443 ssl;
server_name matrix.yourdomain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# Matrix client-server API
location /_matrix {
proxy_pass http://MATRIX_VM_IP:8008;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# Required for Matrix long-polling sync requests
proxy_read_timeout 600s;
proxy_send_timeout 600s;
client_max_body_size 50M;
}
# Synapse admin API
location /_synapse {
proxy_pass http://MATRIX_VM_IP:8008;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name matrix.yourdomain.com;
return 301 https://$host$request_uri;
}
Reload nginx
# nginx -t && sudo nginx -s reload
Verify end-to-end
Run this command from outside of your system.
$ curl https://matrix.yourdomain.com/_matrix/client/versions
{"versions":["r0.0.1","r0.1.0","r0.2.0","r0.3.0","r0.4.0","r0.5.0","r0.6.0",
"r0.6.1","v1.1","v1.2","v1.3","v1.4","v1.5","v1.6","v1.7","v1.8","v1.9",
"v1.10","v1.11","v1.12"],"unstable_features":{"org.matrix.label_based_filtering"
:true,"org.matrix.e2e_cross_signing":true,"org.matrix.msc2432":true,
"uk.half-shot.msc2666.query_mutual_rooms":false,
"io.element.e2ee_forced.public":false,"io.element.e2ee_forced.private":false,
"io.element.e2ee_forced.trusted_private":false,
"org.matrix.msc3026.busy_presence":false,"org.matrix.msc2285.stable":true,
"org.matrix.msc3827.stable":true,"org.matrix.msc3440.stable":true,
"org.matrix.msc3771":true,"org.matrix.msc3773":false,"fi.mau.msc2815":false,
"fi.mau.msc2659.stable":true,"org.matrix.msc3882":false,
"org.matrix.msc3881":false,"org.matrix.msc3874":false,
"org.matrix.msc3912":false,"org.matrix.msc3981":true,
"org.matrix.msc3391":false,"org.matrix.msc4069":false,
"org.matrix.msc4028":false,"org.matrix.msc4108":false,
"io.element.msc4388":false,"org.matrix.msc4140":false,
"org.matrix.simplified_msc3575":true,"uk.tcpip.msc4133":false,
"uk.tcpip.msc4133.stable":true,"org.matrix.msc4155":false,
"org.matrix.msc4306":false,"com.beeper.msc4169":false,
"org.matrix.msc4354":false,"org.matrix.msc4380.stable":true}}
Phase 7: Registration tokens
Synapse has no web UI — going to https://matrix.yourdomain.com in a browser returns a 404, which is expected. Users connect via a Matrix client such as Element. Registration is token-gated, meaning users can only sign up if you give them a token first.
7.1 — Get your admin access token
Log into Element (desktop app or app.element.io in your browser):
- Open Element → Sign in → click Change on the homeserver field
- Enter
matrix.yourdomain.com - Log in with your admin credentials from Phase 5
Find your access token:
- Desktop: Settings → Help & About → scroll to bottom → Access Token
- Mobile: Settings → Help & About → Advanced → Access Token
7.2 — Create a single-use token for one user
$ EXPIRY=$(( $(date +%s) + 604800 ))000
$ curl -X POST "https://matrix.yourdomain.com/_synapse/admin/v1/registration_tokens/new" \
-H "Authorization: Bearer YOUR_ADMIN_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"uses_allowed\": 1, \"expiry_time\": $EXPIRY}"
7.3 — Create a class token (multiple uses)
$ curl -X POST "https://matrix.yourdomain.com/_synapse/admin/v1/registration_tokens/new" \
-H "Authorization: Bearer YOUR_ADMIN_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"token": "class2026", "uses_allowed": 30}'
7.4 — List all tokens
$ curl "https://matrix.yourdomain.com/_synapse/admin/v1/registration_tokens" \
-H "Authorization: Bearer YOUR_ADMIN_ACCESS_TOKEN"
Phase 8: Adding a new user
Share the registration token with the new user — in this example, John Doe. The registration flow is simple:
- John downloads Element (desktop or mobile) or visits app.element.io
- Clicks Create Account
- Clicks Change on the homeserver field and enters
matrix.yourdomain.com - Fills in a username and password
- When prompted for a registration token, pastes the token you sent him
- Account created — John is now
@john:matrix.yourdomain.com
Send John a message
Once John has registered, you can reach him from your admin account in Element:
- Click the “+” button next to People or Direct Messages
- Search for
@john:matrix.yourdomain.com(full Matrix ID required) - Start the DM and send your first message
To create a shared room and invite John:
- Click “+” next to Rooms → Create a room
- Give it a name (e.g.
general) - Once created, click Invite and search for
@john:matrix.yourdomain.com
Phase 9: Maintenance
Update Synapse
# apt update && sudo apt upgrade matrix-synapse-py3
# systemctl restart matrix-synapse
Backup PostgreSQL
Create backup directory
# mkdir -p /var/backups/matrix
Manual backup
# sudo -u postgres pg_dump synapse > /var/backups/matrix/synapse-$(date +%Y%m%d).sql
Add to root crontab for daily backups at 3am
$ (crontab -l 2>/dev/null; echo "0 3 * * * sudo -u postgres pg_dump synapse > /var/backups/matrix/synapse-\$(date +\%Y\%m\%d).sql") | crontab -
Monitor
Service
# systemctl status matrix-synapse
Live logs
# journalctl -u matrix-synapse -f
Database size
# sudo -u postgres psql -c "SELECT pg_size_pretty(pg_database_size('synapse'));"
Verify a user is admin
$ curl https://matrix.yourdomain.com/_synapse/admin/v2/users/@youradminusername:matrix.yourdomain.com \
-H "Authorization: Bearer YOUR_ADMIN_ACCESS_TOKEN"
Should return user details with “admin”: true
Conclusion
You now have a fully operational, private Matrix homeserver running natively on Debian 13 — no vendor, no subscription, no compromises. What you’ve built is not a toy. This is the same protocol used by the French government, NATO, and thousands of organisations worldwide who decided that their communications were too important to hand over to a third party. The difference is you set it up in an afternoon, on hardware you control, for the cost of a small VM.
Image generated via nano banana
Think about what that actually means in practice. Your team’s entire conversation history is yours — today, next year, in ten years. No message will disappear behind a paywall. No pricing change will force you to export your data and scramble for an alternative. No outage at a Silicon Valley data centre will leave your team unable to communicate. And no one is reading your messages to train an AI model or serve you targeted ads.
With federation disabled, your server is an island — in the best possible sense. Every byte of data stays within your own infrastructure. Add end-to-end encryption on top of that and even you, as the server admin, cannot read your users’ messages.
This setup scales further than you might think. The same Synapse instance can comfortably serve dozens of users on modest hardware. When you’re ready, you can add bridges to connect your Matrix rooms to Slack, Discord, or Telegram — letting external collaborators stay on their platforms while your internal team communicates on yours. You can integrate bots, automate workflows, and even connect AI agents like OpenClaw directly into your rooms — see my follow-up post on wiring OpenClaw, NemoClaw, and Ollama into this exact homeserver.
The hard part — the setup — is done. From here, the only thing left to do is invite your team :)