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/postgresStep 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 generateThis 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: 10Listener
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: falseDisable 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_networkThe 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 caddyCheck the logs:
docker logs synapse --tail 20You 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:8008Follow 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.