Self-hosting Matrix Synapse with Docker Compose and Caddy

Discord recently announced that they're planning to start requiring all their users to upload photos of their faces or government ID for age verification. Their press release was somewhat incoherent in that they are seemingly trying to prevent their investors from fleeing by assuring them that every user will now need to be age verified, but also trying to prevent their users from fleeing by assuring them that very few users will now need to be age verified. This is the same company, by the way, which was hacked in 2025, resulting in approximately 70,000 government ID photos being leaked from one of their third-party age verification vendors. So now might be a really great time to start considering a non-enshittified, self-hosted alternative to Discord.

The field of alternatives to Discord is... somewhat rough. There's a couple of services which have the vague stink of corporate bullshit about them - Rocket.Chat, Zulip, and Mattermost. I really don't want to host anything that describes itself as "bringing together messaging, voice, video, critical apps, and AI to keep teams connected and missions protected". All these services give me the sense that they're suddenly and coincidentally going to go to a freemium model in the next twelve months.

Then there are a handful of services which seem to have sprung up quite recently, are intentionally designed to copy Discord, and have the vague stink of being written by very small teams of devs who are going to burn out and collapse in the next twelve months: Stoat, Fluxer, and Spacebar. Good luck to them all.

And then there's Matrix, which is an encrypted messaging protocol rather than a shiny all-in-one service. The company Element - founded by the same people who created the protocol - also produce user-facing clients, called Element and Element X, but there are other clients, like FluffyChat, and there are a number of protocol server implementations, among them Synapse.

Matrix reminds me a lot of ActivityPub: it's open source, it has a lot of devs, it's got a lot of funding (Element has raised a lot of VC investment money and contributes the vast majority of code to the protocol, but the protocol itself is governed by the independent Matrix.org Foundation, which means the spec can't be rug-pulled by the investors), it has a lot of users, and a lot of them are constantly unhappy with various things that Element does. Development is slow and uneven. Some features get prioritised, while others - for instance a GIF picker widget in Element - languish for years with lots of desperate user requests and little dev interest. It's all very familiar and comforting. All of these, to my mind, are the tell-tale signs of a well-oiled large open source project. So I decided to self-host Matrix for my friends.

Limitations - who is this for?

Like ActivityPub, Matrix has inbuilt federation - that is, users living on one server can join chatrooms and talk to users on other servers. One of the issues with Matrix when self-hosting is that to participate in a federated room, your server has to sync the full state of that room, including all the events and media shared in it. This can be very slow and resource-hungry on lower-end hardware, particularly for large public rooms. Because I only want to host a chatroom for my friends, I've disabled federation from my instance, meaning that all our chats are self-contained and very lightweight.

We also don't really use a lot of Discord features like granular roles, moderation, or intro channels, which Matrix doesn't have mature equivalents for. For what I wanted - a small chat server with a handful of rooms for a handful of friends - Matrix has proven easy, stable, and lean.

One tedious gotcha I've discovered: there appears to be a bug in Element where messages in a room created before a user joins will never appear for that user, even if the room's history visibility is set to allow new members to see old messages. This is disappointing, and there's no fix yet.

Prerequisites

I'm going to assume you already have a home server running Docker Compose with Caddy as a reverse proxy, and a domain name pointed at it. If you don't, I wrote about how I set up my home server a while back and it's still mostly the same setup. I'm going to use matrix.example.com as the domain throughout this guide - replace it with yours.

You'll also need to set up a DNS A record for your Matrix subdomain. If you're using Cloudflare, make sure the proxy is turned off (grey cloud, DNS only) - Cloudflare's proxy is known to cause issues with WebSocket connections, which Matrix clients rely on for real-time sync, apparently resulting in random disconnections that don't occur when connecting DNS-only.

Step 1: Create directories

mkdir -p synapse/data
mkdir -p synapse/postgres

Step 2: Generate the Synapse config

Run a one-off Docker container to generate the homeserver configuration:

docker run -it --rm \
  -v "$(pwd)/synapse/data:/data" \
  -e SYNAPSE_SERVER_NAME=matrix.example.com \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest generate

This creates synapse/data/homeserver.yaml and a signing key. The server_name is permanent - it can't be changed later without starting from scratch.

Step 3: Edit homeserver.yaml

Open synapse/data/homeserver.yaml. There are a few things to change.

Database

Comment out the default SQLite database and replace it with PostgreSQL:

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

Listener

Make sure the listener has x_forwarded: true so that Synapse correctly reads client IPs from behind Caddy. Don't set bind_addresses to 127.0.0.1 - that's for non-Docker installs and will prevent the reverse proxy from reaching the container.

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

Disable federation

If, like me, you just want a private server for friends:

federation_domain_whitelist: []

This whitelists no domains, effectively blocking all federation traffic.

Registration

Disable public registration. Set a shared secret you'll use to create accounts from the command line:

enable_registration: false
registration_shared_secret: "GENERATE_WITH_openssl_rand_-hex_32"

Optional tweaks

# Presence broadcasts online/offline/away status to other users in real
# time. It's nice to have but requires a surprising amount of CPU for
# constant status updates - and on a small server, you'll notice who's
# around anyway.
presence:
  enabled: false

# Limit uploads
max_upload_size: 50M

# Automatically and permanently delete media files older than these
# thresholds. The database records are kept (so messages still show
# that media was attached), but the files themselves are gone forever.
# Set these to something you're comfortable with.
media_retention:
  local_media_lifetime: 90d
  remote_media_lifetime: 14d

# Enable link previews. Synapse will fetch a thumbnail and summary for
# URLs shared in chat. The IP blacklist below is required - it prevents
# Synapse from being tricked into fetching URLs on your internal network
# (e.g. a malicious user posting a link to http://192.168.1.1/admin).
# These ranges cover localhost, private networks, and link-local addresses.
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'
  - 'fe80::/10'
  - 'fc00::/7'
  - '::1/128'

For details on all available configuration options, see the Synapse configuration manual.

Step 4: Fix permissions

The Synapse Docker image runs as UID/GID 991 by default, but the generated files are probably owned by root because root ran the setup command:

sudo chown -R 991:991 ./synapse/data/

Step 5: Add to docker-compose.yml

Add these two services to your existing docker-compose.yml (I'm also showing the Caddy service here for context - yours will already exist):

  caddy:
    image: caddy:alpine
    container_name: caddy
    restart: unless-stopped
    volumes:
      - ./caddy/data:/data:rw
      - ./caddy/www:/usr/share/caddy
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile
    ports:
      - 80:80
      - 443:443
    extra_hosts:
      - "host.docker.internal=host-gateway"
    networks:
      - caddy_network

  synapse-postgres:
    image: postgres:16-alpine
    container_name: synapse-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: synapse
      POSTGRES_USER: synapse
      POSTGRES_PASSWORD: YOUR_STRONG_PASSWORD
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
    volumes:
      - ./synapse/postgres:/var/lib/postgresql/data
    networks:
      - caddy_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U synapse"]
      interval: 10s
      timeout: 5s
      retries: 5

  synapse:
    image: matrixdotorg/synapse:latest
    container_name: synapse
    restart: unless-stopped
    depends_on:
      synapse-postgres:
        condition: service_healthy
    volumes:
      - ./synapse/data:/data
    ports:
      - 8008:8008
    networks:
      - caddy_network

The ports: 8008:8008 mapping on the Synapse container isn't strictly necessary for Caddy (which reaches Synapse via the Docker network), but it exposes the port to localhost, which is needed for the register_new_matrix_user command we'll use later to create accounts.

Make sure the POSTGRES_PASSWORD matches what you put in homeserver.yaml.

Step 6: Add to Caddyfile

matrix.example.com {
    reverse_proxy /_matrix/* synapse:8008
    reverse_proxy /_synapse/client/* synapse:8008
}

Caddy handles TLS automatically.

Step 7: Start everything

docker compose up -d synapse-postgres
# Wait a few seconds for Postgres to initialise
docker compose up -d synapse
# Caddy's hot-reload doesn't always pick up changes in a container:
docker compose restart caddy

Check the logs:

docker logs synapse --tail 20

You should see Synapse now listening on TCP port 8008 with no errors. You can verify it's working by visiting https://matrix.example.com/_matrix/client/versions in a browser - it should return a JSON blob of supported spec versions.

Step 8: Create your first user

docker exec -it synapse register_new_matrix_user \
  -c /data/homeserver.yaml \
  http://localhost:8008

Follow the prompts. Make your first user an admin.

Step 9: Sign in

Download Element X on your phone or Element X on your desktop, tap Sign in, change the account provider to https://matrix.example.com, and enter your username and password. You should now have access to your very own, self-hosted, encrypted chat service! Other/older Matrix clients may call this field "homeserver" rather than "account provider" - it's the same thing.

Creating accounts for friends

There's nothing lonelier and more indicative of the self-hosted lifestyle than a beautifully set up service without anybody else on it. Buck the trend. Force your friends to join the revolution. Since public registration is off, you create accounts for them with the same register_new_matrix_user command, then send them their username and password - they can change their password in their account settings afterwards.

More references