Deploy a Matrix Server

Step-by-step guide for chapters to stand up their own Matrix/Synapse communications server on a Raspberry Pi 5.

Overview

The LFHI homeserver provides a shared space for inter-chapter coordination across the network. Anyone with a Matrix account can join the LFHI Space directly, which gives access to all coordination rooms. That shared space is the starting point, not the end state. The goal is for every chapter to run its own Matrix server. Self-hosting is a core principle of the initiative. Your messages, member data, and encryption keys should live on hardware you control, not on someone else’s server.

Matrix is a decentralized, end-to-end encrypted communications protocol. Unlike Signal or Discord, Matrix lets you run your own server. No third party can read your conversations, shut down your server, or hand over your data. Matrix servers federate with each other, which means your chapter’s server can communicate directly with the LFHI homeserver and other chapter servers across the country.

What follows is a step-by-step deployment of 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.

The LFHI homeserver runs the reference implementation on this same stack. When you complete this guide, your chapter will have a private, encrypted communications server that federates with the LFHI homeserver and other chapters.

Prerequisites

Gather the following before you start:

  • 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. Select your Raspberry Pi device (Raspberry Pi 5).
  2. 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.
  3. Click Choose Storage and select your microSD card.
  4. Click Next. The Imager will ask if you want to apply OS customisation settings. Click Edit Settings to open the customisation panel:
    • General tab: Set hostname (e.g., matrix-pi or your chapter name), set username and password (do not use the default pi username), configure WiFi only if you are not using Ethernet, and set your locale.
    • Services tab: Enable SSH. Select “Use password authentication” or set up public key authentication if you are comfortable with it.
  5. Click Save, then confirm and click Yes to write the image. Wait for it 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 (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, 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: serve .well-known so other servers can find yours
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_baseurl. server_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 serve a .well-known/matrix/server response that advertises matrix.yourdomain.org:443 as the actual server location. Important: For federation to work, other servers must be able to reach this .well-known endpoint at https://yourdomain.org/.well-known/matrix/server (the bare domain). This requires your Cloudflare tunnel to also route the bare domain to Synapse (covered in Part 5).

enable_registration: false. This disables self-service registration entirely. Nobody can create an account through the registration API. All accounts are created manually using the register_new_matrix_user CLI tool (covered in Part 6). The registration_requires_token: true setting is a second layer of protection: if registration is ever enabled, users will still need a token that you generate before they can register. With registration disabled, the token setting has no effect, but it guards against accidentally enabling open registration.

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. Synapse 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. 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.
  8. Go back and click Add a published application route one more time for the bare domain (required for federation):
    • Subdomain: leave blank (or enter @ if required)
    • Domain: select yourdomain.org from the dropdown
    • Path: leave blank
    • Type: HTTP
    • URL: localhost:8008
  9. Click Save.

The third route sends traffic for yourdomain.org to Synapse. Federation lookups hit https://yourdomain.org/.well-known/matrix/server to find the actual server address. Without this route, other Matrix servers cannot discover yours and federation will fail.

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

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 all three 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"},{"hostname":"yourdomain.org",...,"service":"http://localhost:8008"},{"service":"http_status:404"}],...}"

If you do not see all three hostnames, the missing 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 from the bare domain (this is what other Matrix servers will query for federation):

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

This should return {"m.server":"matrix.yourdomain.org:443"}. This tells other servers to connect to matrix.yourdomain.org for federation. A connection error or 404 here means the bare domain tunnel route from step 8 is not working. Go back and verify it.

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 -a \
  http://localhost:8008

The tool will prompt you for a password. Enter a strong password generated by a password manager. The -a flag makes this account a server administrator. Do not pass the password with -p on the command line. Command-line arguments are visible in process listings and may be saved in shell history.

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: end-to-end encryption for your account depends on it.

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 without 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. Organize your chapter as 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 confirm 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.

Leave #general permissions as-is. All members can read and send messages. Leave #leadership 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 \
  http://localhost:8008

Replace USERNAME with their chosen username. The tool will prompt for a password. Enter 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 self-register through the registration API. All accounts are created manually with the register_new_matrix_user CLI tool.
  2. Token safeguard in place. registration_requires_token: true in homeserver.yaml. If registration is ever enabled, users will still need a token you generate before they can register.
  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 replicates 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 routes 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 (allowed by default for invite-only rooms if you are invited).

Disabling Federation

To run a fully isolated server with no outside connections, set the following in homeserver.yaml:

federation_domain_whitelist: []

This restricts outbound federation requests so your server will not contact any other server. For full isolation, also remove federation from the resources.names list in the listener configuration to stop accepting inbound federation requests. With both changes, members can only communicate with other members on the same server. You can also selectively federate by listing specific domains in the whitelist instead of an empty list.

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, confirm 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. It must return {"m.server":"matrix.yourdomain.org:443"}. If it does not, the well-known delegation is not working.
  3. Check the bare domain tunnel route. The bare domain (yourdomain.org) must route to localhost:8008 in your Cloudflare tunnel (Part 5, step 8). Without this route, other Matrix servers cannot reach the .well-known endpoint and federation will fail.
  4. DNS issues. Both yourdomain.org and matrix.yourdomain.org must resolve to Cloudflare IPs. The tunnel automatically creates DNS records for published hostnames.

Registration Token Not Working

If you have enabled self-service registration (enable_registration: true) with token-based access and a member enters a token but gets an error:

  1. Verify both settings. enable_registration: true and registration_requires_token: true must both be set in homeserver.yaml. If enable_registration is false, the registration endpoint is completely disabled and tokens have no effect.
  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.

To check 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.

Download

Microsoft Word format (.docx)

Download