Archive / 2026

Setting up a federated Matrix Synapse server with Docker and Cloudflare Tunnel

Run a federated Matrix Synapse homeserver with Docker, PostgreSQL, Cloudflare Tunnel, and .well-known delegation without opening inbound ports.

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

as the Matrix server name, and:

matrix.example.com

as 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.com

That 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.com

You 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/client

There 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.com

other 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/server

with 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/matrix

Create the folders:

sudo mkdir -p /srv/config/matrix/{synapse,postgres}
sudo chown -R "$USER:$USER" /srv/config/matrix
cd /srv/config/matrix

This creates:

/srv/config/matrix/synapse
/srv/config/matrix/postgres

The 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 generate

If 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:latest

Then rerun the generate command.

After generation, you should have files like:

/srv/config/matrix/synapse/homeserver.yaml
/srv/config/matrix/synapse/example.com.signing.key

The 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.yaml

If nano says the file is unwritable, fix ownership temporarily:

sudo chown -R "$USER:$USER" /srv/config/matrix/synapse

Then 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: false

Replace the default SQLite database section:

database:
  name: sqlite3
  args:
    database: /data/homeserver.db

with PostgreSQL:

database:
  name: psycopg2
  args:
    user: synapse
    password: CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD
    database: synapse
    host: postgres
    cp_min: 5
    cp_max: 10

Disable public registration unless you deliberately want open signups:

enable_registration: false

Keep 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.yml

Use 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: bridge

The 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.yaml

5. Start Synapse and PostgreSQL

Run:

cd /srv/config/matrix
docker compose up -d

Check the containers:

docker compose ps

Check Synapse logs:

docker compose logs -f synapse

If 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.yaml

Then restart Synapse:

cd /srv/config/matrix
docker compose restart synapse

Check logs again:

docker compose logs -f synapse

The 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.yaml

or 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/versions

You 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/version

Expected 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 container

Find your Cloudflare Tunnel config.

Common locations:

/etc/cloudflared/config.yml
~/.cloudflared/config.yml
/srv/config/cloudflared/config.yml

If using systemd, /etc/cloudflared/config.yml is common.

Edit it:

sudo nano /etc/cloudflared/config.yml

Add an ingress rule for Matrix:

ingress:
  - hostname: matrix.example.com
    service: http://127.0.0.1:8008

  - service: http_status:404

If you already have other ingress rules, insert this above the final catch-all rule:

  - hostname: matrix.example.com
    service: http://127.0.0.1:8008

Restart cloudflared:

sudo systemctl restart cloudflared

Check status:

systemctl status cloudflared --no-pager

Check logs:

journalctl -u cloudflared -f

If you need to create the DNS route:

cloudflared tunnel route dns YOUR_TUNNEL_NAME matrix.example.com

Example:

cloudflared tunnel route dns matrix matrix.example.com

Or create the route in the Cloudflare dashboard.


8. Test the public Matrix endpoint

Run:

curl -i https://matrix.example.com/_matrix/client/versions

You should get:

HTTP/2 200
content-type: application/json

Then test federation:

curl -i https://matrix.example.com/_matrix/federation/v1/version

You 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/client

The 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.com

Create this file on your existing website:

/.well-known/matrix/server

Content:

{
  "m.server": "matrix.example.com:443"
}

Create this file too:

/.well-known/matrix/client

Content:

{
  "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 nginx

If 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/client

or 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/server

Expected body:

{
  "m.server": "matrix.example.com:443"
}

Check the client discovery file:

curl -i https://example.com/.well-known/matrix/client

Expected 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:8008

When prompted:

New user localpart [root]:

Enter a username, for example:

admin

Then enter a strong password.

When asked:

Make admin [no]:

type:

yes

Your Matrix ID will be:

@admin:example.com

12. Log in with a Matrix client

Use Element or another Matrix client.

Homeserver URL:

https://matrix.example.com

Log 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.com

Incorrect, unless you deliberately used this as your Synapse server name:

matrix.example.com

The tester should discover:

example.com
  -> .well-known
  -> matrix.example.com:443

If it reports zero connection results, check your .well-known response first.

Run:

curl -i https://example.com/.well-known/matrix/server

Make 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.com

Avoid 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 Matrix

15. Backups

Back up:

/srv/config/matrix/synapse
/srv/config/matrix/postgres
/srv/config/matrix/docker-compose.yml

Especially back up the signing key:

/srv/config/matrix/synapse/example.com.signing.key

or for your real domain:

/srv/config/matrix/synapse/YOUR_DOMAIN.signing.key

A simple PostgreSQL dump:

docker exec matrix-postgres pg_dump -U synapse synapse > /srv/config/matrix/synapse-db-$(date +%F).sql

A 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 -d

Do 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 synapse

For a more controlled setup, pin Synapse to a specific version instead of using latest:

image: matrixdotorg/synapse:v1.156.0

Using latest is convenient. Pinning versions is calmer.