Skip to content
Serverküche
Search

Loading search …

Applications Difficulty: Advanced

Matrix Part 2: Element Web, Federation and Day-to-Day Admin

Element Web next to Synapse on the same domain, device verification, encrypted rooms, invite tokens and the admin API for accounts, rooms and media.

· 14 min read ·Duration: approx. 60 minutes
Table of contents

The server from part 1 runs, federates and has exactly one account. Only nobody can see it. Now the interface joins in, and with it the questions that actually come up in daily use: how do friends get in without registration standing wide open? Who cleans up the rooms?

What are we building?

We put Element Web 1.12.33 next to Synapse 1.164.2, on the same domain; Traefik splits the requests by path. Afterwards you have a finished chat interface in the browser, an end-to-end encrypted room, a registration that only works with an invite token, and the admin moves for accounts, rooms and media.

This is part 2 of the Matrix series. Part 1 built the homeserver, the delegation and federation; this one is about everything that comes after.

Why one domain and not two

Two subdomains (matrix. for the server, chat. for Element) are the more common route and perfectly fine. We deliberately do it on just one domain here because that is the harder case: as soon as two services share a hostname, the order of the Traefik rules decides who gets a request, and that is exactly where it goes wrong in practice. If you have two subdomains, skip step 1 and leave out the priority lines in step 3.

Requirements

  • A running Matrix server from Matrix: Your Own Chat Server with Synapse (Part 1), including a green federation test
  • Debian 13 with Docker and a Traefik that fetches the certificates
  • The domain from part 1; the server_name stays unchanged
  • About 60 minutes, a good part of it for the first login
🍳 Recommendation Ad

VPS 2000 G12.5

8 vCores · 16 GB RAM · 256 GB SSD

from €26.92/month

Synapse grows from a good 120 to around 280 MB with the first large federated room. Element itself needs nothing – under 5 MB.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 2000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

Step by step

Step 1: How two services share one hostname

Synapse lays claim to three path prefixes and nothing else:

Ausgabe
/_matrix         # the client and federation API
/_synapse        # the admin API
/.well-known/matrix  # the delegation from part 1

Everything else (the start page, JavaScript, images, fonts) belongs to Element. Traefik can tell them apart if you tell it which rule is checked first. That is what priority is for: the higher the number, the earlier the rule is evaluated.

Ausgabe
Rule                                              priority   Result
Host(`YOUR_DOMAIN`) && PathPrefix(`/_matrix`)        100      Synapse
Host(`YOUR_DOMAIN`)                                   10      Element

Without priority, Traefik sorts by rule length and the longer one wins. That happens to give the right result here, but you cannot rely on it: it can change with every rule you add. Write the numbers down.

Step 2: Configure Element

Element Web is pure JavaScript in the browser; the entire configuration sits in a single config.json. Put it next to the compose.yaml:

Terminal
cd /opt/matrix
nano element-config.json
JSON
{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://YOUR_DOMAIN",
      "server_name": "YOUR_DOMAIN"
    }
  },
  "brand": "Kitchen Chat",
  "disable_custom_urls": true,
  "disable_guests": true,
  "disable_3pid_login": true,
  "default_country_code": "DE",
  "show_labs_settings": false,
  "room_directory": { "servers": ["YOUR_DOMAIN"] },
  "setting_defaults": {
    "breadcrumbs": true,
    "UIFeature.feedback": false,
    "UIFeature.registration": false
  }
}

The switches that matter:

  • disable_custom_urls: true: The sign-in mask shows your server fixed instead of an input field for arbitrary homeservers. Without it people accidentally sign in to matrix.org and wonder why their account does not exist.
  • disable_3pid_login and disable_guests: No login via email/phone (that would need an identity server) and no guest accounts.
  • UIFeature.registration: false: The “Create account” link disappears. We handle registration in step 7 via tokens, not via an open button.
  • show_labs_settings: false: Element has a whole drawer of unfinished features. On a server other people share, that drawer is better left shut.

The file has to be readable for the container:

Terminal
chmod 0644 element-config.json

If you hardened the server after Lynis

A umask 027 in /etc/login.defs, as suggested by the security audit with Lynis, creates new files with 0640. Element runs unprivileged inside the container and then cannot read the file; the container refuses to start with cp: can't open '/app/config.json': Permission denied. The chmod is therefore mandatory.

Step 3: Extend the stack

In the compose.yaml from part 1 we change Synapse’s Traefik rule and attach a second service:

YAML
# /opt/matrix/compose.yaml (excerpt)
  synapse:
    image: ghcr.io/element-hq/synapse:v1.164.2
    # ... unchanged ...
    labels:
      - traefik.enable=true
      - traefik.http.routers.synapse.rule=Host(`YOUR_DOMAIN`) && (PathPrefix(`/_matrix`) || PathPrefix(`/_synapse`) || PathPrefix(`/.well-known/matrix`))
      - traefik.http.routers.synapse.priority=100
      - traefik.http.routers.synapse.entrypoints=websecure
      - traefik.http.routers.synapse.tls.certresolver=le
      - traefik.http.services.synapse.loadbalancer.server.port=8008

  element:
    image: vectorim/element-web:v1.12.33
    container_name: element
    restart: unless-stopped
    volumes:
      - ./element-config.json:/app/config.json:ro
    networks: [proxy]
    labels:
      - traefik.enable=true
      - traefik.http.routers.element.rule=Host(`YOUR_DOMAIN`)
      - traefik.http.routers.element.priority=10
      - traefik.http.routers.element.entrypoints=websecure
      - traefik.http.routers.element.tls.certresolver=le
      - traefik.http.services.element.loadbalancer.server.port=80

Element sits only in the proxy network. It never talks to the database and not directly to Synapse either: the visitor’s browser makes the API calls, not the container. The internal network from part 1 stays reserved for the database.

Terminal
docker compose up -d
docker compose ps
Ausgabe
NAME        STATUS
element     Up 20 seconds (healthy)
matrix-db   Up 20 seconds (healthy)
synapse     Up 20 seconds (healthy)

Step 4: Counter-check both routes

Before you open a browser, check from the outside that the split really works. First the root, where Element has to answer:

Terminal
curl -s https://YOUR_DOMAIN/ | grep -o "<title>[^<]*</title>"
Ausgabe
<title>Element</title>

Then the configuration Element loads at startup:

Terminal
curl -s https://YOUR_DOMAIN/config.json | head -5
Ausgabe
{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://YOUR_DOMAIN",

And finally that Synapse kept its three paths:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_DOMAIN/_matrix/client/versions
curl -s https://YOUR_DOMAIN/.well-known/matrix/server
curl -s https://YOUR_DOMAIN/.well-known/matrix/client
Ausgabe
200
{"m.server":"YOUR_DOMAIN:443"}
{"m.homeserver":{"base_url":"https://YOUR_DOMAIN/"}}

If one of the three returns HTML instead of JSON, Element intercepted the request. Then the priority is wrong. If the first one returns JSON instead of HTML, it is the other way round.

Step 5: The first login and device verification

Open https://YOUR_DOMAIN/ in the browser. Because disable_custom_urls is set, your server is fixed in the mask; you only enter the username and password from part 1.

The Element Web sign-in mask with a fixed homeserver and the fields for username and password
Element only asks for username and password – the homeserver is fixed

On the very first device Element sets up encryption by itself, without asking: in the background it creates a cross-signing key that marks your devices as yours, and a key backup on the server. The server only stores encrypted copies of those; it cannot read them. All you see of it is a prompt in the top left:

Element after the first sign-in with the prompt Back up your chats and the buttons Dismiss and Continue
The prompt after the first login – carry on with Continue, not Dismiss

Do not dismiss the key backup prompt

The prompt “Back up your chats” says: “Your chats are backed up automatically. You’ll need your recovery key to restore them if you lose all your devices.” So the backup is already running, but without a recovery key you will never get at it again.

Take Continue. Element opens the settings under “Encryption”; there “Get recovery key” and “Continue” lead to “Save your recovery key somewhere safe” with the key. It belongs in your password manager. Click “Dismiss” instead and you have none.

That only surfaces when you sign in on another device. Element then asks with “Confirm your digital identity”: “Use another device” or “Use recovery key”. Without a key the second option is greyed out, and once all old devices are gone only “Can’t confirm?” remains, which resets your identity, and the history that lives only on the server can no longer be deciphered. Not even by you as the server administrator: a pg_dump saves the messages, not the keys for them.

Step 6: The first encrypted room

Via “New room” you create a room. Two settings decide things here that are fixed later on:

The dialog Create a private room with a filled-in name, the selection Invite only and end-to-end encryption enabled
Invite only can be changed later, encryption cannot

  • “Invite only”: The visibility can be changed later.
  • “Enable end-to-end encryption”: That one cannot be taken back. Element says so as well: “You can’t disable this later. Bridges & most bots won’t work yet.” An encrypted room is unusable for most bridges to other networks and for many bots. If you are planning a Telegram or IRC bridge later, create an unencrypted room for it.

After that the room is there, and the first message goes out:

An encrypted Matrix room in Element with the notice Encryption enabled and a first message in the timeline
The finished room: Encryption enabled sits as a system message at the start of the timeline

Through the admin API you can counter-check that the server really records the room as encrypted:

Terminal
curl -s "https://YOUR_DOMAIN/_synapse/admin/v1/rooms?limit=5" \
  -H "Authorization: Bearer YOUR_TOKEN"
Ausgabe
name: Cooking night | members: 1 | encryption: m.megolm.v1.aes-sha2

m.megolm.v1.aes-sha2 is Matrix’s group encryption scheme. If it says null there, the room is readable in the clear, for the server and for anyone who gets hold of the database.

Step 7: Let friends in without opening registration

Part 1 sets enable_registration: false. That is right as long as you are alone. Only you would have to create every further account by hand and send the password over chat. The better way are registration tokens: registration is open, but only with an invite code that you hand out.

YAML
# /opt/matrix/data/homeserver.yaml
enable_registration: true
registration_requires_token: true
Terminal
docker compose restart synapse

Now you create a token, here one that may be used three times:

Terminal
curl -s -X POST "https://YOUR_DOMAIN/_synapse/admin/v1/registration_tokens/new" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"uses_allowed": 3, "length": 12}'
Ausgabe
{"token":"LvM3cFXHXX7H","uses_allowed":3,"pending":0,"completed":0,"expiry_time":null}

That string is what you send your people. Element asks for it during registration. The easiest way to check that nothing works without a token is at the start of the registration flow:

Terminal
curl -s -X POST "https://YOUR_DOMAIN/_matrix/client/v3/register" \
  -H "Content-Type: application/json" -d '{}'
Ausgabe
{"session":"PklWEdRKlnhHbBVJrwJnwnBG",
 "flows":[{"stages":["m.login.registration_token","m.login.dummy"]}],"params":{}}

The server now demands two stages, and the first one is the token. Without it (or with a wrong one, in which case the server reports “Invalid registration token”) the flow ends without an account being created. After a successful registration you can see the usage:

Terminal
curl -s "https://YOUR_DOMAIN/_synapse/admin/v1/registration_tokens" \
  -H "Authorization: Bearer YOUR_TOKEN"
Ausgabe
{"registration_tokens":[{"token":"LvM3cFXHXX7H","uses_allowed":3,"pending":0,"completed":1,"expiry_time":null}]}

completed are finished registrations, pending are started ones. Once the token is used up it simply stops working; you do not have to switch anything off. With "expiry_time" (milliseconds since 1970) you can additionally give it an expiry date.

Step 8: Federation in daily use

The federation test from part 1 checks whether others can reach your server. The other direction, whether your server gets out, you check by asking about someone who only exists elsewhere:

Terminal
curl -s "https://YOUR_DOMAIN/_matrix/client/v3/profile/@matthew:matrix.org" \
  -H "Authorization: Bearer YOUR_TOKEN"
Ausgabe
{"displayname":"Matthew","avatar_url":"mxc://matrix.org/oUxxDyzQOHdVDMxgwFzyCWEe"}

Your server cannot have made that answer up: it asked matrix.org, checked the signature and passed the result on. The same works with room addresses; the directory entry additionally tells you which servers sit in the room.

What that costs shows when you join a real public room:

Terminal
time curl -s -X POST "https://YOUR_DOMAIN/_matrix/client/v3/join/%23element-web:matrix.org" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{}'
Ausgabe
{"room_id":"!xUW4PpAe1CmThA3r2wI8IrgwwsK006-zqWdJCljpd10"}
real    0m2.221s

Two seconds. In the two minutes after that, while Synapse loads the room state (a good 5,000 state events), its memory climbs from around 120 MB to around 280 MB, the database from 15 to 63 MB. For a single room. That is the most important sentence about Matrix federation: your server keeps the complete state of every room you sit in, including the part that comes from foreign servers. A homeserver with 1 GB of RAM is therefore quickly finished once somebody joins three large rooms.

Room IDs have changed their format

The room ID above has no colon and no server name any more. From room version 12 onwards, room IDs are pure checksums without an origin. Old IDs of the form !abcdef:matrix.org stay valid. Scripts that rely on the colon do not.

Step 9: Day-to-day admin

There are three moves you need regularly. Retiring accounts. An account is never deleted but deactivated, because the user ID lives on in foreign rooms:

Terminal
curl -s -X POST "https://YOUR_DOMAIN/_synapse/admin/v1/deactivate/@NAME:YOUR_DOMAIN" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{"erase": true}'
Ausgabe
{"id_server_unbind_result":"success"}

erase: true additionally removes the display name and avatar. The user ID stays blocked and cannot be handed out again. That is on purpose, otherwise someone could take over the identity.

Getting rid of rooms you no longer need. First leave and forget, then purge from the server:

Terminal
ROOM='!YOUR_ROOM_ID'
curl -s -X DELETE "https://YOUR_DOMAIN/_synapse/admin/v2/rooms/$ROOM" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" -d '{"purge": true, "block": false}'
Ausgabe
{"delete_id":"qUcHmFcjfVZFfHWv"}

The call returns immediately, the cleanup runs in the background. You query the state at /_synapse/admin/v2/rooms/delete_status/DELETE_ID until it says "status":"complete". With "block": true you additionally block the room so nobody can join it again.

Cleaning up media. Every image from every federated room ends up as a copy on your disk:

Terminal
BEFORE=$(( ($(date +%s) - 2592000) * 1000 ))
curl -s -X POST "https://YOUR_DOMAIN/_synapse/admin/v1/media/YOUR_DOMAIN/delete?before_ts=$BEFORE" \
  -H "Authorization: Bearer YOUR_TOKEN"
Ausgabe
{"deleted_media":[],"total":0}

before_ts is a timestamp in milliseconds; the calculation above means “older than 30 days”. Forget the three zeros and the call deletes nothing, because the point in time lands in 1970.

The admin API does not belong on the open internet

Every call in this section hangs off a single token. Whoever has it can deactivate accounts and delete rooms. Restrict /_synapse/admin in the reverse proxy to your VPN or your fixed address; the pattern for that is in Make internal services reachable only over VPN.

When it doesn’t work

/_matrix/client/versions returns the Element start page instead of JSON. Both routers listen on the same host and Traefik checked the wrong one first. Set traefik.http.routers.synapse.priority=100 and …element.priority=10. Without explicit numbers Traefik sorts by rule length, and that result changes as soon as another rule joins. After the change docker compose up -d is enough; Traefik re-reads the labels straight away.

The Element container does not start and reports cp: can't open '/app/config.json': Permission denied. The element-config.json on the host is not readable for the container. This happens reliably on servers with umask 027, because new files then get 0640 and no read bit for others. chmod 0644 /opt/matrix/element-config.json and restart. The same applies to every configuration file you hand into a container as a bind mount.

Element hangs at “Confirm your digital identity”, and “Use recovery key” is greyed out. You are signing in on another device and did not create a recovery key on the first one (step 5). If an old device is still signed in, take “Use another device” and confirm there. Otherwise only “Can’t confirm?” followed by your account password remains: Element then resets the identity; account, contacts and rooms stay, but messages from before remain unreadable. Create the recovery key right afterwards.

Signing in suddenly fails with M_LIMIT_EXCEEDED. The answer contains retry_after_ms, the waiting time in milliseconds. Synapse rate-limits login attempts per account and per IP; that hits you when you build several sessions in a row or test with scripts. For permanent operation it can be loosened through the rc_login block in homeserver.yaml, only you should not, because that exact brake is what stops guessing attacks.

The device list and the logs show 172.x.x.x instead of the real visitor IP. This only affects requests over IPv6, which, on a domain with an AAAA record, is most of them. The proxy network is created with docker network create proxy in the Traefik tutorial, and that is an IPv4-only network. Docker then cannot rewrite an incoming IPv6 connection onto the container directly and sends it through a helper process that replaces the source address with the bridge gateway’s; over IPv4 the address is correct the whole time. The fix is a network with IPv6: docker compose down in every stack, then docker network rm proxy && docker network create --ipv6 --subnet fd00:cafe::/64 proxy and bring everything back up; the steps are in Reverse proxy with Traefik. Afterwards the real address shows up in X-Forwarded-For over IPv4 and IPv6.

Your problem is not listed? Search all error messages →

Maintenance & backups

You update Element and Synapse separately. Element Web is a collection of static files with no state of its own; an update is low-risk and takes seconds:

Terminal
cd /opt/matrix
sed -i 's/element-web:v1.12.33/element-web:vNEW_VERSION/' compose.yaml
docker compose pull element && docker compose up -d element
curl -s https://YOUR_DOMAIN/version

The last call prints the running version, for example 1.12.33. Element follows Synapse loosely; a version jump on the server does not break the interface. The reverse holds too, as long as you do not skip several years.

What has to go into the backup on top of part 1:

Ausgabe
/opt/matrix/element-config.json   # lives in no database

It is a small file, but it holds every decision from step 2. If it is lost, Element starts with the default configuration, and your people suddenly face an input field into which they can type any homeserver at all.

Registration tokens need hygiene. A token with uses_allowed: 3 and no expiry date is an invite code that stays valid for years. Look at the list every few months and delete what you no longer need:

Terminal
curl -s -X DELETE "https://YOUR_DOMAIN/_synapse/admin/v1/registration_tokens/THE_TOKEN" \
  -H "Authorization: Bearer YOUR_TOKEN"

Memory use is the actual maintenance task. The jump from around 120 to 280 MB for a single federated room scales onwards. Set yourself a reminder to look at docker stats --no-stream and du -sh /opt/matrix/data/media_store every few months, and leave rooms where none of you writes any more. That is more effective than any fine-tuning.

Realistically, Element itself creates no work: two or three updates a year. The time goes into the homeserver: a Synapse update every two weeks with a look at the release notes, a media and room cleanup once a quarter. Together roughly one hour a month.

You might also like