[Blog](https://deploywithox.com/blog) [Guides](https://deploywithox.com/blog#c-guides) # 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. Saurav Sharma Oct 17, 2026 12 min read On this page - [What you need](https://deploywithox.com/blog/self-host-synapse#what-you-need) - [The steps](https://deploywithox.com/blog/self-host-synapse#steps) - [The ox.toml, line by line](https://deploywithox.com/blog/self-host-synapse#config) - [Postgres](https://deploywithox.com/blog/self-host-synapse#services) - [Updates and backups](https://deploywithox.com/blog/self-host-synapse#updates) - [Frequently asked questions](https://deploywithox.com/blog/self-host-synapse#faq) 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`](https://github.com/element-hq/synapse/commit/193cf9466fbfbe334700c541dc1442f135179aaf) 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](https://github.com/element-hq/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](https://github.com/element-hq/synapse) at v1.163.0, commit [`193cf946`](https://github.com/element-hq/synapse/commit/193cf9466fbfbe334700c541dc1442f135179aaf). The Synapse It works page at the server's address 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](https://deploywithox.com/docs/cli) signed in, run: Shell ```bash ox new /synapse ox review synapse --from-file synapse.env --wait ``` The review saves the values and starts the first deploy, and `--wait` prints the log until it ends. The [quickstart](https://deploywithox.com/docs/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](https://deploywithox.com/docs/operate/domains) covers how ox adds one. ## The ox.toml, line by line TOML ```toml # 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. - `packages` adds four system packages: `libpq-dev` builds the Postgres driver, `xmlsec1` is what the SAML extra runs, `libjemalloc2` is the memory allocator the Docker image uses, and `curl` lets the admin step wait for Synapse to answer. - `required_variables` names the four keys with no default. `ox review` says which is missing before the first deploy. - `[app] start` runs the script's `start` step. `health` is `/health`, which every Synapse HTTP listener answers with 200 OK. - `stop_before_start` is 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] install` runs `poetry 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. - `migrate` runs the script's `migrate` step, which calls Synapse's `update_synapse_database`. The schema is ready before traffic switches, and ox dumps Postgres before it runs. - `[services]` asks for Postgres. `[storage]` keeps the `data` folder 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` Code ``` #!/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 ;; esac ``` Synapse 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_URL` ox provides, split into the fields Synapse's database settings take. - One HTTP listener on `127.0.0.1` and the app's port, with `x_forwarded` on because Caddy passes the client's address and scheme in headers. - `enable_registration` off, so nobody can sign up on their own, and `report_stats` off, so Synapse sends no usage statistics. - `public_baseurl` from `PUBLIC_URL`, and `serve_server_wellknown` on, which serves the file that tells other Matrix servers to use port 443. `trusted_key_servers` is 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. Shell ```bash SYNAPSE_SERVER_NAME SYNAPSE_REGISTRATION_SHARED_SECRET SYNAPSE_ADMIN_USER SYNAPSE_ADMIN_PASSWORD ``` - `SYNAPSE_SERVER_NAME` ends 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](https://deploywithox.com/docs/operate/variables) for how references work. - `SYNAPSE_REGISTRATION_SHARED_SECRET` is 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_USER` and `SYNAPSE_ADMIN_PASSWORD` are 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](https://deploywithox.com/docs/operate/backups) 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](https://deploywithox.com/self-hosting) shows what ox sets up on your server. On this page - [What you need](https://deploywithox.com/blog/self-host-synapse#what-you-need) - [The steps](https://deploywithox.com/blog/self-host-synapse#steps) - [The ox.toml, line by line](https://deploywithox.com/blog/self-host-synapse#config) - [Postgres](https://deploywithox.com/blog/self-host-synapse#services) - [Updates and backups](https://deploywithox.com/blog/self-host-synapse#updates) - [Frequently asked questions](https://deploywithox.com/blog/self-host-synapse#faq) - [Do I need Docker to self-host Synapse?](https://deploywithox.com/blog/self-host-synapse#faq-docker) - [How do I create the first Synapse admin?](https://deploywithox.com/blog/self-host-synapse#faq-admin) - [Which database does self-hosted Synapse need?](https://deploywithox.com/blog/self-host-synapse#faq-database) - [How much memory does Synapse use?](https://deploywithox.com/blog/self-host-synapse#faq-memory) - [Can I change the Synapse server name later?](https://deploywithox.com/blog/self-host-synapse#faq-name) Written by Saurav Sharma Builds ox. Writes about running apps on servers you own, without Docker and without a platform bill. [Older post How to self-host Twenty CRM on your own VPS without Docker](https://deploywithox.com/blog/self-host-twenty) [Newer post How to self-host SurrealDB on your own VPS without Docker](https://deploywithox.com/blog/self-host-surrealdb) ## Keep reading - Guides [Self-Hosted Open-Source Apps Deployed with ox: All 85](https://deploywithox.com/blog/self-hosted-open-source-apps-deployed-with-ox) ox deployed 85 open-source apps from pinned commits on fresh servers, with no Docker. The stacks, services, deploy times and a table of every app. Saurav Sharma Oct 17, 2026 16 min read - Guides [How to self-host AFFiNE on your own VPS without Docker](https://deploywithox.com/blog/self-host-affine) Run AFFiNE v0.27.4 on your own Ubuntu server with ox: Postgres with pgvector, Redis, a Rust and Node build, and an admin made from two variables. Saurav Sharma Oct 17, 2026 12 min read - Guides [How to self-host Akaunting on your own VPS without Docker](https://deploywithox.com/blog/self-host-akaunting) Run Akaunting 3.2.4 on your own Ubuntu server with ox: PHP, MariaDB, a scheduler and the first company and admin made on deploy, from the config that ran. Saurav Sharma Oct 17, 2026 10 min read ## Deploy to a server you own ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free for your first project. [Sign up with GitHub](https://deploywithox.com/login/github) [Read the docs](https://deploywithox.com/docs)