To run Element and Matrix self-hosted, you need three things on one VPS: Synapse (the Matrix homeserver), PostgreSQL (its database) and Element Web (the chat app in the browser), plus a reverse proxy for HTTPS. With Docker Compose that is one folder and four containers. On 24 October 2026 we built exactly that on a Debian 13 test server with Synapse 1.163.2 and Element Web 1.12.32: the whole stack used about 245 MiB of RAM at idle, invite-only sign-up with registration tokens worked on the first try, and a coturn relay accepted the call credentials Synapse handed out. Below are the commands we ran and what they printed.
This guide is the Docker route, with federation switched on, voice and video calls, and invite tokens instead of admin-made accounts. If you want the Debian-package install for a closed company chat with federation off, read our Matrix and Element setup for a company team instead. Both run on any of our KVM VPS and VDS plans in Amsterdam, Dublin and Prishtina.
The short facts, checked on 24 October 2026:
- Synapse 1.163.2 is the current stable release, published on 23 October 2026 to pull in a Pillow security update (Synapse releases, changelog). 1.164.0 is at release candidate 3.
- Element Web 1.12.32 came out on 16 October 2026 (Element Web releases).
- coturn 4.18.0 is the current TURN server release, from 8 September 2026 (coturn releases).
- Group calls with Element Call need a LiveKit server (v1.13.10) and the MatrixRTC authorization service lk-jwt-service (v0.9.0); a TURN server alone is not enough (Element Call self-hosting docs).
- Tuwunel 1.9.4, the Rust homeserver that succeeded conduwuit, used 52 MiB at idle in the same lab (Tuwunel).
What you build, and the names it needs
Matrix user IDs look like email addresses: @anna:example.com. The part after the colon is the server name, and you cannot change it later without starting over, so use your real domain. The server itself can live on a subdomain; a tiny file on the main domain, called delegation, tells the world where to find it.
| Name | What answers there | DNS |
|---|---|---|
| example.com | Two small JSON files under /.well-known/matrix/ | Your website, or this VPS |
| matrix.example.com | Synapse: the client API and federation, both on port 443 | A and AAAA to the VPS |
| chat.example.com | Element Web | A and AAAA to the VPS |
| turn.example.com | coturn, for calls through firewalls | A and AAAA to the VPS |
Element's own README advises against serving Element on the same hostname as the homeserver, which is why the app gets chat. and Synapse gets matrix.. On the firewall you need TCP 80 and 443 for the web, and for coturn UDP and TCP 3478 plus the UDP relay range you set (49160-49200 in our config). Nothing else has to face the internet: PostgreSQL and Synapse stay on the internal Docker network.
Element Matrix self-hosted with Docker: the install, step by step
Our lab: a Debian 13 KVM server with 3 vCPUs and 4 GB of RAM, shared that evening with other test jobs. Because the lab host allowed us a single local port, everything web-facing went through one nginx container on 127.0.0.1:18086; on your server a Caddy or nginx on the host terminates HTTPS and forwards to that port. Every block below says who runs it.
1. Install Docker
# as root
apt update
apt install -y docker.io docker-compose
docker --version
docker compose version
We got Docker version 26.1.5+dfsg1 and Docker Compose version 2.26.1-4, the versions Debian 13 ships. Docker's own repository works just as well if you prefer newer builds.
2. Let Synapse write its first config
Make a folder for everything and ask the Synapse image to generate a config for your server name. Change example.com to your domain here; this is the step you cannot undo.
# as root
mkdir -p /opt/matrix/synapse
cd /opt/matrix
docker run --rm -v "$PWD/synapse:/data" -e SYNAPSE_SERVER_NAME=example.com -e SYNAPSE_REPORT_STATS=no matrixdotorg/synapse:v1.163.2 generate
It ends with A config file has been generated in '/data/homeserver.yaml' for server name 'example.com'. and leaves three files in synapse/: homeserver.yaml, a log config and example.com.signing.key. That key signs everything your server sends to other servers. Back it up.
3. Switch the database to PostgreSQL and set the rules
The generated file points at SQLite. The next block writes two random secrets into a .env file, deletes the SQLite lines and appends the PostgreSQL connection, invite-only sign-up and the TURN settings. The <<EOF here is unquoted on purpose, so the shell fills in the secrets.
# as root, in /opt/matrix
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" > .env
echo "TURN_SECRET=$(openssl rand -hex 32)" >> .env
chmod 600 .env
. ./.env
sed -i '/^database:/,/homeserver.db$/d' synapse/homeserver.yaml
cat >> synapse/homeserver.yaml <<EOF
public_baseurl: "https://matrix.example.com/"
database:
name: psycopg2
args:
user: synapse
password: "$POSTGRES_PASSWORD"
dbname: synapse
host: postgres
cp_min: 5
cp_max: 10
enable_registration: true
registration_requires_token: true
max_upload_size: 100M
turn_uris:
- "turn:turn.example.com:3478?transport=udp"
- "turn:turn.example.com:3478?transport=tcp"
turn_shared_secret: "$TURN_SECRET"
turn_user_lifetime: 86400000
turn_allow_guests: false
EOF
enable_registration: true together with registration_requires_token: true means the sign-up form is open, but only to people holding a token you created. The Synapse docs note that both settings are needed.
4. The Compose file, Element's config and the internal nginx
# as root, in /opt/matrix
cat > compose.yaml <<'EOF'
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: synapse
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: synapse
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "synapse"]
interval: 10s
synapse:
image: matrixdotorg/synapse:v1.163.2
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
volumes:
- ./synapse:/data
element:
image: vectorim/element-web:v1.12.32
restart: unless-stopped
volumes:
- ./element-config.json:/app/config.json:ro
nginx:
image: nginx:1.29-alpine
restart: unless-stopped
depends_on: [synapse, element]
ports:
- "127.0.0.1:18086:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
EOF
The POSTGRES_INITDB_ARGS line matters: Synapse wants a UTF8 database with the C collation, and it refuses to start on the wrong one (Synapse PostgreSQL guide). Pinning image versions instead of latest means an update happens when you decide, not on a random restart.
# as root, in /opt/matrix
cat > element-config.json <<'EOF'
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.example.com",
"server_name": "example.com"
}
},
"disable_guests": true,
"brand": "Element",
"room_directory": { "servers": ["example.com"] }
}
EOF
cat > nginx.conf <<'EOF'
# Synapse: client API and federation on matrix.example.com
server {
listen 80;
server_name matrix.example.com;
client_max_body_size 100M;
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass http://synapse:8008;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1;
}
}
# Element Web on chat.example.com
server {
listen 80;
server_name chat.example.com;
location / {
proxy_pass http://element:80;
}
}
# Delegation files on the bare domain example.com
server {
listen 80;
server_name example.com;
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"}}';
}
}
EOF
Notice what is missing: /_synapse/admin. The admin API is not proxied, so nobody on the internet can even try it. You reach it from the server itself, as shown further down.
5. Start it and check each name
# as root, in /opt/matrix
docker compose up -d
docker compose ps --format "table {{.Service}}\t{{.Status}}"
Give Synapse about 30 seconds on the first start: it builds its tables. In our lab all four services read Up, with postgres, element and synapse marked (healthy), and PostgreSQL held 176 tables. Now ask each hostname through the local port, the way your HTTPS proxy will:
# as root, on the server
curl -s -H "Host: matrix.example.com" http://127.0.0.1:18086/_matrix/federation/v1/version; echo
curl -s -H "Host: example.com" http://127.0.0.1:18086/.well-known/matrix/server; echo
curl -s -H "Host: example.com" http://127.0.0.1:18086/.well-known/matrix/client; echo
curl -s -H "Host: chat.example.com" http://127.0.0.1:18086/version; echo
Expected output, as we saw it:
{"server":{"name":"Synapse","version":"1.163.2"}}
{"m.server": "matrix.example.com:443"}
{"m.homeserver": {"base_url": "https://matrix.example.com"}}
1.12.32
6. HTTPS in front
Put a reverse proxy with free certificates on the host and send all three names to 127.0.0.1:18086. With Caddy, which gets Let's Encrypt certificates on its own, the whole site file is one block:
example.com, matrix.example.com, chat.example.com {
reverse_proxy 127.0.0.1:18086
}
Caddy passes the original Host header, so the internal nginx knows which name was asked for. If example.com is your website on another server, leave it out here and publish the two .well-known files on that website instead. Our Caddy, nginx and Traefik comparison covers the proxy and certificate side in more detail.
Federation and .well-known delegation, in plain words
Federation is Matrix's version of email between providers: your server joins rooms hosted elsewhere, and people on matrix.org or another company's server can message @anna:example.com. To find you, another server reads https://example.com/.well-known/matrix/server. Without that file it tries https://example.com:8448, the default federation port (Synapse delegation docs). With our file it goes to matrix.example.com:443, so port 8448 can stay closed.
The second file, /.well-known/matrix/client, is for apps. When someone types @anna:example.com into Element X on a phone, the app fetches it to learn the real server URL. It needs the Access-Control-Allow-Origin header, or web clients cannot read it.
Once DNS and HTTPS are live, check from outside with the public Matrix Federation Tester; it should show your server name, the delegated host and a green result. Our lab used a fake server name with no public DNS, so we tested the same endpoints locally: the federation version call above, and /_matrix/key/v2/server, which returned the server's signed ed25519 key.
If you do not want federation at all, a single line in homeserver.yaml closes it: federation_domain_whitelist: []. That is the setup in our company chat guide.
Invite people with registration tokens
A registration token is a code that lets one or more people create their own account. You decide how many uses it has and when it expires, and you never handle anyone's password. First make yourself an admin and log in once to get an access token:
# as root, in /opt/matrix
openssl rand -base64 18 > admin.pw
chmod 600 admin.pw
docker compose exec -T synapse register_new_matrix_user -c /data/homeserver.yaml -u admin -p "$(cat admin.pw)" --admin http://localhost:8008
curl -s -H "Host: matrix.example.com" -H 'Content-Type: application/json' -X POST http://127.0.0.1:18086/_matrix/client/v3/login -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"admin\"},\"password\":\"$(cat admin.pw)\"}" | sed -E 's/.*"access_token":"([^"]+)".*/\1/' > admin.token
chmod 600 admin.token
The first command prints Sending registration request... and Success!. Now create a token for five people, through the admin API from inside the Synapse container:
# as root, in /opt/matrix
docker compose exec -T synapse curl -s -X POST -H "Authorization: Bearer $(cat admin.token)" -d '{"uses_allowed": 5}' http://localhost:8008/_synapse/admin/v1/registration_tokens/new; echo
Our answer was {"token":"WatZX1J4dTBLIzl6","uses_allowed":5,"pending":0,"completed":0,"expiry_time":null}. Send the token to your people; in Element they pick "Create account", fill in a name and password, and Element asks for the token. To add an expiry, pass "expiry_time" as a Unix time in milliseconds (registration tokens API).
We tested the sign-up through the API, the same calls Element makes. With no token the server answered with the steps it requires, "stages":["m.login.registration_token","m.login.dummy"]. A made-up token got "error":"Invalid registration token","errcode":"M_UNAUTHORIZED". The real one went through and created @anna:example.com, and the token list then showed "completed":1, four uses left.
Voice and video calls: coturn, and when you need LiveKit
Matrix has two kinds of calls today, and they need different servers.
Classic one-to-one calls connect the two devices directly with WebRTC. When both sit behind strict NAT or a company firewall, the media has to bounce through a TURN relay. That is coturn. Synapse hands each logged-in user short-lived TURN credentials, built from the turn_shared_secret you set in step 3. You can see them as any user:
# on the server, with a normal user's access token (the "access_token" from their login)
curl -s -H "Host: matrix.example.com" -H "Authorization: Bearer USER_ACCESS_TOKEN" http://127.0.0.1:18086/_matrix/client/v3/voip/turnServer; echo
For Anna that returned {"username":"1792965613:@anna:example.com","password":"...","ttl":86400,"uris":["turn:turn.example.com:3478?transport=udp","turn:turn.example.com:3478?transport=tcp"]}. The username is an expiry time plus the user ID, and the password is a hash of it with the shared secret, so coturn can check it without a user database.
The coturn config we used follows the Synapse coturn guide, including its list of private ranges the relay must never reach. Without those lines, anyone with an account could use your TURN server to poke at addresses inside your network.
# as root, in /opt/matrix
. ./.env
cat > turnserver.conf <<EOF
listening-port=3478
realm=turn.example.com
use-auth-secret
static-auth-secret=$TURN_SECRET
min-port=49160
max-port=49200
no-tls
no-dtls
no-cli
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
user-quota=12
total-quota=1200
log-file=stdout
EOF
The Synapse guide lists more reserved ranges (100.64.0.0/10, 169.254.0.0/16 and the documentation blocks); copy its full list for production. On your VPS, add coturn to compose.yaml with network_mode: host, the image coturn/coturn:4.18.0 and this file mounted at /etc/coturn/turnserver.conf, because TURN needs the real public address and a whole range of UDP ports. Our lab could only open one port, so there coturn ran on the internal Docker network, and we tested it with coturn's own client, turnutils_uclient, using the username and password that Synapse had just issued to Anna:
- With a wrong password:
Cannot complete Allocation. Good. - With Synapse's credentials, relaying to a test peer on a private 172.18.x address: allocation succeeded, then
channel bind: error 403, and the coturn log saidA peer IP 172.18.0.6 denied in the range: 172.16.0.0-172.31.255.255. The deny list did its job. - After a lab-only
allowed-peer-ipline for the test peer:tot_send_msgs=20, tot_recv_msgs=20andTotal lost packets 0 (0.000000%). Relaying worked with Synapse-issued credentials.
coturn used 7 to 10 MiB of RAM. For a public server, add a certificate and TLS on port 5349 so calls also pass networks that only allow HTTPS-like traffic.
Element Call is the newer system, built on MatrixRTC. It handles group calls, and Element X uses it for calls too. It does not use your coturn. It needs a LiveKit SFU (a media server that receives every participant's stream and forwards it) and Element's MatrixRTC Authorization Service, lk-jwt-service, which checks that a Matrix user may join a call and gives them a LiveKit token. Synapse also needs some extra settings: MSC3266, MSC4143 and MSC4222 enabled, delayed events through max_event_delay_duration, looser rc_message limits and a matrix_rtc transport that points at the authorization service. The Element Call self-hosting guide gives the exact block. LiveKit has its own TURN built in, and it is the heavier of the two: plan CPU for it, because the SFU forwards every video stream. We did not load-test LiveKit in this lab. If what your team really wants is video meetings with guests who have no Matrix account, Jitsi Meet on its own VPS is the simpler tool.
How much RAM: what we measured, and what grows it
Measured with docker stats on 24 October 2026, three minutes after start and again after a 500-message burst in one room:
| Container | Idle | After the burst | Image size |
|---|---|---|---|
| Synapse 1.163.2 | 161 MiB | 163 MiB | 332 MB |
| PostgreSQL 17 | 62 MiB | 82 MiB | 297 MB |
| Element Web 1.12.32 | 9.7 MiB | 9.7 MiB | 234 MB |
| nginx | 4.1 MiB | 4.2 MiB | 62 MB |
| coturn 4.18.0 | 7.1 MiB | 10.0 MiB | 127 MB |
| Total | about 245 MiB | about 270 MiB | about 1 GB |
So how much RAM per user? For a private server, users are cheap: a few idle accounts barely move the numbers above. What grows Synapse is the size of the rooms it joins. A local room with 20 colleagues is small. A big public room on another server can hold tens of thousands of members and years of state, and your server must load and check that state when it joins. That is why the Synapse install guide asks for "at least 1GB of free RAM" if you want to join large public rooms like #matrix:matrix.org (Synapse installation). Images and files go to disk, not RAM, in synapse/media_store.
A rough guide from that:
- A family or a team of up to about 50 that mostly talks among itself: 2 GB of RAM is comfortable, leaving room for Debian, Docker and coturn.
- The same team plus joining large public federated rooms, or bridges to WhatsApp, Telegram or Signal: 4 GB.
- Several hundred active users, many federated rooms, or self-hosted LiveKit for group video: 4 vCPUs and 8 GB, and a dedicated vCPU helps because calls and room joins are CPU-bound. Our VDS plans give dedicated vCPUs; VPS plans share them.
PostgreSQL or SQLite?
PostgreSQL, for anything you plan to keep. The Synapse backup guide says it plainly: "Synapse's support for SQLite is only suitable for testing purposes" (Synapse backups). SQLite is a single file with one writer at a time, and federation writes a lot in the background. Switching later is possible with the synapse_port_db script, but it is a migration with downtime, so it is easier to start on PostgreSQL. In the Compose setup above it costs about 62 MiB of RAM.
Back up three things, and leave one table out
A Synapse server is the database, the media folder and the signing key with the config. Lose the key and other servers stop trusting your old messages; lose the media and avatars and attachments break.
# as root, in /opt/matrix
mkdir -p backup
D=$(date +%F)
docker compose exec -T postgres pg_dump -U synapse -Fc --exclude-table-data e2e_one_time_keys_json synapse > backup/synapse-$D.dump
tar -czf backup/synapse-files-$D.tar.gz synapse/homeserver.yaml synapse/example.com.signing.key synapse/media_store .env compose.yaml nginx.conf element-config.json turnserver.conf
ls -lh backup
The --exclude-table-data e2e_one_time_keys_json part comes straight from the Synapse backup guide: restoring old one-time encryption keys can make the server hand out keys that were already used, and users then see messages they cannot decrypt. pg_dump takes a consistent copy while Synapse runs, so nothing has to stop. Our test server's backup was 324 KB for the database and 2.4 KB for the files; a real one grows mostly with media.
We tested the restore into a fresh, empty PostgreSQL 17 container. pg_restore finished with exit code 0, the 60 messages and both accounts were there, and the excluded key table was empty, as intended. Never restore over a database that already has tables. Copy the backup folder off the server every night; our guide to offsite backups with restic shows one way.
Two rate limits that surprised us
Synapse protects itself, and the defaults are tight for a fresh admin running scripts:
- After about five logins from the same IP in a few minutes, the sixth got
{"errcode":"M_LIMIT_EXCEEDED","error":"Too Many Requests","retry_after_ms":178688}, three minutes of waiting. The defaultrc_loginallows a burst of 5 and then 0.003 per second. Reuse one access token in scripts instead of logging in each time. - Our 500-message loop got 60 messages accepted and the rest refused with HTTP 429. The default
rc_messageis a burst of 10, then 0.2 messages per second per account (config docs). People typing never hit it; imports and bots do. Raise it for a bot account through the admin API's per-user rate-limit override, not for everyone.
Synapse, conduwuit or Tuwunel?
Conduwuit, the fast Rust fork of Conduit, is now listed as obsolete on the matrix.org server list. Its official successor is Tuwunel, which describes itself as a Synapse alternative "written entirely in Rust" and says it is primarily sponsored by the government of Switzerland. The other active fork is Continuwuity. These servers keep everything in an embedded RocksDB database, so there is no PostgreSQL to run.
In the same lab, Tuwunel 1.9.4 started in about a second and used 52 MiB of RAM at idle with an empty database, against about 225 MiB for Synapse plus PostgreSQL. For a small private server that difference is real. What you give up: the full Synapse admin API that tools and guides assume, the long list of documented Synapse settings such as the MatrixRTC block above, and the large pool of Synapse answers online. Our view: pick Synapse when you want the best-documented path, bridges and Element Call; try Tuwunel when RAM is tight or you just want a family server. Moving between them later means a new server and a new database, so choose at the start.
Frequently asked questions
Can I self-host Element and Matrix for free?
Yes. Synapse is free under the AGPL, Element Web is free, and coturn, LiveKit and Tuwunel are free too. You pay only for the server and a domain. Element also sells a commercial server edition with support, which you do not need for a self-hosted setup like this one.
How much RAM does a Matrix server need?
In our test, Synapse, PostgreSQL, Element Web, nginx and coturn used about 245 MiB together at idle. A 2 GB VPS is comfortable for a private team. Plan 4 GB if you join large public rooms on other servers or run bridges, because joining big rooms is what makes Synapse use more memory.
Do I need port 8448 for federation?
No, if you publish /.well-known/matrix/server on your domain pointing to your Matrix host on port 443. Other servers read that file first and then connect on 443. Without delegation they try port 8448 on the server name itself.
Why don't video calls work on my self-hosted Element?
Classic one-to-one calls need a TURN server such as coturn once users sit behind strict NAT. Element Call group calls, and calls in Element X, need a LiveKit SFU plus lk-jwt-service and extra Synapse settings. TURN alone does not make Element Call work.
How do I stop strangers from signing up?
Either keep enable_registration: false and create accounts yourself, or set enable_registration: true with registration_requires_token: true and give out tokens with a fixed number of uses. In our test a wrong token was refused with "Invalid registration token".
Can I change the server name later?
No. The server name is part of every user ID and room alias. Changing it means a new server and new accounts. Use your main domain and delegate to a subdomain, as shown above.
Run it on a VPS in Kosovo, Amsterdam or Dublin
A chat server holds your messages and files, so where it runs matters. A VPS in the Netherlands or a VPS in Ireland keeps the data inside the EU, and a VPS in Kosovo keeps it in Prishtina, close to teams in the region. RS Computers runs every plan on KVM with NVMe storage, IPv6 and a 99% monthly uptime SLA, and a new server is ready under 60 seconds after payment. For Synapse with PostgreSQL and coturn, start with 2 GB of RAM; pick a VDS with dedicated vCPUs if you add LiveKit for group video. Compare the VPS and VDS plans, and if you would rather have us set the stack up, ask on Telegram.