Deploy a Matrix Server

Step-by-step guide for chapter leads to stand up a private Matrix/Synapse communications server on a Raspberry Pi 5.

Overview

Matrix is a decentralized, end-to-end encrypted communications protocol. Unlike Signal or Discord, Matrix lets you run your own server. Your messages, member data, and encryption keys stay on hardware you control. No third party can read your conversations, shut down your server, or hand over your data.

This guide walks you through deploying a Matrix homeserver on a Raspberry Pi 5. The stack uses Synapse (the reference Matrix server implementation), Element Web (a browser-based chat client), and a Cloudflare tunnel to handle external access without opening ports on your network. The entire deployment runs in Docker containers, which means installation is clean, updates are simple, and the server is easy to back up or rebuild.

LFHI HQ runs the reference implementation on this same stack. When you complete this guide, your chapter will have a private, encrypted communications server that can optionally federate with other chapter servers across the country.

Prerequisites

Before you start, make sure you have the following:

  • Raspberry Pi 5 – 4GB minimum, 8GB recommended. The Pi 4 works but is slower. Do not attempt this on a Pi 3 or Zero.
  • microSD card – 32GB minimum, 64GB recommended. Use a card rated A2 for better random I/O performance.
  • Power supply – Official Raspberry Pi 5 USB-C power supply (27W). Underpowered supplies cause instability.
  • Ethernet cable – WiFi works but wired is significantly more reliable for a server that needs to stay online.
  • Domain name – You need a domain you control. This can be your chapter’s existing domain or a new one. You will create two subdomains: matrix.yourdomain.org and chat.yourdomain.org.
  • Cloudflare account – Free tier is sufficient. You will use Cloudflare for DNS and tunneling.
  • Another computer – To SSH into the Pi and configure everything.
  • About 2 hours – Most of that is waiting for downloads and reading configuration explanations.

Part 1: Pi Setup

Flash the Operating System

Download and install Raspberry Pi Imager on your computer. Insert the microSD card and open the Imager.

  1. Click Choose OS and select Raspberry Pi OS Lite (64-bit) under “Raspberry Pi OS (other).” Use the Lite version – you do not need a desktop environment on a server.
  2. Click Choose Storage and select your microSD card.
  3. Click the gear icon (or press Ctrl+Shift+X) to open advanced settings:
    • Set hostname: Pick something descriptive, like matrix-pi or your chapter name.
    • Enable SSH: Select “Use password authentication” or set up public key authentication if you are comfortable with it.
    • Set username and password: Create a user account. Do not use the default pi username.
    • Configure WiFi: Only if you are not using Ethernet.
    • Set locale: Your timezone and keyboard layout.
  4. Click Write and wait for the image to finish.

Insert the card into the Pi, connect Ethernet, and plug in power.

Find the Pi and Connect

After a minute or two, the Pi will be on your network. Find its IP address from your router’s admin page or by running:

ping matrix-pi.local

SSH into the Pi:

ssh youruser@matrix-pi.local

Update the System

Run a full system update:

sudo apt update && sudo apt upgrade -y

Reboot if the kernel was updated:

sudo reboot

Install Docker

Docker runs all three services (Synapse, Element Web, Cloudflare tunnel) in containers. Install Docker using the official convenience script:

curl -fsSL https://get.docker.com | sh

Add your user to the Docker group so you can run Docker commands without sudo:

sudo usermod -aG docker $USER

Log out and back in for the group change to take effect:

exit

SSH back in and verify Docker is working:

docker --version
docker compose version

Both commands should return version numbers. If docker compose is not found, install the compose plugin:

sudo apt install docker-compose-plugin

Configure the Firewall

Set up UFW (Uncomplicated Firewall) to block everything except SSH from your local network:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 192.168.0.0/16 to any port 22 proto tcp
sudo ufw enable

Adjust the 192.168.0.0/16 range if your local network uses a different subnet (e.g., 10.0.0.0/8). This allows SSH only from your LAN. All other inbound traffic is blocked. The Cloudflare tunnel initiates outbound connections, so it works without any inbound port rules.

Verify the firewall is active:

sudo ufw status

Part 2: Domain and Cloudflare

All external traffic to your Matrix server flows through a Cloudflare tunnel. The tunnel is an outbound connection from your Pi to Cloudflare’s network, which means you do not need to open any ports on your router, configure port forwarding, or manage TLS certificates. Cloudflare handles all of it.

Add Your Domain to Cloudflare

If your domain is not already on Cloudflare:

  1. Log in to dash.cloudflare.com.
  2. Click Add a site and enter your domain.
  3. Select the Free plan.
  4. Cloudflare will provide two nameservers. Go to your domain registrar and update the nameservers to point to Cloudflare’s.
  5. Wait for DNS propagation (usually under an hour, sometimes up to 24 hours).

Create a Cloudflare Tunnel

  1. In the Cloudflare dashboard, click Zero Trust in the left sidebar.
  2. Navigate to Networks > Connectors.
  3. Under Cloudflare Tunnels, click Create a tunnel.
  4. Select Cloudflared as the connector type.
  5. Name your tunnel (e.g., “matrix-pi” or your chapter name).
  6. On the install page, Cloudflare will show you a command with a tunnel token. Copy the token – it is the long string that starts with eyJ.... You will need it in the next step.

Do not add published application routes yet. You will do that after Synapse and Element are running.

Deploy Cloudflared on the Pi

Create the directory and compose file:

mkdir -p ~/docker/cloudflared

Create the Docker Compose file at ~/docker/cloudflared/docker-compose.yml:

services:
  cloudflared:
    container_name: cloudflared
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel run
    environment:
      - TUNNEL_TOKEN=your-tunnel-token-here

Replace your-tunnel-token-here with the token you copied from the Cloudflare dashboard.

Start the tunnel:

cd ~/docker/cloudflared && docker compose up -d

Verify the tunnel is connected:

docker logs cloudflared

You should see a line indicating the tunnel is connected and has registered connectors. In the Cloudflare dashboard, the tunnel status should show as Healthy.

Part 3: Deploy Synapse

Synapse is the Matrix homeserver. It handles user accounts, room state, message storage, federation with other servers, and the Matrix client-server API.

Generate the Configuration

Create the directory structure:

mkdir -p ~/docker/matrix/data

Generate the initial Synapse configuration:

docker run --rm \
  -v ~/docker/matrix/data:/data \
  -e SYNAPSE_SERVER_NAME=yourdomain.org \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest generate

This creates homeserver.yaml and signing keys inside ~/docker/matrix/data/. The SYNAPSE_SERVER_NAME is your identity domain – this is the domain that appears in your Matrix IDs (@user:yourdomain.org). It is not the same as the subdomain where Synapse is accessible. Set it to your base domain.

Configure Synapse

Open the generated configuration file:

nano ~/docker/matrix/data/homeserver.yaml

The generated file contains secrets unique to your server: registration_shared_secret, macaroon_secret_key, form_secret, and signing_key_path. Do not delete or change those values. They were generated for your server and must stay.

Replace the rest of the configuration with the following hardened template. Keep your generated secrets and signing key path intact – the template marks where they go:

server_name: "yourdomain.org"
pid_file: /data/homeserver.pid
public_baseurl: "https://matrix.yourdomain.org"

listeners:
  - port: 8008
    tls: false
    type: http
    x_forwarded: true
    bind_addresses: ['0.0.0.0']
    resources:
      - names: [client, federation]
        compress: false

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

log_config: "/data/yourdomain.org.log.config"
# NOTE: The log_config filename above must match the file that was
# generated in your data directory. Replace "yourdomain.org" with the
# same domain you used in SYNAPSE_SERVER_NAME during the generate step.
# Check: ls ~/docker/matrix/data/*.log.config

media_store_path: /data/media_store
max_upload_size: 25M

# --- Keep your generated secrets below ---
registration_shared_secret: "YOUR_GENERATED_SECRET_HERE"
macaroon_secret_key: "YOUR_GENERATED_SECRET_HERE"
form_secret: "YOUR_GENERATED_SECRET_HERE"
signing_key_path: "/data/yourdomain.org.signing.key"
# --- End generated secrets ---

# Registration: closed by default, tokens required when enabled
enable_registration: false
registration_requires_token: true

# Federation
federation_enabled: true
serve_server_wellknown: true

# Telemetry
report_stats: false

# Trusted key servers
trusted_key_servers:
  - server_name: "matrix.org"

# Rate limiting
rc_message:
  per_second: 0.2
  burst_count: 10

rc_registration:
  per_second: 0.05
  burst_count: 3

rc_login:
  address:
    per_second: 0.1
    burst_count: 3
  account:
    per_second: 0.1
    burst_count: 3

# URL previews
url_preview_enabled: true
url_preview_ip_range_blacklist:
  - '127.0.0.0/8'
  - '10.0.0.0/8'
  - '172.16.0.0/12'
  - '192.168.0.0/16'
  - '100.64.0.0/10'
  - '169.254.0.0/16'
  - '::1/128'
  - 'fe80::/10'
  - 'fc00::/7'

# Suppress key share warnings
suppress_key_server_warning: true

A few notes on what these settings do:

server_name vs public_baseurlserver_name is your identity domain. It appears in Matrix IDs like @user:yourdomain.org. public_baseurl is where the server is actually reachable. These are intentionally different: your identity is yourdomain.org, but the server lives at matrix.yourdomain.org. The serve_server_wellknown setting tells Synapse to advertise the correct server location so federation and clients can find it.

enable_registration: false with registration_requires_token: true – This means nobody can create an account on your server without a registration token that you generate. You control exactly who joins.

Rate limiting – Limits message sending to about one message every five seconds with short bursts, and throttles login and registration attempts to slow brute force attacks.

url_preview_ip_range_blacklist – When a user shares a link and Synapse generates a preview, this prevents Synapse from making requests to internal network addresses. Without this, a malicious link could probe your LAN.

Save the file and exit (Ctrl+X, then Y, then Enter in nano).

Fix Permissions

Synapse runs as UID 991 inside the container. The data directory must be owned by this UID before the container starts, or Synapse will fail to write to its database and media store.

sudo chown -R 991:991 ~/docker/matrix/data

Create the Docker Compose File

Create ~/docker/matrix/docker-compose.yml:

services:
  synapse:
    container_name: synapse
    image: matrixdotorg/synapse:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8008:8008"
    volumes:
      - ./data:/data
    environment:
      - TZ=America/New_York

Change the timezone (TZ) to match your location. Use the standard tz database format (e.g., America/Chicago, America/Denver, America/Los_Angeles).

The port binding 127.0.0.1:8008:8008 is critical. This binds Synapse to localhost only. It is not accessible from the network – only from the Pi itself and from the Cloudflare tunnel.

Start Synapse

cd ~/docker/matrix && docker compose up -d

Wait about 30 seconds for Synapse to initialize its database, then verify it is running:

curl http://localhost:8008/_matrix/client/versions

You should get a JSON response listing supported Matrix client versions. If you get a connection refused error, check the logs:

docker logs synapse --tail 50

The most common issue at this stage is a permissions problem on the data directory. If the logs mention permission errors, run the chown command from above again and restart the container:

sudo chown -R 991:991 ~/docker/matrix/data
docker restart synapse

Part 4: Deploy Element Web

Element Web is the browser-based Matrix client. It connects to your Synapse server and provides the chat interface your chapter members will use. Element is also available as a desktop and mobile app, and those apps can be pointed at your server. But hosting the web client means members can access chat from any device with a browser, with no installation required.

Create the Configuration

mkdir -p ~/docker/element-web

Create the Element Web configuration at ~/docker/element-web/config.json:

{
    "default_server_config": {
        "m.homeserver": {
            "base_url": "https://matrix.yourdomain.org",
            "server_name": "yourdomain.org"
        },
        "m.identity_server": {
            "base_url": "https://vector.im"
        }
    },
    "disable_custom_urls": true,
    "disable_guests": true,
    "disable_3pid_login": true,
    "brand": "Chapter Matrix",
    "default_theme": "dark",
    "room_directory": {
        "servers": ["yourdomain.org"]
    }
}

Replace yourdomain.org with your actual domain in both places. The base_url must match the public_baseurl you set in Synapse’s homeserver.yaml.

disable_custom_urls – Prevents users from pointing the client at a different server. Your Element instance is for your server only.

disable_guests – No anonymous browsing.

disable_3pid_login – Disables login via email or phone number. Members log in with their Matrix username and password.

Create the Docker Compose File

Create ~/docker/element-web/docker-compose.yml:

services:
  element-web:
    container_name: element-web
    image: vectorim/element-web:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./config.json:/app/config.json:ro

Like Synapse, Element Web binds to localhost only. The :ro flag mounts the config file as read-only inside the container.

Start Element Web

cd ~/docker/element-web && docker compose up -d

Verify it is running:

curl -s -o /dev/null -w '%{http_code}' http://localhost:8080

This should return 200.

Part 5: Connect the Tunnel

With Synapse and Element Web both running on localhost, the final step is to route internet traffic to them through your Cloudflare tunnel. The localhost URLs you enter in the next step tell Cloudflare where to send traffic on the Pi itself. Users on the internet will access your server through https://matrix.yourdomain.org and https://chat.yourdomain.org – Cloudflare handles TLS and routes the traffic through the tunnel to the correct container.

Add Published Application Routes

Go back to the Cloudflare Zero Trust dashboard:

  1. Click Zero Trust in the left sidebar, then navigate to Networks > Connectors.
  2. Click on your tunnel name.
  3. Click the Published application routes tab.
  4. Click Add a published application route and fill in the first entry:
    • Subdomain: matrix
    • Domain: select yourdomain.org from the dropdown
    • Path: leave blank
    • Type: HTTP
    • URL: localhost:8008
  5. Click Save.
  6. Go back and click Add a published application route again for the second entry:
    • Subdomain: chat
    • Domain: select yourdomain.org from the dropdown
    • Path: leave blank
    • Type: HTTP
    • URL: localhost:8080
  7. Click Save.

Cloudflare automatically creates DNS records for both subdomains and provisions TLS certificates. No DNS configuration needed on your part.

Important: get the ports right. Synapse (the server) is localhost:8008. Element Web (the chat client) is localhost:8080. If you swap them, the Matrix API will not respond correctly. Double-check before saving.

If you see a DNS conflict error (“An A, AAAA, or CNAME record with that host already exists”) when saving a route, it means you already have a DNS record for that subdomain. Go to the main Cloudflare dashboard > your domain > DNS > Records, delete the conflicting record, then come back and save the published application route. The route will create the correct DNS record automatically.

Restart the Tunnel

After saving both routes, restart cloudflared so it picks up the new configuration:

docker restart cloudflared

Verify the new routes are loaded by checking the logs:

docker logs cloudflared 2>&1 | grep "Updated to new configuration"

You should see a line showing both hostnames in the ingress configuration. It will look something like:

Updated to new configuration config="{"ingress":[{"hostname":"matrix.yourdomain.org",...,"service":"http://localhost:8008"},{"hostname":"chat.yourdomain.org",...,"service":"http://localhost:8080"},{"service":"http_status:404"}],...}"

If you only see one hostname, the other route did not save. Go back to the dashboard and add it.

Test External Access

From your computer (not the Pi), test that the server is reachable:

curl https://matrix.yourdomain.org/_matrix/client/versions

You should get a JSON response listing supported Matrix client versions – the same response you saw when testing locally. Then test the well-known endpoint:

curl https://matrix.yourdomain.org/.well-known/matrix/server

This should return {"m.server":"yourdomain.org:443"}.

If either test fails with a 404 from nginx or a connection timeout, check:

  1. The tunnel is healthy in the Cloudflare dashboard (Zero Trust > Networks > Connectors – status should show Healthy).
  2. The published application routes are correct (matrix -> localhost:8008, chat -> localhost:8080).
  3. Synapse is running: docker ps on the Pi should show the synapse container.
  4. The cloudflared logs show “Updated to new configuration” with both hostnames.

Open https://chat.yourdomain.org in a browser. You should see the Element Web login page. Do not try to log in yet – you have not created any user accounts.

Part 6: Create Admin, Space, and Rooms

Register the Admin Account

Synapse includes a command-line tool for creating user accounts. Create your admin account:

docker exec -it synapse register_new_matrix_user \
  -c /data/homeserver.yaml \
  -u admin -p 'YOUR_STRONG_PASSWORD' -a \
  http://localhost:8008

Replace YOUR_STRONG_PASSWORD with a strong password. The -a flag makes this account a server administrator. Use a password manager to generate and store the password.

The tool will confirm the account was created. You now have @admin:yourdomain.org as the server administrator.

Log In

Open https://chat.yourdomain.org in your browser and log in with the admin account you just created. Element will prompt you to set up encryption keys and cross-signing. Complete this process – it is what ensures end-to-end encryption works correctly for your account.

Save the recovery key Element provides. Store it in your password manager or write it down and keep it secure. If you lose access to all your devices and do not have the recovery key, you will lose access to your encrypted message history.

Create Your Chapter Space

Matrix uses Spaces to organize rooms, similar to how Discord uses servers or Slack uses workspaces. A Space is a container that groups your chapter’s rooms together. When you invite a member to the Space, they can see and join all the rooms inside it. This is how you should organize your chapter – one Space with rooms inside it, not standalone rooms floating on their own.

Create the chapter Space:

  1. Click the + icon next to Spaces in the left sidebar.
  2. Select Create a space.
  3. Choose Private – only people you invite can find or join it.
  4. Name it with your chapter name (e.g., “LFHI Upson County” or “LFHI Nashville”).
  5. Optionally add a description (e.g., “Chapter coordination and training”).
  6. Click Create.

Create Rooms Inside the Space

Element will prompt you to add rooms to your new Space. Create the following rooms. All rooms should be set to Private (invite only, not visible in room directory).

#general – The main room for chapter-wide coordination, meeting scheduling, and general discussion. All members join this room.

#leadership – Restricted to chapter leads, role holders, and LFHI liaisons. Use this for planning, sensitive coordination, and admin discussions that do not need to go to the full chapter.

#announcements – Admin-only posting (set room permissions so only admins can send messages, everyone else is read-only). Use this for meeting reminders, schedule changes, and official chapter communications. Members can read but not post.

To create each room inside the Space:

  1. Click on your Space name in the left sidebar.
  2. Click the + button or Add room option within the Space.
  3. Select Create a new room.
  4. Enter the room name, set it to Private, and make sure Add to [Space Name] is checked.
  5. Click Create room.

After creating all three rooms, set the permissions on #announcements:

  1. Open the #announcements room.
  2. Click the room name at the top to open room settings.
  3. Go to Roles & Permissions.
  4. Under Default role, set it to a level that cannot send messages (e.g., set “Send messages” to Moderator or Admin only).

Room Permissions Overview

Matrix rooms have three default power levels:

  • Default (0) – Regular members. Can read and send messages (unless restricted).
  • Moderator (50) – Can kick/ban users, delete messages, and manage room settings.
  • Admin (100) – Full control, including changing permissions and deleting the room.

For #general, leave default permissions as-is. All members can read and send messages.

For #leadership, leave default permissions as-is but only invite leadership members.

For #announcements, restrict “Send messages” to Moderator or Admin level. This makes it a broadcast channel – leadership posts, everyone reads.

You can add more rooms later as the chapter grows. Common additions: #training (scheduling and after-action), #comms (radio and technical), #medical (medical training coordination), #events (public outreach planning). Keep room count low to start. Two or three rooms are enough for most chapters in Phase 1. More rooms means more fragmented conversation.

Add Members

To create an account for a new member, SSH to your Pi and run:

docker exec -it synapse register_new_matrix_user \
  -c /data/homeserver.yaml \
  -u USERNAME -p 'TEMPORARY_PASSWORD' \
  http://localhost:8008

Replace USERNAME with their chosen username and TEMPORARY_PASSWORD with a temporary password. When asked “Make admin?”, answer no.

Then tell the member:

  1. Go to https://chat.yourdomain.org in a browser.
  2. Log in with the username and temporary password you gave them.
  3. Element will prompt them to set up encryption keys. Complete this step and save the recovery key somewhere safe.
  4. Click the avatar in the top-left corner, go to All Settings > Security & Privacy > Change Password and set a new password.

Once they are logged in, invite them to the chapter Space:

  1. Click on the Space name in the left sidebar.
  2. Click the Invite button (or the people icon).
  3. Enter their Matrix ID (@username:yourdomain.org).

They will see all rooms inside the Space once they accept the invite.

Part 7: Security Checklist

Before considering your server operational, confirm every item on this list:

  1. Registration disabled. enable_registration: false in homeserver.yaml. Nobody can create accounts without a token.
  2. Registration requires token. registration_requires_token: true in homeserver.yaml. Even if registration were enabled, a token is still required.
  3. Synapse listens on localhost only. The Docker port binding is 127.0.0.1:8008:8008. Run docker port synapse to confirm – it should show 127.0.0.1:8008.
  4. Element Web listens on localhost only. The Docker port binding is 127.0.0.1:8080:80. Run docker port element-web to confirm.
  5. No ports exposed to the internet. All inbound traffic routes through the Cloudflare tunnel. No port forwarding on your router. Run sudo ufw status to verify the firewall is active and only SSH from your LAN is allowed.
  6. URL preview blacklists internal networks. The url_preview_ip_range_blacklist in homeserver.yaml covers RFC 1918 ranges, link-local, CGNAT, and IPv6 private ranges.
  7. Rate limiting configured. Message, registration, and login rate limits are set in homeserver.yaml.
  8. No telemetry. report_stats: false in homeserver.yaml.
  9. UFW firewall active. SSH allowed from LAN only, all other inbound traffic denied.
  10. Docker containers auto-restart. All three compose files use restart: unless-stopped, so containers come back after a reboot or crash.

If any item is not in place, fix it before inviting members.

Part 8: Maintenance

Backups

The critical data lives in ~/docker/matrix/data/. This directory contains the SQLite database (all messages, room state, user accounts), media uploads, signing keys, and configuration. Back it up.

Weekly backup to an external drive or another machine:

# Stop Synapse to ensure database consistency
cd ~/docker/matrix && docker compose stop

# Create timestamped backup
mkdir -p ~/backups
tar czf ~/backups/matrix-$(date +%Y%m%d).tar.gz -C ~/docker/matrix data/

# Start Synapse again
cd ~/docker/matrix && docker compose start

Keep at least four weeks of backups. If you have an external USB drive or another machine on your network, copy backups there:

rsync -av ~/backups/ /mnt/usb/matrix-backups/

Stopping Synapse for the backup means a brief outage (usually under a minute). Schedule backups for a time when the chapter is unlikely to be active.

Updates

Update all three services by pulling the latest images and restarting:

cd ~/docker/matrix && docker compose pull && docker compose up -d
cd ~/docker/element-web && docker compose pull && docker compose up -d
cd ~/docker/cloudflared && docker compose pull && docker compose up -d

Check Synapse’s release notes before updating. Most updates are straightforward, but major version bumps occasionally require configuration changes or database migrations. Synapse handles SQLite migrations automatically on startup.

Update monthly or when security patches are released. After updating, verify the server is healthy:

curl http://localhost:8008/_matrix/client/versions
docker logs synapse --tail 20

Monitoring

Check that all containers are running:

docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

All three containers (synapse, element-web, cloudflared) should show “Up” with their respective uptimes.

Check Synapse logs for errors:

docker logs synapse --tail 50

Check disk usage:

df -h /
du -sh ~/docker/matrix/data/

Synapse’s database and media store will grow over time. On a 64GB card with a chapter of 20-30 members, you have plenty of room for years of use. If disk usage becomes a concern, Synapse’s admin API supports purging old room history and unused media.

Registration Tokens

Keep a record of tokens you generate, who they are for, and whether they have been used. The admin API shows all tokens and their usage status. Periodically clean up expired or unused tokens:

curl http://localhost:8008/_synapse/admin/v1/registration_tokens \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Space and Room Management

As admin, you can perform these common tasks through Element Web:

  • Invite members to the Space – Click the Space name, click Invite, enter their Matrix ID (@user:yourdomain.org). They will see all rooms inside the Space.
  • Add new rooms to the Space – Click the Space name, click Add Room, create or link an existing room. New rooms inherit the Space’s visibility.
  • Set room permissions – Room settings let you control who can send messages, invite members, change topics, and manage the room.
  • Remove members – Right-click a user in the member list and select “Remove from room” or “Ban.” Note: removing someone from the Space does not automatically remove them from rooms inside it. You need to remove them from each room individually. To fully remove a member, kick or ban them from each room they are in, then remove them from the Space.
  • Create sub-spaces – If your chapter grows large enough, you can create sub-spaces within the main Space (e.g., a “Training” sub-space with rooms for each training area). Most chapters will not need this.

Appendix A: Federation

Federation is what makes Matrix decentralized. When your server federates with another server, members on your server can join rooms on the other server, send direct messages to its users, and participate in shared spaces. The conversation data is replicated across both servers.

Your server federates by default. The serve_server_wellknown: true setting in homeserver.yaml tells Synapse to serve the .well-known/matrix/server endpoint, which tells other servers where to find yours. Since your traffic goes through a Cloudflare tunnel, federation works without opening any additional ports.

Testing Federation

After your server has been running for a few minutes, test federation using the Matrix Federation Tester:

  1. Open federationtester.matrix.org in your browser.
  2. Enter your server_name (yourdomain.org, not the matrix. subdomain).
  3. The tester will check .well-known delegation, TLS, and server connectivity.
  4. All checks should pass green.

If federation tests fail, verify that serve_server_wellknown: true is set in homeserver.yaml and that your Cloudflare tunnel is routing traffic to Synapse on port 8008. The .well-known endpoint is served by Synapse itself, so no separate configuration is needed.

Federating with Other Chapters

Once two chapter servers are both federated, members can interact across servers. To join a room on another chapter’s server:

  1. In Element Web, click + next to Rooms.
  2. Select Join a room.
  3. Enter the room address: #roomname:otherchapter.org.

The chapter lead of the other server will need to set the room to allow federation (it is allowed by default for invite-only rooms if you are invited).

Disabling Federation

If you want a fully isolated server with no outside connections, set the following in homeserver.yaml:

federation_domain_whitelist: []

This prevents your server from federating with any other server. Members can only communicate with other members on the same server. You can also selectively federate by listing specific domains in the whitelist.

Restart Synapse after changing federation settings:

cd ~/docker/matrix && docker compose restart

Appendix B: PostgreSQL Migration

SQLite is the right choice for chapter-size deployments. It requires no additional setup, no separate database server, and performs well for groups of up to about 50 active users. If your chapter grows beyond that or you notice performance degradation (slow room loads, delayed message delivery), consider migrating to PostgreSQL.

The migration path is:

  1. Add a PostgreSQL container to your Docker Compose setup.
  2. Create a synapse database and user in PostgreSQL.
  3. Run Synapse’s built-in synapse_port_db script to migrate data from SQLite to PostgreSQL.
  4. Update homeserver.yaml to point to the PostgreSQL database.
  5. Restart Synapse.

The full process is documented in the Synapse database migration guide. The migration script handles schema conversion automatically. Plan for downtime during the migration – the script needs exclusive access to both databases.

Do not start with PostgreSQL unless you have a specific reason. SQLite is simpler to back up (it is a single file), simpler to restore, and performs well within the expected scale of a chapter server.

Appendix C: Troubleshooting

Synapse Will Not Start

Check the logs first:

docker logs synapse --tail 50

Permission errors. The data directory must be owned by UID 991. Fix with:

sudo chown -R 991:991 ~/docker/matrix/data
docker restart synapse

YAML syntax errors. If you edited homeserver.yaml and introduced a syntax error, the logs will show the exact line. Common mistakes: missing colons, incorrect indentation, tabs instead of spaces (YAML requires spaces).

Port conflicts. If another service is already using port 8008, Synapse will fail to bind. Check with ss -tlnp | grep 8008. Stop the conflicting service or change Synapse’s port in both homeserver.yaml and docker-compose.yml.

Cannot Reach the Server Externally

If curl https://matrix.yourdomain.org/_matrix/client/versions fails from outside the Pi:

  1. Check the tunnel. Run docker logs cloudflared on the Pi. Look for “Registered tunnel connection” lines and “Updated to new configuration” showing both hostnames. If the token is wrong or expired, the logs will show authentication errors.
  2. Check DNS. Run nslookup matrix.yourdomain.org from your computer. It should resolve to a Cloudflare IP address. If it does not resolve, the published application route was not saved correctly or the DNS record was not auto-created.
  3. Check the published application routes. In Cloudflare Zero Trust > Networks > Connectors > your tunnel > Published application routes, verify both routes exist and point to localhost:8008 (matrix) and localhost:8080 (chat). A common mistake is swapping the ports – Synapse is 8008, Element Web is 8080.
  4. Check Synapse is running. docker ps should show the synapse container as running. curl http://localhost:8008/_matrix/client/versions from the Pi itself should return JSON.
  5. Restart cloudflared. After adding or editing published application routes, always restart the tunnel: docker restart cloudflared. Then check the logs to confirm the new configuration was loaded.

Element Web Shows “Connection Error”

This means Element loaded successfully but cannot reach Synapse.

  1. Check config.json. The base_url in ~/docker/element-web/config.json must match your Synapse public_baseurl exactly, including the scheme (https://) and subdomain (matrix.yourdomain.org). A mismatch here is the most common cause.
  2. Check Synapse is running. If Synapse crashed or is restarting, Element has nothing to connect to. Run docker ps and docker logs synapse --tail 20.
  3. Check CORS. Cloudflare tunnels handle CORS headers automatically. If you have added any custom headers in Cloudflare, ensure they are not interfering.

Federation Test Fails

If federationtester.matrix.org reports errors:

  1. Check serve_server_wellknown. Must be true in homeserver.yaml. Synapse serves the .well-known/matrix/server endpoint, which tells other servers that yourdomain.org delegates to matrix.yourdomain.org:443.
  2. Test .well-known directly. Run curl https://yourdomain.org/.well-known/matrix/server from your computer. If it does not return JSON with your server’s address, the well-known delegation is not working.
  3. DNS issues. Both yourdomain.org and matrix.yourdomain.org must resolve to Cloudflare IPs. The base domain needs a proxied DNS record (usually an A or CNAME for the root domain pointing to Cloudflare).

If your Cloudflare tunnel only has a public hostname for matrix.yourdomain.org and not the bare yourdomain.org, federation may fail because other servers try to fetch .well-known from the bare domain first. You can solve this by adding a third public hostname in the tunnel that routes yourdomain.org to localhost:8008 as well, or by creating a Cloudflare redirect rule that sends yourdomain.org/.well-known/* to matrix.yourdomain.org/.well-known/*.

Registration Token Not Working

If a member enters a registration token and gets an error:

  1. Verify registration_requires_token is true in homeserver.yaml. The token system requires this setting.
  2. Check the token is valid. List all tokens with the admin API and confirm the token has not already been used or expired.
  3. Check the member is entering it correctly. Tokens are case-sensitive. Send the token via Signal where the member can copy-paste it.

High Memory Usage

Synapse uses more memory during initial federation. When your server first connects to a federated room, it downloads the room’s state history. For large public rooms this can spike memory usage temporarily. On a Pi 5 with 4GB or 8GB of RAM, this is rarely a problem for chapter-size deployments with private rooms.

If you are concerned about memory:

free -h
docker stats --no-stream

Synapse typically uses 200-400MB of RAM for a small server. If it is consistently above 1GB with only chapter rooms, check for runaway federation with large public rooms and consider leaving those rooms.