This guide shows how to run a Matrix Synapse homeserver in Docker, expose it through Cloudflare Tunnel, and make federation work using Matrix .well-known delegation.
The goal is:
- no public inbound ports on your server
- Synapse running in Docker
- Cloudflare Tunnel handling HTTPS
- federation working properly
- Matrix IDs like
@alice:example.com - Synapse reachable at
https://matrix.example.com
This setup uses:
example.comas the Matrix server name, and:
matrix.example.comas the public Synapse hostname.
Replace those with your own domain.
Do not set the Synapse server name to matrix.example.com unless you want Matrix IDs like:
@alice:matrix.example.comThat works, but it is less tidy.
What you will need
You need:
- a domain managed through Cloudflare
- Docker and Docker Compose installed
- Cloudflare Tunnel already set up, or permission to create one
- a server capable of running Synapse and PostgreSQL
- shell access to the server
- a subdomain for Matrix, for example:
matrix.example.comYou also need to decide how you will serve Matrix .well-known files from your root domain:
https://example.com/.well-known/matrix/server
https://example.com/.well-known/matrix/clientThere are two good options:
Option A:
Serve static .well-known files from your existing root website.
Use this if you control the existing website at example.com.
Option B:
Use a Cloudflare Worker for only the Matrix .well-known paths.
Use this if your root website is already hosted somewhere else and you do not want to touch it.
Both options are covered below.
How Matrix federation will work
Matrix federation normally tries to reach your homeserver using your server name.
If your Matrix server name is:
example.comother Matrix servers need to know where to connect.
Instead of exposing port 8448, this guide uses Matrix delegation.
You publish this file:
https://example.com/.well-known/matrix/serverwith this content:
{
"m.server": "matrix.example.com:443"
}That tells other Matrix servers:
Federate with example.com by connecting to matrix.example.com on port 443.So your final layout is:
Matrix IDs: @user:example.com
Synapse public URL: https://matrix.example.com
Federation target: matrix.example.com:443
Root well-known: https://example.com/.well-known/matrix/*Cloudflare Tunnel handles the HTTPS connection to Synapse.
1. Create the Matrix folders
This guide stores Matrix config under:
/srv/config/matrixCreate the folders:
sudo mkdir -p /srv/config/matrix/{synapse,postgres}
sudo chown -R "$USER:$USER" /srv/config/matrix
cd /srv/config/matrixThis creates:
/srv/config/matrix/synapse
/srv/config/matrix/postgresThe synapse folder stores Synapse config, media, and signing keys.
The postgres folder stores the PostgreSQL database files.
2. Generate the Synapse config
Run:
docker run -it --rm \
-v /srv/config/matrix/synapse:/data \
-e SYNAPSE_SERVER_NAME=example.com \
-e SYNAPSE_REPORT_STATS=no \
matrixdotorg/synapse:latest generateIf Docker appears to hang while pulling the image, it is probably still downloading or extracting layers.
You can pull the image first:
docker pull matrixdotorg/synapse:latestThen rerun the generate command.
After generation, you should have files like:
/srv/config/matrix/synapse/homeserver.yaml
/srv/config/matrix/synapse/example.com.signing.keyThe signing key is important. Back it up. If you lose it, your homeserver identity gets very annoying very quickly.
3. Edit homeserver.yaml
Open the config file:
nano /srv/config/matrix/synapse/homeserver.yamlIf nano says the file is unwritable, fix ownership temporarily:
sudo chown -R "$USER:$USER" /srv/config/matrix/synapseThen reopen the file.
A newly generated Synapse config may look fairly small. That is normal.
You need these settings.
Set the server name:
server_name: "example.com"Add the public base URL:
public_baseurl: "https://matrix.example.com/"Make sure the listener looks like this:
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ['0.0.0.0']
resources:
- names: [client, federation]
compress: falseReplace the default SQLite database section:
database:
name: sqlite3
args:
database: /data/homeserver.dbwith PostgreSQL:
database:
name: psycopg2
args:
user: synapse
password: CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD
database: synapse
host: postgres
cp_min: 5
cp_max: 10Disable public registration unless you deliberately want open signups:
enable_registration: falseKeep the generated secret values and signing key path. Do not delete lines like these:
registration_shared_secret: "..."
macaroon_secret_key: "..."
form_secret: "..."
signing_key_path: "/data/example.com.signing.key"Do not post these secrets publicly.
The signing key is especially sensitive.
4. Create the Docker Compose file
Create:
nano /srv/config/matrix/docker-compose.ymlUse this:
services:
postgres:
image: postgres:16-alpine
container_name: matrix-postgres
restart: unless-stopped
environment:
POSTGRES_DB: synapse
POSTGRES_USER: synapse
POSTGRES_PASSWORD: CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
volumes:
- /srv/config/matrix/postgres:/var/lib/postgresql/data
networks:
- matrix
synapse:
image: matrixdotorg/synapse:latest
container_name: matrix-synapse
restart: unless-stopped
depends_on:
- postgres
volumes:
- /srv/config/matrix/synapse:/data
ports:
- "127.0.0.1:8008:8008"
networks:
- matrix
networks:
matrix:
name: matrix
driver: bridgeThe important part is this:
ports:
- "127.0.0.1:8008:8008"That exposes Synapse only on the host itself.
Do not use this unless you intentionally want Synapse exposed on your LAN/public interface:
ports:
- "8008:8008"That is not needed when using Cloudflare Tunnel.
Also make sure the PostgreSQL password matches in both files:
/srv/config/matrix/docker-compose.yml
/srv/config/matrix/synapse/homeserver.yaml5. Start Synapse and PostgreSQL
Run:
cd /srv/config/matrix
docker compose up -dCheck the containers:
docker compose psCheck Synapse logs:
docker compose logs -f synapseIf Synapse complains about permissions on the signing key, run:
sudo chown -R 991:991 /srv/config/matrix/synapse
sudo chmod 600 /srv/config/matrix/synapse/*.signing.key
sudo chmod 644 /srv/config/matrix/synapse/homeserver.yamlThen restart Synapse:
cd /srv/config/matrix
docker compose restart synapseCheck logs again:
docker compose logs -f synapseThe official Synapse container commonly runs as UID/GID 991, so this gives the container access to its config and signing key.
If you need to edit the config again later, either use:
sudo nano /srv/config/matrix/synapse/homeserver.yamlor temporarily change ownership back to your user, edit it, then return ownership to 991:991.
6. Test Synapse locally
Once the container is running, test the local Synapse endpoint:
curl -i http://127.0.0.1:8008/_matrix/client/versionsYou should get a JSON response.
Also test the federation endpoint locally through the published localhost port:
curl -i http://127.0.0.1:8008/_matrix/federation/v1/versionExpected response should include something like:
{
"server": {
"name": "Synapse",
"version": "..."
}
}7. Configure Cloudflare Tunnel
This guide assumes cloudflared is running on the host, not inside Docker.
The tunnel path is:
Internet
-> Cloudflare
-> cloudflared on the host
-> http://127.0.0.1:8008
-> Synapse containerFind your Cloudflare Tunnel config.
Common locations:
/etc/cloudflared/config.yml
~/.cloudflared/config.yml
/srv/config/cloudflared/config.ymlIf using systemd, /etc/cloudflared/config.yml is common.
Edit it:
sudo nano /etc/cloudflared/config.ymlAdd an ingress rule for Matrix:
ingress:
- hostname: matrix.example.com
service: http://127.0.0.1:8008
- service: http_status:404If you already have other ingress rules, insert this above the final catch-all rule:
- hostname: matrix.example.com
service: http://127.0.0.1:8008Restart cloudflared:
sudo systemctl restart cloudflaredCheck status:
systemctl status cloudflared --no-pagerCheck logs:
journalctl -u cloudflared -fIf you need to create the DNS route:
cloudflared tunnel route dns YOUR_TUNNEL_NAME matrix.example.comExample:
cloudflared tunnel route dns matrix matrix.example.comOr create the route in the Cloudflare dashboard.
8. Test the public Matrix endpoint
Run:
curl -i https://matrix.example.com/_matrix/client/versionsYou should get:
HTTP/2 200
content-type: application/jsonThen test federation:
curl -i https://matrix.example.com/_matrix/federation/v1/versionYou should get a JSON response from Synapse.
If this works, Cloudflare Tunnel is routing to Synapse correctly.
9. Set up Matrix .well-known
You now need the root domain to tell Matrix clients and other Matrix servers where your homeserver lives.
These URLs need to work:
https://example.com/.well-known/matrix/server
https://example.com/.well-known/matrix/clientThe server file is required for clean federation.
The client file helps Matrix clients discover the homeserver automatically.
You have two options.
Option A: Serve .well-known from your existing root website
Use this if you already control the website at:
https://example.comCreate this file on your existing website:
/.well-known/matrix/serverContent:
{
"m.server": "matrix.example.com:443"
}Create this file too:
/.well-known/matrix/clientContent:
{
"m.homeserver": {
"base_url": "https://matrix.example.com"
}
}Make sure both return:
Content-Type: application/json
Access-Control-Allow-Origin: *If your existing root site is nginx, an example config could be:
location = /.well-known/matrix/server {
default_type application/json;
add_header Access-Control-Allow-Origin * always;
return 200 '{"m.server":"matrix.example.com:443"}';
}
location = /.well-known/matrix/client {
default_type application/json;
add_header Access-Control-Allow-Origin * always;
return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"}}';
}Then reload nginx:
sudo nginx -t
sudo systemctl reload nginxIf your root site is Caddy, you can use:
example.com {
handle /.well-known/matrix/server {
header Content-Type application/json
header Access-Control-Allow-Origin *
respond `{"m.server":"matrix.example.com:443"}`
}
handle /.well-known/matrix/client {
header Content-Type application/json
header Access-Control-Allow-Origin *
respond `{"m.homeserver":{"base_url":"https://matrix.example.com"}}`
}
# your existing site config below
}If your site is static, create files at:
public/.well-known/matrix/server
public/.well-known/matrix/clientor wherever your web root is.
Option B: Use a Cloudflare Worker for .well-known
Use this if your root domain is already hosted somewhere else and you do not want to modify that site.
Cloudflare does not have a simple DNS-level “well-known folder” feature.
A Worker is the clean way to intercept only:
example.com/.well-known/matrix/*while leaving the rest of your root website alone.
Create a Cloudflare Worker with this code:
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/.well-known/matrix/server") {
return new Response(
JSON.stringify({
"m.server": "matrix.example.com:443"
}),
{
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*"
}
}
);
}
if (url.pathname === "/.well-known/matrix/client") {
return new Response(
JSON.stringify({
"m.homeserver": {
"base_url": "https://matrix.example.com"
}
}),
{
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*"
}
}
);
}
return fetch(request);
}
}Then add a Worker route:
example.com/.well-known/matrix/*This lets the Worker answer only the Matrix discovery paths. Everything else on your root domain keeps going to the existing website.
Double-check every hostname in the .well-known responses. A one-letter typo is enough to break federation; computers remain joyless about this.
10. Verify .well-known
Check the federation discovery file:
curl -i https://example.com/.well-known/matrix/serverExpected body:
{
"m.server": "matrix.example.com:443"
}Check the client discovery file:
curl -i https://example.com/.well-known/matrix/clientExpected body:
{
"m.homeserver": {
"base_url": "https://matrix.example.com"
}
}11. Create your first admin user
Run:
docker exec -it matrix-synapse register_new_matrix_user \
-c /data/homeserver.yaml \
http://localhost:8008When prompted:
New user localpart [root]:Enter a username, for example:
adminThen enter a strong password.
When asked:
Make admin [no]:type:
yesYour Matrix ID will be:
@admin:example.com12. Log in with a Matrix client
Use Element or another Matrix client.
Homeserver URL:
https://matrix.example.comLog in with the user you created.
13. Test federation
Use the Matrix Federation Tester:
https://federationtester.matrix.org/Enter your server name, not your Matrix subdomain.
Correct:
example.comIncorrect, unless you deliberately used this as your Synapse server name:
matrix.example.comThe tester should discover:
example.com
-> .well-known
-> matrix.example.com:443If it reports zero connection results, check your .well-known response first.
Run:
curl -i https://example.com/.well-known/matrix/serverMake sure the hostname is exactly right.
14. Cloudflare settings
For Matrix to work properly, avoid putting browser-only protection in front of Matrix endpoints.
Do not enable Cloudflare Access for:
matrix.example.comAvoid challenges or restrictive WAF rules on:
/_matrix/client/*
/_matrix/federation/*
/.well-known/matrix/*Matrix servers are not browsers. If Cloudflare asks another homeserver to solve a JavaScript challenge, federation will fail.
Recommended Cloudflare settings:
SSL/TLS mode: Full or Full strict
Cloudflare Access: disabled for Matrix
Bot challenges: disabled for Matrix paths
Caching: do not cache Matrix API responses aggressively
Rocket Loader: not relevant, best left away from Matrix15. Backups
Back up:
/srv/config/matrix/synapse
/srv/config/matrix/postgres
/srv/config/matrix/docker-compose.ymlEspecially back up the signing key:
/srv/config/matrix/synapse/example.com.signing.keyor for your real domain:
/srv/config/matrix/synapse/YOUR_DOMAIN.signing.keyA simple PostgreSQL dump:
docker exec matrix-postgres pg_dump -U synapse synapse > /srv/config/matrix/synapse-db-$(date +%F).sqlA cold backup:
cd /srv/config/matrix
docker compose down
sudo tar -czf /srv/config/matrix-backup-$(date +%F).tar.gz /srv/config/matrix
docker compose up -dDo not skip backups. Matrix media and database state are not things you want to lovingly reconstruct by hand.
16. Updating Synapse
Update the containers:
cd /srv/config/matrix
docker compose pull
docker compose up -d
docker compose logs -f synapseFor a more controlled setup, pin Synapse to a specific version instead of using latest:
image: matrixdotorg/synapse:v1.156.0Using latest is convenient. Pinning versions is calmer.