SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host Matrix Synapse on a VPS

What it really takes to keep a Matrix Synapse homeserver alive on a VPS: sizing, Postgres, media retention, registration defences, and backups.

What it takes to keep a Matrix Synapse homeserver alive

Matrix Synapse is easy to install and easy to neglect. The install is one apt repository, one config file, one reverse proxy block and one DNS record. Keeping the homeserver healthy for a year is different work: a real database, a media store that gets pruned, registration that strangers cannot use, and a backup that captures both halves of the server.

This guide targets Ubuntu 24.04 LTS and installs Synapse from the matrix.org apt repository, which is the package source the Synapse project maintains for Debian and Ubuntu. Package versions move every few weeks, so no version number is quoted here. Every path and option below comes from the current Synapse documentation.

Sizing: what 1 vCPU and 2 GB of RAM really gets you

Published sizing pages, as of August 2026, commonly put a Synapse homeserver on 1 vCPU and 2 GB of RAM. That is honest for one case: a private server, a few users, small rooms, and no busy public rooms. The Synapse documentation is direct about the other case. It asks for "At least 1GB of free RAM if you want to join large public rooms like #matrix:matrix.org". Free RAM, on top of Python, Postgres and the kernel.

One room can change your sizing, because of how joining works. When a local user joins a room, your homeserver becomes a full participant in that room. It receives every event in it from every other server in it, it verifies the signature on each one, and it stores the room's state locally. A large public room has thousands of members spread over hundreds of servers, so your box does that work continuously whether or not your user ever opens the room again. Leaving the room later does not delete the history you already stored.

Most of Synapse's RAM goes to caches. The caches section has a global_factor that scales every cache at once, and the SYNAPSE_CACHE_FACTOR environment variable sets the same thing. Raising it spends RAM to avoid database queries. Lowering it spends CPU and Postgres time to save RAM. Postgres wants memory of its own, so on a 2 GB box the two are competing for the same megabytes.

Two practical rules for a small plan. Add swap: swap will not make Synapse fast, but it stops the kernel from killing the process during a large join. Then watch disk from the first week, because the two things that grow without a limit are the media store and the room state tables, and both live on disk.

Why Postgres, and why SQLite stops being an option

The Debian package starts on SQLite. That is fine for a first boot and wrong for a server other people use. SQLite allows one writer at a time. Federation traffic and client requests write at the same moment, so a cheap request waits behind a slow one, and the symptom your users report is that the app hangs for a few seconds at random.

The second reason is structural. Synapse's worker processes are the supported way to use more than one CPU core, and workers require Postgres. Staying on SQLite gives up the upgrade path as well as the performance.

Migrating later is supported and it means downtime, so do it before you have users. Synapse ships synapse_port_db, which copies a SQLite database into a prepared Postgres one:

synapse_port_db --sqlite-database homeserver.db --postgres-config homeserver-postgres.yaml

If you would rather run the database in a container beside Synapse, the trade-offs are in running your database in Docker or on the host.

Install Synapse on Ubuntu 24.04

sudo apt install -y lsb-release wget apt-transport-https
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
sudo apt update
sudo apt install matrix-synapse-py3

On Ubuntu 24.04 lsb_release -cs prints noble, and the matrix.org repository publishes a noble suite. Do not use the matrix-synapse package from Ubuntu's own archive. The Synapse project asks you not to, because those builds lag its releases and carry known security bugs.

The installer asks for a server name and writes the answer to /etc/matrix-synapse/conf.d/server_name.yaml. Answer it carefully. server_name is the part after the colon in every user ID (@alice:example.com) and it is embedded in every room your server creates. Changing it later does not move anything: it produces a different homeserver. Use your bare domain, example.com, even when Synapse itself will run on matrix.example.com. Delegation connects the two, and that is the next section.

The package runs Synapse as the matrix-synapse user, keeps its data under /var/lib/matrix-synapse, and reads /etc/matrix-synapse/homeserver.yaml followed by every file in /etc/matrix-synapse/conf.d/. Put your own settings in small files in conf.d. Package upgrades leave them alone.

sudo systemctl restart matrix-synapse
systemctl status matrix-synapse
sudo journalctl -u matrix-synapse -n 100 --no-pager

A healthy start brings the listeners up and then goes quiet. The systemd unit restarts the service a few seconds after any exit, so a config Synapse rejects shows as a unit that starts and dies in a loop. The last lines of the journal name the key it refused.

Point Synapse at Postgres

sudo apt install -y postgresql
sudo -u postgres createuser --pwprompt synapse_user
sudo -u postgres createdb --encoding=UTF8 --locale=C --template=template0 --owner=synapse_user synapse

The locale is not cosmetic. Synapse refuses to start against a database created with different COLLATE and CTYPE values unless you set allow_unsafe_locale in the database config, and the documented repair afterwards is a dump and a reload into a correctly created database. Create it right the first time.

database:
  name: psycopg2
  txn_limit: 10000
  args:
    user: synapse_user
    password: secretpassword
    dbname: synapse
    host: localhost
    port: 5432
    cp_min: 5
    cp_max: 10

Keep exactly one database: key across all your config files. Replace the SQLite block inside homeserver.yaml instead of adding a second copy under conf.d, so there is never a question about which one is live. Restart, then prove Synapse is really on Postgres:

sudo -u postgres psql synapse -c "SELECT count(*) FROM users;"

A number means Synapse built its schema in this database. An error about a missing relation means it is still writing to the SQLite file, so the config you edited is not the one being read.

Reverse proxy, TLS and the .well-known files federation needs

Synapse listens on plain HTTP on port 8008, bound to localhost. TLS and the public port belong to a reverse proxy in front of it.

listeners:
- port: 8008
  tls: false
  type: http
  x_forwarded: true
  bind_addresses:
  - '::1'
  - '127.0.0.1'
  resources:
  - names:
    - client
    - federation
    compress: false

x_forwarded: true tells Synapse to trust the X-Forwarded-For header the proxy sets. Without it every client looks like it came from 127.0.0.1, so rate limiting sees one extremely busy local user and throttles everybody together.

location ~ ^(/_matrix|/_synapse/client) {
    proxy_pass http://localhost:8008;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Host $host:$server_port;
    client_max_body_size 50M;
    proxy_http_version 1.1;
}

The Synapse documentation gives one warning about this block that costs people days. Do not add a path, not even a single /, after the port in proxy_pass. nginx then canonicalises the URI, which changes the bytes the sending server signed, and federation requests fail signature verification while ordinary client requests keep working.

client_max_body_size must be at least as large as Synapse's max_upload_size. If nginx holds the smaller number, uploads above it are rejected by nginx with 413 Request Entity Too Large before Synapse ever sees them, so there is no Synapse log line to explain the failure.

For the certificate itself, follow Certbot and Let's Encrypt on Ubuntu 24.04. If the proxy is still undecided, the reverse proxy comparison covers which one does the TLS work for you.

Delegation is what lets server_name stay example.com while Synapse runs on matrix.example.com. Serve two files from the bare domain:

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"}}';
}

The server file tells other homeservers where to send federation traffic, which is how federation runs over 443 instead of the default port 8448. The client file tells Matrix clients which URL backs @alice:example.com. The Access-Control-Allow-Origin header matters on the client file because browser-based clients fetch it cross-origin, so without the header the browser blocks the response and the client reports that it cannot find your homeserver.

Both files must be served over valid TLS from example.com itself. Check them, then check what the outside world sees:

curl -s https://example.com/.well-known/matrix/server
curl -s https://matrix.example.com/_matrix/federation/v1/version

The first returns the JSON you wrote. The second returns a JSON object naming the server implementation and its version, which proves the proxy reaches Synapse on the federation path. Then run the domain through the Matrix federation tester at https://federationtester.matrix.org, which follows the same route a real remote server takes.

Federate or do not federate: decide it on purpose

Federation is the point of Matrix and it is also most of the cost. A federating homeserver accepts connections from servers you have never heard of, receives their events, caches their media, and stores state for every room your users touch. That is a threat model decision, not a default.

Federate when your users need to reach people on other homeservers, or when a portable identity is the reason you chose Matrix. Do not federate when the server exists for one team and every account on it is yours. A closed server stores less, receives less, and is far less interesting to abuse.

To restrict rather than disable, Synapse takes an allow list:

federation_domain_whitelist:
- lon.example.com
- nyc.example.com

The documentation recommends firewalling the federation listener as well, so unwanted traffic stops at the network rather than inside Python. To switch federation off completely, remove federation from the listener resources list, do not publish /.well-known/matrix/server, and leave port 8448 closed.

If the reason for running Matrix was private team chat and federation was never part of it, compare the running cost against the other self-hosted Slack alternatives before you commit to Synapse. Rocket.Chat on Docker Compose does team chat on a smaller machine, because it never has to store another organisation's room state.

The media repository is what quietly fills the disk

Files your own users upload stay on your disk permanently. Files posted by users on other homeservers are fetched and cached on your disk as soon as one of your clients displays them, and Synapse also generates thumbnails for images, so one photo becomes several files. Nothing expires any of it by default.

Find the store and measure it:

grep media_store_path /etc/matrix-synapse/homeserver.yaml
sudo du -sh /var/lib/matrix-synapse/media_store

Measure the path your own config prints. The Debian package keeps Synapse's data under /var/lib/matrix-synapse, so the store normally sits there. Then set a retention policy in conf.d:

media_retention:
  local_media_lifetime: 90d
  remote_media_lifetime: 14d

Read those two lines carefully, because they are not the same kind of setting. remote_media_lifetime expires a cache, and anything it deletes can be fetched again from the server that owns the file. local_media_lifetime deletes your own users' uploads permanently once they reach that age. A team that shares documents in chat and expects to find them next year will lose them. Many servers set only the remote value.

For a one-off cleanup, the admin API takes a Unix timestamp in milliseconds:

BEFORE_TS=$(date -d '30 days ago' +%s%3N)
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  "https://matrix.example.com/_synapse/admin/v1/purge_media_cache?before_ts=$BEFORE_TS"

POST /_synapse/admin/v1/purge_media_cache drops cached remote media last accessed before that timestamp. POST /_synapse/admin/v1/media/delete?before_ts=<ms> deletes local media by the same rule. Run the remote purge first and measure again, because on a federating server the remote cache is usually the larger half.

Two settings feed the same disk. max_upload_size caps a single upload and has to stay in step with client_max_body_size in nginx. url_preview_enabled: true makes your server fetch remote pages so clients can show link previews, which spends bandwidth and stores thumbnails of content nobody uploaded to you.

Close registration before someone finds your homeserver

Scanners find an open homeserver in days. Once accounts are free to create, your server becomes a spam source in every room it federates with, and the administrators on the other side block your whole domain. That reputation damage outlives the cleanup, because the block lists are maintained by hand.

Synapse ships closed. enable_registration defaults to false and registration_requires_token defaults to false. Synapse also refuses to start with registration enabled and no verification step unless you additionally set enable_registration_without_verification: true. That refusal is deliberate, so do not switch it on to make a startup error go away.

Create the accounts you want by hand:

sudo register_new_matrix_user -c /etc/matrix-synapse/homeserver.yaml http://localhost:8008

It prompts for the user name, the password, and whether the account is a server admin. It reads registration_shared_secret from the config you pass with -c, so if it reports that it cannot find a shared secret, point -c at the file that holds it.

When creating accounts by hand stops scaling, registration tokens are the setting in between. A token is a string a new user must present during signup, and each token can carry a limit on how many times it works:

enable_registration: true
registration_requires_token: true
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"uses_allowed": 1}' \
  https://matrix.example.com/_synapse/admin/v1/registration_tokens/new

Leave token out of the body and Synapse generates one and returns it. GET /_synapse/admin/v1/registration_tokens lists the tokens that are live. Both calls need the access token of a server admin account, which you get by logging in as the admin user you created above.

An organisation that already manages accounts elsewhere can skip local passwords entirely, because Synapse can delegate login to an OIDC (OpenID Connect) provider, for example Authentik as a self-hosted SSO provider. Joiners and leavers are then handled in one place.

A backup that can actually rebuild the server

A Synapse backup has three parts, and a backup missing any one of them restores a server nobody can use.

  • The Postgres database, which holds every event, account and room.
  • The media store directory, which holds every uploaded file.
  • /etc/matrix-synapse, which holds your config and the server's signing key.

The signing key is the part people forget. It is the private key your homeserver signs events with, and remote servers verify events against the matching public key. Run grep signing_key_path /etc/matrix-synapse/homeserver.yaml to see where yours lives. Lose it and you restore a server that cannot prove it is the same server your rooms already know.

sudo -u postgres pg_dump --format=custom --file=/var/backups/synapse-$(date +%F).dump synapse
sudo tar czf /var/backups/synapse-etc-$(date +%F).tgz -C /etc matrix-synapse

Dump the database first, then copy the media store. Media files are written once and referenced by ID, so a media copy taken after the dump can only contain extra files, never missing ones. The other order can leave the restored database pointing at a file your backup never captured.

Send all three parts off the VPS. restic with off-site snapshots fits this shape well, because the media store is the large half and it barely changes between runs, so deduplication keeps each snapshot small.

Then rehearse the restore, because a backup you have never restored is only a hypothesis. Build a second VPS, install the same package, restore the config, create the database with the same encoding and locale, pg_restore the dump into it, copy the media store back, and log in. Write down how long it took. That number is your real recovery time.

When the state tables grow: compaction

Synapse stores room state as state groups, and on a federating server state_groups_state often becomes the largest object in the database. Measure before you change anything:

sudo -u postgres psql synapse -c "SELECT pg_size_pretty(pg_database_size('synapse'));"
sudo -u postgres psql synapse -c "SELECT pg_size_pretty(pg_total_relation_size('state_groups_state'));"

If that one table is most of your database, the project publishes a compressor for it, rust-synapse-compress-state, which rewrites the state group hierarchy into fewer rows without changing what any room's state means. It is built with Rust:

sudo apt install -y build-essential libssl-dev pkg-config git
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
git clone https://github.com/matrix-org/rust-synapse-compress-state.git
cd rust-synapse-compress-state/synapse_auto_compressor
cargo build --release
./target/release/synapse_auto_compressor -p postgresql://synapse_user:secretpassword@localhost/synapse -c 500 -n 100

-c is how many state groups it works on at once and -n is how many of those chunks this run processes. The auto compressor records how far it got, so the next run continues from there, which is what makes it safe to schedule. Its documentation notes the changes are applied in transactions against append-only tables, so it can run while Synapse is up. Take a database backup before the first run regardless.

One Postgres detail surprises people here. Deleting rows returns space to Postgres for reuse, not to the file system, so df may not move at all after a large compaction. VACUUM FULL returns it, and it takes an exclusive lock on the table plus free disk roughly equal to the table's size, so schedule it as maintenance rather than running it on a whim.

Checks that tell you the server is healthy

systemctl status matrix-synapse
curl -s https://example.com/.well-known/matrix/server
curl -s https://matrix.example.com/_matrix/federation/v1/version
sudo -u postgres psql synapse -c "SELECT pg_size_pretty(pg_database_size('synapse'));"
sudo du -sh /var/lib/matrix-synapse/media_store

Healthy means the unit is active and not restarting, the delegation file returns your m.server value, the federation version endpoint returns JSON, and the two size numbers can be compared against last month's. The sizes are the check people skip, and disk is the failure that takes a Synapse server down with no warning: a full volume stops Postgres writing, and Synapse then fails every request that touches the database.

FAQ

How much RAM does a Matrix Synapse server need?

For a private homeserver with a few users, small rooms and no large public rooms, 2 GB is workable, and that is what most published sizing pages recommend as of August 2026. The Synapse documentation asks for at least 1 GB of free RAM on top of the rest if your users will join large public rooms such as #matrix:matrix.org, because your server then stores that room's state and processes its traffic continuously. Add swap on a 2 GB plan so one large join cannot get the process killed by the kernel.

Do I have to use PostgreSQL instead of SQLite?

Past a handful of users, yes. SQLite allows one writer at a time, so federation traffic and client requests block each other under load and requests hang for seconds at a time. Synapse's worker processes, the supported way to use more than one CPU core, require Postgres. Migrating later works with synapse_port_db and costs downtime, so create the database with --encoding=UTF8 --locale=C --template=template0 before you have users.

Why does my Synapse disk usage keep growing?

One directory and one table. The media store keeps every file uploaded to rooms your server is in, including cached copies of remote users' media and generated thumbnails, and nothing expires until you set media_retention. The state_groups_state table grows with room state on a federating server, and rust-synapse-compress-state reduces it. Measure both, with du -sh on your media_store_path and with SELECT pg_size_pretty(pg_total_relation_size('state_groups_state'));, before deciding which one to work on.

How do I stop strangers registering on my homeserver?

Leave enable_registration at its default of false and create accounts with register_new_matrix_user. When that stops scaling, set enable_registration: true together with registration_requires_token: true, and hand out tokens created through POST /_synapse/admin/v1/registration_tokens/new. Do not set enable_registration_without_verification: true just to silence Synapse's startup refusal, because an open homeserver becomes a spam source and other administrators respond by blocking your entire domain.

Should my homeserver federate?

Federation is a decision about exposure, not a default. Federate if your users need to reach people on other homeservers. Keep it off if the server serves one team, because a non-federating server stores less, receives less and attracts far less abuse. In between, federation_domain_whitelist limits federation to named partner domains, and the Synapse documentation recommends firewalling the federation listener as well rather than relying on that application-layer check alone.

#matrix#synapse#self-hosting#postgresql#federation