What you will set up
Matrix is an open chat network. Like email, anyone can run a server, and people on different servers talk to each other as if they were on the same one. Here, your server is two containers in one pod, under a user of its own called matrix:
- Synapse, the reference Matrix server,
- PostgreSQL, its database.
Caddy, set up in the Podman guide, handles HTTPS in front of it. Addresses look like @anna:example.com, while the server itself runs at matrix.example.com, so example.com can stay your website.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13. Matrix's federation tester passed over IPv4 and IPv6, a user on the test server joined a public room on matrix.org and received its messages, and the server came back after a reboot. With that room joined, Synapse and PostgreSQL used under 200 MB of memory. Voice and video calls need more, set up in the calls guide.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running;
- a domain, such as
example.com, with an A record and an AAAA record formatrix.example.compointing at your server; - a way to publish two small files on
https://example.com, either from this server's Caddy or from wherever your website runs (step 6).
The examples use example.com. Replace it with your own throughout.
1. Create the user
As root:
useradd -m -s /bin/bash matrix
loginctl enable-linger matrix
machinectl shell matrix@
Everything up to step 6 runs as matrix.
2. Generate Synapse's configuration
podman run --rm -v matrix-data:/data \
-e SYNAPSE_SERVER_NAME=example.com -e SYNAPSE_REPORT_STATS=no \
docker.io/matrixdotorg/synapse:latest generate
This creates a volume, matrix-data, holding Synapse's configuration, its secrets and the server's signing key, which is its identity on the Matrix network. The server name becomes part of every address and cannot be changed later, so choose it carefully.
3. Create the database password
pw=$(openssl rand -hex 24)
printf %s "$pw" | podman secret create matrix-db-password -
printf 'database:\n name: psycopg2\n args:\n user: synapse\n password: "%s"\n dbname: synapse\n host: 127.0.0.1\n' "$pw" | podman secret create synapse-database -
unset pw
The first secret goes to PostgreSQL. The second is the database section of Synapse's configuration, which Synapse reads as a file, so the password is not written into any of the files below.
4. Describe the pod
mkdir -p ~/.config/containers/systemd ~/matrix
Create ~/.config/containers/systemd/matrix.pod:
[Pod]
PodName=matrix
# Only Caddy, on this server, can reach Synapse: the port is not open to the internet.
PublishPort=127.0.0.1:8082:8008
[Install]
WantedBy=default.target
Create ~/.config/containers/systemd/matrix-db.container:
[Container]
ContainerName=matrix-db
Image=docker.io/library/postgres:18-alpine
Pod=matrix.pod
Volume=matrix-db:/var/lib/postgresql
Environment=POSTGRES_USER=synapse POSTGRES_DB=synapse
Environment="POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
Secret=matrix-db-password,type=env,target=POSTGRES_PASSWORD
AutoUpdate=registry
[Service]
Restart=always
Synapse requires a database with the C collation, which POSTGRES_INITDB_ARGS sets when the database is first created.
Create ~/.config/containers/systemd/matrix-synapse.container:
[Unit]
After=matrix-db.service
[Container]
ContainerName=matrix-synapse
Image=docker.io/matrixdotorg/synapse:latest
Pod=matrix.pod
Volume=matrix-data:/data
Volume=%h/matrix/synapse.yaml:/config/synapse.yaml:ro
Secret=synapse-database,type=mount,target=/config/database.yaml,uid=991,gid=991,mode=0400
Exec=run --config-path /data/homeserver.yaml --config-path /config/synapse.yaml --config-path /config/database.yaml
AutoUpdate=registry
[Service]
Restart=always
And ~/matrix/synapse.yaml, your own settings:
public_baseurl: https://matrix.example.com/
Synapse reads the three configuration files in order, and a setting in a later one replaces the same setting in an earlier one. That keeps your changes out of the generated file: here, the public address and the switch from the built-in SQLite database to PostgreSQL. Synapse is released every few weeks, and latest with AutoUpdate keeps it current; it updates its database by itself when it starts.
5. Start it
systemctl --user daemon-reload
systemctl --user start matrix-pod
podman logs -f matrix-synapse
When the log says Synapse now listening on TCP port 8008, press Ctrl+C. A warning just before it, that Synapse failed to listen on 0.0.0.0, is harmless: it listens on [::], which covers IPv4 too.
6. Put Caddy in front
Go back to root with exit, and switch to Caddy's user:
machinectl shell caddy@
Add this block to the end of ~/Caddyfile:
matrix.example.com {
reverse_proxy /_matrix/* 127.0.0.1:8082
reverse_proxy /_synapse/client/* 127.0.0.1:8082
}
Only the paths Matrix apps and other servers use are passed on, so Synapse's admin interface stays reachable from the server only.
Then the two files on https://example.com. They tell other servers and apps that the Matrix server for example.com is matrix.example.com. If example.com points at this server, add:
example.com {
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `{"m.server": "matrix.example.com:443"}`
respond /.well-known/matrix/client `{"m.homeserver": {"base_url": "https://matrix.example.com"}}`
}
If example.com already has a block in the Caddyfile, put the four lines inside that block instead: Caddy does not accept two blocks for the same address. If your website runs somewhere else, publish the same two files there, at /.well-known/matrix/server and /.well-known/matrix/client, with the same two headers.
Restart Caddy, which briefly interrupts every site it serves:
systemctl --user restart caddy
7. Create your account
Go back to root with exit, then to machinectl shell matrix@, and run:
podman exec -it matrix-synapse register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008
It asks for a user name and password, and whether the account is an admin. Nobody can sign up on the server by themselves, so create accounts for others the same way.
Log in with a Matrix app, such as Element from element.io: enter example.com as the server, and the app finds matrix.example.com by itself. In Element, private chats are end-to-end encrypted by default.
8. Check federation
Open https://federationtester.matrix.org, enter example.com and check that it reports success. Then, from your app, join a public room on another server. The first join of a large room can take a few minutes, as your server fetches the room's history.
9. Keep it up to date
Switch on Podman's daily updates for this user:
systemctl --user enable --now podman-auto-update.timer
10. Back up
mkdir -p ~/backup
podman exec matrix-db pg_dump -U synapse synapse > ~/backup/synapse-db.sql
podman volume export matrix-data --output ~/backup/matrix-data.tar
This saves the database, and the configuration, signing key and uploaded files in one archive. Copy ~/backup to another machine afterwards. Keep it private: the signing key lets anyone who has it speak as your server.
Troubleshooting
The federation tester reports a problem with .well-known. Open https://example.com/.well-known/matrix/server in a browser; it should show the JSON from step 6.
Synapse stops at start with a message about the database collation. The database was created without the C collation, and POSTGRES_INITDB_ARGS only takes effect on an empty volume. On a new server, stop the pod, delete the database with podman volume rm matrix-db and start again.
Synapse says its config file does not exist. Step 2 has not been run as this user, or the volume in step 2 and in matrix-synapse.container have different names.
Calls, and more
Voice and video calls need a call service of their own. The calls guide adds one to this server, for Element's apps and for older ones. Hermes Agent can join it as an AI assistant you chat with.
With your Matrix server on a Melonslab server, your accounts, rooms and messages are stored in Sweden, under Swedish and EU law, on a server you control.