Skip to content

How to Deploy Synapse on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Sep 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-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.

Before you start
  • 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
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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, but https://example.com must then serve a small .well-known/matrix/server file 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.
Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

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_secret can 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 ./data readable by root and UID 991 only.
  • Guard the signing key. data/matrix.example.com.signing.key is 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.

Next steps

How to self-host Synapse →More self-hosted team chat tools →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.