How to self-host Synapse on your own VPS, no Docker
Run Synapse v1.163.0, the Matrix homeserver, on your own Ubuntu server with ox: Postgres and one start script, from the config that passed its test.
To self-host Synapse you need an Ubuntu server, a fork of the Synapse repo with an ox.toml and one script added, and four variables. In our run the first deploy took about 3 minutes, and ox set up Postgres for it.
How we tested On Oct 17, 2026, ox deployed Synapse v1.163.0 from commit 193cf946 on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory. The run then redeployed it, backed it up and rebooted the server. Then a separate test agent, one that did not write the config, signed in through the Matrix client API, created a room, sent a message, and read it back from the room's history, and checked the app's services and its backup.
Synapse is the Matrix homeserver that Element writes and maintains. Matrix is an open standard for real-time communication, and a homeserver holds your users, rooms and messages. Synapse is written in Python with a Rust extension, and this run kept its data in Postgres. It has no chat screen of its own: people use a Matrix client app such as Element pointed at its address.
The code is dual licensed: free under the AGPL, version 3 or later, or under a paid commercial license from Element.
ox deployed Synapse v1.163.0 (commit 193cf946) on Oct 17, 2026 on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory; the first deploy took 3 minutes and a redeploy took 2 minutes.
- First deploy: 3 minutes, from adding the project until the app passed the run's first checks.
- Redeploy of the same commit: 2 minutes, until the app passed the checks again.
- Server: DigitalOcean, 8 vCPUs and 16 GB of memory (
s-8vcpu-16gb), with this app alone on it. Every one of the 85 apps we deployed ran on this one size, so it was not picked for this app. - Memory in use after the reboot: 758 MB of 15986 MB, for the whole server: Ubuntu, the app, its services, Caddy and the ox agent. On a server with no apps, ox's tests on real servers hold the agent to 40 MB or less and Caddy to 50 MB or less.
- Services ox runs for it:
postgres. - Source: github.com/element-hq/synapse at v1.163.0, commit
193cf946.

A browser opening the address shows only that page.
What you need
An Ubuntu LTS server with root access. The facts above name the size the run used.
A GitHub account to fork
element-hq/synapse, and an ox account, which you open by signing in with GitHub.Nothing from outside. Synapse can send email, but the run set none up, so email was not tested.
A Matrix client app to chat with. The run used none, since the test agent called the client API directly, so Element and other client apps were not tested.
The steps
Open the ox dashboard and press Add a server. It shows one install command with a fresh token in it. Run that as root on the machine. It puts only the ox agent and Caddy there.
Fork element-hq/synapse on GitHub and check out the tag v1.163.0, the release the run deployed. Add the ox.toml and the ox-synapse.sh script shown below to the root of the fork, then commit and push. Write a file named synapse.env on your laptop with the four keys from the variables section. Synapse's docs say the shared secret can be any private value, and openssl rand -base64 48 makes a long one. With the ox CLI signed in, run:
ox new <you>/synapse
ox review synapse --from-file synapse.env --waitThe review saves the values and starts the first deploy, and --wait prints the log until it ends. The quickstart shows the same two steps in the browser.
When the deploy is green, open the project's address. /health answers OK, and /_matrix/client/versions lists the Matrix versions it speaks.
The test agent then used the server as a chat app would. It posted the admin's name and password to /_matrix/client/v3/login, which answers with an access token. With that token it made a private room, sent a message to it, and read it back from the room's history. The admin's user ID is @name:server-name.
The run served Synapse on its preview address only. A domain of your own was not part of it. The domains page covers how ox adds one.
The ox.toml, line by line
# Synapse, the Matrix homeserver, as its Docker image runs it
# (docker/Dockerfile): the locked main dependencies with every extra, the
# package itself with its Rust extension (maturin, built by the install),
# postgres, the config written from variables at each start, and jemalloc
# in front of the server. The signing key, made on the first start, and the
# uploaded media live in the kept data folder (the image's /data volume).
# xmlsec1 is what the SAML extra runs, libpq-dev builds psycopg2.
packages = ["libpq-dev", "xmlsec1", "libjemalloc2", "curl"]
# The server's name is part of every user's ID and cannot change later.
# The admin is made at the first start from SYNAPSE_ADMIN_USER and
# SYNAPSE_ADMIN_PASSWORD through the shared-secret registration, since
# sign-up is closed.
required_variables = ["SYNAPSE_SERVER_NAME", "SYNAPSE_REGISTRATION_SHARED_SECRET", "SYNAPSE_ADMIN_USER", "SYNAPSE_ADMIN_PASSWORD"]
[app]
start = "exec bash ox-synapse.sh start"
health = "/health"
# One Synapse per database: two main processes would hand out the same
# stream IDs, so the running one stops before the new one starts.
stop_before_start = true
[build]
install = "poetry install --only main --extras all"
# Synapse's own schema updater, so a first deploy's schema is not made
# inside the start's health wait.
migrate = "bash ox-synapse.sh migrate"
[services]
postgres = {}
[storage]
keep = ["data"]The file mirrors how Synapse's own Docker image runs the server, without Docker.
packagesadds four system packages:libpq-devbuilds the Postgres driver,xmlsec1is what the SAML extra runs,libjemalloc2is the memory allocator the Docker image uses, andcurllets the admin step wait for Synapse to answer.required_variablesnames the four keys with no default.ox reviewsays which is missing before the first deploy.[app] startruns the script'sstartstep.healthis/health, which every Synapse HTTP listener answers with 200 OK.stop_before_startis on because two main Synapse processes on one database would hand out the same stream IDs. ox stops the running copy first, so every redeploy has a short gap in which the site answers 502.[build] installrunspoetry install --only main --extras all: the lock file's main dependencies with every extra, as the Docker image does, then Synapse itself and its Rust extension.migrateruns the script'smigratestep, which calls Synapse'supdate_synapse_database. The schema is ready before traffic switches, and ox dumps Postgres before it runs.[services]asks for Postgres.[storage]keeps thedatafolder across deploys, which holds the signing key and the uploaded media. Each release is a fresh folder, so they would otherwise start empty.
There are no workers and no cron jobs. One main Synapse process does everything.
ox-synapse.sh
#!/usr/bin/env bash
# Synapse on ox, run from the environment as docker/start.py runs it: the
# homeserver config is written from variables, and the signing key and the
# media live in the kept data folder.
# migrate bring the database schema up to date (update_synapse_database)
# start make the admin when it is missing, then run the server
set -euo pipefail
# write_config FILE KEY MEDIA PORT: the homeserver config as JSON, which
# YAML reads, built in Python so no secret passes through shell quoting.
# Synapse's database args take separate fields, read from DATABASE_URL.
write_config() {
python -I - "$@" <<'PY'
import json, os, sys
from urllib.parse import unquote, urlsplit
path, key, media, port = sys.argv[1:5]
env = os.environ
db = urlsplit(env["DATABASE_URL"])
config = {
"server_name": env["SYNAPSE_SERVER_NAME"],
"listeners": [{
"port": int(port),
"bind_addresses": ["127.0.0.1"],
"type": "http",
"tls": False,
# ox's proxy calls from 127.0.0.1 with X-Forwarded-For and -Proto.
"x_forwarded": True,
"resources": [{"names": ["client", "federation"], "compress": False}],
}],
"database": {
"name": "psycopg2",
# ox makes the database in the server's C.UTF-8 locale, which sorts
# by code point as the C locale Synapse asks for does.
"allow_unsafe_locale": True,
"args": {
"user": unquote(db.username),
"password": unquote(db.password),
"host": db.hostname,
"port": db.port,
"dbname": db.path.lstrip("/"),
"cp_min": 5,
"cp_max": 10,
},
},
"media_store_path": media,
"signing_key_path": key,
"registration_shared_secret": env["SYNAPSE_REGISTRATION_SHARED_SECRET"],
"enable_registration": False,
"report_stats": False,
"trusted_key_servers": [{"server_name": "matrix.org"}],
"suppress_key_server_warning": True,
# Federation reaches the server on 443 through ox's proxy.
"serve_server_wellknown": True,
}
if env.get("PUBLIC_URL"):
config["public_baseurl"] = env["PUBLIC_URL"].rstrip("/") + "/"
with open(path, "w") as f:
json.dump(config, f, indent=1)
PY
}
migrate() {
local tmp
tmp=$(mktemp -d)
# The updater loads a whole config, signing key included, and only
# changes the schema: the server's key is in the kept folder, which a
# build step sees empty, so a key made for this step stands in.
generate_signing_key -o "$tmp/signing.key"
# The updater listens on nothing, so the port is only there to parse.
write_config "$tmp/homeserver.yaml" "$tmp/signing.key" "$tmp/media_store" 8008
update_synapse_database --database-config "$tmp/homeserver.yaml"
}
# admin_state: "yes" when the admin user exists, "no" when not; any error
# stops the start rather than reading as "no".
admin_state() {
python -I - <<'PY'
import os
import psycopg2
user = "@%s:%s" % (os.environ["SYNAPSE_ADMIN_USER"], os.environ["SYNAPSE_SERVER_NAME"])
with psycopg2.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE name = %s", (user,))
print("yes" if cur.fetchone() else "no")
PY
}
# make_admin: Synapse makes users only through its running server, so run
# it in the background, register the admin with the shared secret
# (register_new_matrix_user, as the docs say), and stop it again. It listens
# on a free port of its own, not on $PORT, where ox's health check would
# take it for the started release and send visitors to it.
make_admin() {
local config=$XDG_RUNTIME_DIR/homeserver-admin.yaml pid port tries=0 rc=0
local pwfile=$XDG_RUNTIME_DIR/admin-password
port=$(python -I -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
write_config "$config" data/signing.key data/media_store "$port"
synapse_homeserver --config-path "$config" &
pid=$!
until curl -fsS -o /dev/null "http://127.0.0.1:$port/health" 2>/dev/null; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" || rc=$?
echo "synapse stopped (status $rc) before it answered /health, so no admin was made" >&2
exit 1
fi
tries=$((tries + 1))
if [ "$tries" -ge 90 ]; then
echo "synapse did not answer /health in 90 s, so no admin was made" >&2
exit 1
fi
sleep 1
done
printf '%s' "$SYNAPSE_ADMIN_PASSWORD" >"$pwfile"
register_new_matrix_user --config "$config" --user "$SYNAPSE_ADMIN_USER" \
--password-file "$pwfile" --admin --exists-ok "http://127.0.0.1:$port"
rm -f "$pwfile" "$config"
kill "$pid"
wait "$pid" || rc=$?
# 143 is a stop by SIGTERM before Synapse's own handler took it.
if [ "$rc" != 0 ] && [ "$rc" != 143 ]; then
echo "synapse ended with status $rc after the admin was made" >&2
exit 1
fi
}
start() {
local config=$XDG_RUNTIME_DIR/homeserver.yaml
local jemalloc
jemalloc=/usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2
[ -f "$jemalloc" ] || { echo "no $jemalloc (the libjemalloc2 package)" >&2; exit 1; }
umask 077
write_config "$config" data/signing.key data/media_store "$PORT"
# The first start also makes the signing key (Synapse makes a missing
# one as it starts).
local state
state=$(admin_state)
case $state in
yes) ;;
no) make_admin ;;
*) echo "the admin check answered '$state'" >&2; exit 1 ;;
esac
# docker/start.py runs the main process with jemalloc, which keeps
# Synapse's memory from growing in fragments.
exec env LD_PRELOAD="$jemalloc" synapse_homeserver --config-path "$config"
}
case "${1:-}" in
migrate) migrate ;;
start) start ;;
*) echo "usage: ox-synapse.sh migrate|start" >&2; exit 2 ;;
esacSynapse reads a YAML file and ox gives an app environment variables, so the script writes the file from them at every start. It builds it as JSON, which YAML reads, so no secret passes through shell quoting. The file lands in a runtime folder, not in the repo. It sets:
Postgres, from the
DATABASE_URLox provides, split into the fields Synapse's database settings take.One HTTP listener on
127.0.0.1and the app's port, withx_forwardedon because Caddy passes the client's address and scheme in headers.enable_registrationoff, so nobody can sign up on their own, andreport_statsoff, so Synapse sends no usage statistics.public_baseurlfromPUBLIC_URL, andserve_server_wellknownon, which serves the file that tells other Matrix servers to use port 443.trusted_key_serversis matrix.org, Synapse's default, with its warning switched off.
The start step then checks the users table for the admin. When it is missing, the script starts Synapse on a free local port of its own, waits up to 90 seconds for /health, runs Synapse's register_new_matrix_user with the shared secret, and stops it. Then it starts the real server with jemalloc preloaded, as the Docker image does. Synapse makes its signing key on that first start. Later starts skip the admin step.
SYNAPSE_SERVER_NAME
SYNAPSE_REGISTRATION_SHARED_SECRET
SYNAPSE_ADMIN_USER
SYNAPSE_ADMIN_PASSWORDSYNAPSE_SERVER_NAMEends every user ID and room address, and Synapse's docs say it cannot be changed later. The run used the host name of the project's preview address. In an env file,${PUBLIC_HOST}is the reference ox fills in with that name. See variables for how references work.SYNAPSE_REGISTRATION_SHARED_SECRETis shared by the config and the admin step. Synapse's docs warn that anyone who has it can register users, admins included, even with sign-up closed.SYNAPSE_ADMIN_USERandSYNAPSE_ADMIN_PASSWORDare the first admin. They matter only at the first start, when no such user exists.
Postgres
Postgres is shared with other apps on the server, each with its own database and user. Synapse's docs ask for a database in the C locale and refuse to start in another one unless allow_unsafe_locale is set. ox makes its database in C.UTF-8, which sorts text by code point as C does, so the config sets that option. The docs do not recommend it, and Synapse logs a collation warning at start.
Updates and backups
To move to a new release, merge its tag into your fork and push, or run ox deploy synapse --wait. A redeploy has the short gap described above. Synapse's docs/upgrade.md lists steps some versions need.
Backups need no setup, and ox backs each entry up daily. The run's backups held two Postgres dumps, one from Back up now and one taken before the redeploy's migrate, and an archive of the kept data folder with the signing key and media store. Run ox backups synapse to list them. A restore was not tested. The backups page covers keeping copies in an S3 bucket.
Frequently asked questions
Do I need Docker to self-host Synapse?
No. Synapse's docs list a Docker image, Debian packages and a pip install, and this run used none of them. ox installed the locked dependencies with Poetry into a virtual environment on the server, built the Rust extension there, and runs Synapse as a system service beside Postgres. An update is a push to your fork.
How do I create the first Synapse admin?
Set SYNAPSE_ADMIN_USER and SYNAPSE_ADMIN_PASSWORD before the first deploy. At the first start the script runs Synapse's register_new_matrix_user with the shared secret and makes that user an admin. Sign-up stays closed. Making more users and signing in from Element were not tested.
Which database does self-hosted Synapse need?
Synapse's docs say SQLite is acceptable only for testing and name Postgres for almost every installation. This run used Postgres, which ox installs, with its own database and user for Synapse. Because that database is C.UTF-8, not C, the config sets allow_unsafe_locale, which the docs do not recommend.
How much memory does Synapse use?
The facts above show what the whole server used after a reboot, with Synapse, Postgres, Caddy and the ox agent running. The start puts jemalloc in front of Synapse, as its Docker image does. ox takes any server from 1 GB up, and it says so when memory is why the app stopped.
Can I change the Synapse server name later?
No. Synapse's docs say the server name cannot be changed later, because it ends every user ID and room address, so set it before the first start. The run used the host name of the project's preview address. A domain of your own, and federation with other Matrix servers, were not tested.
New to running apps on your own server? How ox self-hosts your apps shows what ox sets up on your server.
