Your Chat, Your Rules: Running a Private Matrix Server on Debian 13

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 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) 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 — contains server_name: matrix.yourdomain.com
  • report_stats.yaml — contains report_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-synapse user 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_servers warning — noisy but harmless since federation is disabled
  • macaroon_secret_key missing — 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_second controls how fast your action budget refills after a burst. burst_count is 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):

  1. Open Element → Sign in → click Change on the homeserver field
  2. Enter matrix.yourdomain.com
  3. 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:

  1. John downloads Element (desktop or mobile) or visits app.element.io
  2. Clicks Create Account
  3. Clicks Change on the homeserver field and enters matrix.yourdomain.com
  4. Fills in a username and password
  5. When prompted for a registration token, pastes the token you sent him
  6. 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:

  1. Click the “+” button next to People or Direct Messages
  2. Search for @john:matrix.yourdomain.com (full Matrix ID required)
  3. Start the DM and send your first message

To create a shared room and invite John:

  1. Click “+” next to RoomsCreate a room
  2. Give it a name (e.g. general)
  3. 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 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 :)