How to Deploy Synapse on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host Synapse, the reference Matrix homeserver, on your own VPS — the official image's generate step, PostgreSQL with the C locale Synapse insists on, a first admin created from the command line, registration kept closed, and Caddy for HTTPS and federation.
- A VPS with at least 1 vCPU / 1 GB RAM (2 GB if you will join large federated rooms)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you control, and a final decision on your server name (it cannot be changed later)
- Docker Engine + Compose installed (see the base guide below)
- A Matrix client such as Element to log in with once it is running
What Synapse is
Synapse is the reference homeserver for Matrix, the open chat protocol, maintained by Element. A homeserver holds your accounts and rooms; any Matrix client (Element is the best known) connects to it, and it can federate with every other Matrix server — your users can join rooms hosted elsewhere and outside users can join yours, much like email between domains.
That federation is the main reason to pick Synapse over a single-server team chat. If everyone who will ever talk to you is on your own team, a closed tool like Mattermost or Rocket.Chat is simpler — see Mattermost vs Rocket.Chat — and Zulip is the pick for threaded, topic-based discussion. Synapse is AGPL-3.0 and written in Python and Rust.
Decide your server name first — it is permanent
Upstream is blunt about this: choose the server name before you install,
because it cannot be changed later. The server name is the domain part of
every user ID (@alice:matrix.example.com) and every room alias, and it is how
other servers find yours. Changing it means a new server with new accounts.
You have two sensible options:
matrix.example.com— the hostname Synapse actually runs on. Simplest: no delegation, user IDs look like@alice:matrix.example.com.example.com— user IDs look like@alice:example.com, like an email address, buthttps://example.commust then serve a small.well-known/matrix/serverfile pointing at the real host. The Caddy section below covers both.
This guide uses matrix.example.com throughout. Replace it everywhere —
including in the generate step — with the name you have chosen.
Server sizing
The catalog lists 1 GB RAM as the minimum. On our test box a freshly installed Synapse measured ~110 MB of RAM at idle and about 500 MB of disk — but idle is the wrong number to plan around. Synapse's memory tracks the rooms it participates in, not the number of local users: joining one large federated room pulls its state and history onto your server.
- 1 GB RAM / 1 vCPU — a handful of users in small or mostly local rooms.
- 2 GB RAM / 2 vCPU — the comfortable default once you join big public rooms, with room for PostgreSQL alongside.
- Disk: 20 GB+ — the uploaded-media store and the database both grow for as long as the server runs, and remote media is cached locally too.
Paid link — we earn a commission if you shop through it.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed. If not, work through Docker & Compose on Ubuntu first.
Open SSH, the web ports and the Matrix federation port. Synapse's own port
8008 stays internal:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw allow 8448
sudo ufw --force enable
sudo ufw status verbose
If you will use .well-known delegation instead of port 8448 (see the Caddy
section), you can skip the 8448 rule.
Generate homeserver.yaml
The official matrixdotorg/synapse image does not build a configuration from
environment variables at runtime — it refuses to start without a config file.
You create one first with its generate command, which writes
homeserver.yaml, a log config and the server's signing key into /data.
Two variables are mandatory: SYNAPSE_SERVER_NAME and SYNAPSE_REPORT_STATS
(yes or no for anonymous usage statistics).
Create the project directory, generate secrets once, and run the generator
against a bind-mounted ./data directory so you can edit the result:
mkdir -p ~/synapse && cd ~/synapse
if [ ! -f .env ]; then
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
ADMIN_PASSWORD=$(openssl rand -hex 16)
EOF
chmod 600 .env
fi
if [ ! -f data/homeserver.yaml ]; then
docker run --rm -v "$HOME/synapse/data:/data" \
-e SYNAPSE_SERVER_NAME=matrix.example.com \
-e SYNAPSE_REPORT_STATS=no \
matrixdotorg/synapse:latest generate
fi
sudo ls -l data
The container sets ./data to be owned by UID/GID 991, the user Synapse
runs as, so reading and editing the files there needs sudo. The generated
config already contains what you want behind a reverse proxy — a single
listener on port 8008 serving the client and federation APIs with
x_forwarded: true — plus a random registration_shared_secret,
macaroon_secret_key and form_secret. Treat the whole directory as secret.
Point it at PostgreSQL
Out of the box the generated config uses SQLite. Upstream is clear that
production should use PostgreSQL, and that the database must use UTF8
encoding with the C collation — Synapse checks at startup and refuses a
database that doesn't match. With the official Postgres image, the
POSTGRES_INITDB_ARGS variable (used in Synapse's own example compose file)
sets that when the cluster is first created.
Replace the database: block with a psycopg2 one. This edits the file in
place, once, using the password from .env, and also sets public_baseurl —
the URL clients use to reach you:
cd ~/synapse
set -a; . ./.env; set +a
if ! sudo grep -q 'name: psycopg2' data/homeserver.yaml; then
sudo sed -i '/^database:/,/homeserver\.db/d' data/homeserver.yaml
# the generated file ends without a newline, so start the appended block on a fresh line
echo | sudo tee -a data/homeserver.yaml >/dev/null
sudo tee -a data/homeserver.yaml >/dev/null <<EOF
database:
name: psycopg2
args:
user: synapse
password: "${POSTGRES_PASSWORD}"
dbname: synapse
host: db
cp_min: 5
cp_max: 10
public_baseurl: "https://matrix.example.com/"
EOF
fi
sudo grep -n -A8 '^database:' data/homeserver.yaml
Do this before the first start. Synapse creates its schema in whichever
database it first sees; moving an existing SQLite server to Postgres is a
separate migration (synapse_port_db), not a config change.
Install Synapse (Docker Compose)
PostgreSQL 17 with its data in ./postgres, Synapse on the generated
./data, and port 8008 published on loopback only so Caddy is the sole way
in:
cd ~/synapse
cat > docker-compose.yml <<'YAML'
services:
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: synapse
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: synapse
# UTF8 + C locale, which Synapse requires
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"]
interval: 5s
timeout: 5s
retries: 20
synapse:
image: matrixdotorg/synapse:latest
container_name: synapse
restart: unless-stopped
depends_on:
db:
condition: service_healthy
volumes:
- ./data:/data
ports:
# Loopback only — Caddy is the route in from outside.
- "127.0.0.1:8008:8008"
YAML
docker compose up -d
for i in $(seq 1 60); do
curl -fsS http://127.0.0.1:8008/health && break
sleep 3
done
echo
docker compose ps
Two notes on the database service. postgres:17 keeps its data under
/var/lib/postgresql/data; the Postgres 18+ images moved that to
/var/lib/postgresql, so if you change the tag, change the mount with it. And
depends_on … service_healthy matters: upstream's own compose file notes that
Synapse does not retry its initial database connection, so it must not start
before Postgres is ready.
Confirm Synapse answers the client API and that the database has the locale it needs:
cd ~/synapse
curl -fsS http://127.0.0.1:8008/_matrix/client/versions
echo
docker compose exec -T db psql -U synapse -d synapse -tAc \
"SELECT pg_encoding_to_char(encoding), datcollate, datctype FROM pg_database WHERE datname = 'synapse'"
You should see a JSON list of supported spec versions and UTF8|C|C.
Create the first admin user
Synapse ships register_new_matrix_user, which creates accounts through the
registration_shared_secret in homeserver.yaml — it works even with public
registration switched off. Run it non-interactively inside the container with
-u (username), -p (password), -a (admin) and -c (the config file to
read the secret from). The block checks whether the account can already log in
first, so it is safe to re-run:
cd ~/synapse
set -a; . ./.env; set +a
login() {
curl -fsS -o /dev/null -X POST http://127.0.0.1:8008/_matrix/client/v3/login \
-H 'Content-Type: application/json' \
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"admin\"},\"password\":\"${ADMIN_PASSWORD}\"}"
}
login || docker exec synapse register_new_matrix_user \
-u admin -p "$ADMIN_PASSWORD" -a -c /data/homeserver.yaml
login && echo "admin can log in"
Your admin is @admin:matrix.example.com, with the generated password in
~/synapse/.env. Change it from your client after the first login. -p puts
the password on a command line where other local users could see it in the
process list; --password-file is the alternative on a shared box.
Keep registration closed
enable_registration defaults to false, and the generated config leaves
it that way. Leave it off: an open homeserver on the public internet attracts
spam accounts quickly, and Synapse will only allow open registration alongside
extra verification such as email or a captcha. Create accounts with
register_new_matrix_user as above (use --no-admin for regular users), or
enable a token-based invite flow later. Check it is closed:
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
http://127.0.0.1:8008/_matrix/client/v3/register \
-H 'Content-Type: application/json' -d '{}')
echo "register endpoint returned $code"
test "$code" = 403
A 403 ("Registration has been disabled") is what you want.
HTTPS, domain and federation with Caddy
Point an A record for matrix.example.com at the server, then put
Caddy in front. Clients connect on 443;
other homeservers connect on 8448 by default. With the server name equal
to the Synapse host, one Caddyfile covers both — this is upstream's Caddy
example with your host substituted:
matrix.example.com {
reverse_proxy /_matrix/* 127.0.0.1:8008
reverse_proxy /_synapse/client/* 127.0.0.1:8008
}
matrix.example.com:8448 {
reverse_proxy /_matrix/* 127.0.0.1:8008
}
Only /_matrix and /_synapse/client are proxied; the admin API under
/_synapse/admin stays reachable only from the server itself. Don't add any
URI rewriting — upstream warns that a proxy which normalises request paths
breaks federation signatures.
If your server name is example.com (the bare domain), use .well-known
delegation instead of 8448. Serve these from example.com, and keep the
matrix.example.com site block above (the 8448 block becomes unnecessary):
example.com {
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `{"m.server": "matrix.example.com:443"}`
respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://matrix.example.com"}}`
}
Once DNS resolves, check both paths from any machine:
curl -fsS https://matrix.example.com/_matrix/client/versions
curl -fsS https://matrix.example.com:8448/_matrix/federation/v1/version
Then run your domain through the Matrix federation tester, which checks the same resolution another server will do.
If Caddy runs as a container, 127.0.0.1 is its own loopback: put it on
the same compose network and proxy to synapse:8008 instead.
Securing it
- The shared secret is an admin key. Anyone with
registration_shared_secretcan register admin accounts even with registration disabled. Upstream suggests removing it (and restarting) if you no longer need command-line registration; if you keep it, keep./datareadable by root and UID 991 only. - Guard the signing key.
data/matrix.example.com.signing.keyis your server's identity on the federation. Back it up; don't publish it. - Leave URL previews off unless you need them. They are disabled by default, and enabling them requires an IP blacklist so users can't make your server fetch internal addresses.
- Voice and video calls need a TURN server, which the image does not include. Plan for coturn separately if calls matter.
Backups
State lives in three places: the PostgreSQL database, the media store, and the
config plus signing key in ./data. Upstream recommends pg_dump's custom
format and excluding the e2e_one_time_keys_json table data, which must not
be restored:
cd ~/synapse
mkdir -p ~/synapse-backups
docker compose exec -T db pg_dump -U synapse -Fc \
--exclude-table-data e2e_one_time_keys_json synapse \
> ~/synapse-backups/synapse-db-$(date +%F).dump
sudo tar czf ~/synapse-backups/synapse-data-$(date +%F).tar.gz \
-C ~/synapse data .env docker-compose.yml
ls -lh ~/synapse-backups
pg_dump runs against the live server; there is no need to stop anything.
The data archive includes uploaded media, homeserver.yaml and the signing
key — restoring without that key means other servers see a new identity. Copy
both files off the box, encrypted: they hold every secret on the server.
Upgrades
cd ~/synapse
docker compose pull
docker compose up -d
docker compose ps
Synapse applies its own database schema migrations at startup, so an upgrade is usually just a new image — but read the upgrade notes before each jump, take a backup first, and don't expect to roll back across a schema change. For a major PostgreSQL upgrade (17 → 18), stop Synapse and dump/restore rather than just changing the tag.
Troubleshooting
Synapse exits with "Config file '/data/homeserver.yaml' does not exist."
The generate step didn't run against the same directory the compose file
mounts. Re-run it with -v "$HOME/synapse/data:/data".
"Database has incorrect collation … Should be 'C'". The Postgres cluster
was initialised without the POSTGRES_INITDB_ARGS above. Those args only
apply to an empty data directory: stop the stack, remove ./postgres (only
on a fresh install with nothing in it), and start again.
Synapse restarts in a loop right after up -d. Usually the database
password in homeserver.yaml doesn't match .env, or Postgres wasn't ready.
Check docker compose logs --tail 100 synapse.
Clients connect but federation fails. Test
https://matrix.example.com:8448/_matrix/federation/v1/version from outside,
check that port 8448 is open in ufw and your provider's firewall, or that
.well-known/matrix/server returns the JSON above.
Verification + next steps
You're done when: curl http://127.0.0.1:8008/_matrix/client/versions answers
on the server, https://matrix.example.com works in Element with
@admin:matrix.example.com, the register endpoint returns 403, the federation
tester is green, and a backup exists off the box.
From there: create accounts for your users, set up a TURN server if you want calls, and consider an SSO provider such as Keycloak or Authentik. For host picks, see Best VPS for Self-Hosting.