Merovingian User Manual¶
This manual covers installing, configuring, running, and operating The Merovingian Matrix homeserver. It is written for server administrators and assumes basic familiarity with POSIX systems, networking, and the Matrix protocol.
Beta status. Merovingian is suitable for evaluation and testing. It is not yet production-ready. Do not deploy it as a production Matrix homeserver until the blocking items in
docs/todos/production-milestone.mdare closed.
For developer-oriented documentation — build internals, testing conventions, and
contribution rules — see docs/dev-environment.md and the
other docs linked from README.md.
Table of contents¶
- What Merovingian is
- System requirements
- Installation
- Verifying release artifacts
- Initial setup
- Configuration
- Database backends
- Running the server
- Reverse proxy
- User management
- Federation
- Media repository
- Logging and diagnostics
- Maintenance
- Troubleshooting
- Security checklist
What Merovingian is¶
Merovingian is a Matrix homeserver written in C++26 with a security-first design. It treats secure defaults, fail-closed validation, narrow trust boundaries, and operational visibility as primary requirements rather than later hardening passes.
Key design choices that affect operators:
- Reverse-proxy-first deployment. Client and federation listeners default to
loopback (
127.0.0.1:8008and127.0.0.1:8009) with TLS disabled andreverse_proxy=true. A reverse proxy owns public TLS and routes traffic to the loopback listeners. Public listeners must use TLS and setreverse_proxy=false; loopback cleartext without an explicit reverse-proxy declaration is rejected at startup. - Fail-closed configuration. Startup rejects unsafe or ambiguous settings before binding listeners, scaffolding the database, or launching federation workers.
- Out-of-process federation worker. When federation is enabled (the default),
all inbound federation traffic is handled by one or more separate
merovingian-fed-workerchild processes that communicate with the main process over an encrypted AF_UNIX channel. - Redaction-aware logs. Diagnostic logs and the audit log intentionally omit tokens, secrets, event content, and media bytes.
System requirements¶
Merovingian targets POSIX server platforms. See
docs/platform-support.md for the full support-tier
matrix.
Build toolchain (for source builds)¶
- Clang ≥ 18 or GCC ≥ 14 with a matching C++26 standard library
- Meson ≥ 1.1.0 and Ninja
- POSIX shell, Python (for
build.py), Perl, Bison, Flex, M4
Runtime dependencies¶
These are resolved from the operating-system package manager and receive normal OS security updates:
- OpenSSL
- LibSodium
- PostgreSQL libpq
- libcurl
- libpng / libjpeg-turbo (media)
SQLite, yyjson, and Catch2 are vendored as Meson subprojects and built from source-pinned wraps.
Library versions in the startup log¶
At startup, before it binds a listener (and in --dry-run), the server logs one
INFO line per library it runs:
Library libsodium 1.0.18 (dynamic, /lib/x86_64-linux-gnu/libsodium.so.23; built against 1.0.18)
Library SQLite 3.54.1 (static)
Thumbnail worker library libpng 1.6.43 (dynamic, /lib/x86_64-linux-gnu/libpng16.so.16; built against 1.6.43)
Thumbnail worker library libjpeg-turbo version not reported (dynamic, /lib/x86_64-linux-gnu/libturbojpeg.so.0; built against 2.1.5)
- static: compiled into the binary. It changes only when Merovingian is rebuilt, so a security fix in it needs a new Merovingian release.
- dynamic: a shared object the loader opened, named by its path. An OS package update replaces it under the binary at the next start. The line also gives the version the binary was built against; when the two differ, the library was updated after the build.
- The linkage is read from the dynamic loader at runtime (
dladdr(3)), not from the build configuration, so it is what the process actually runs. - libpng and libjpeg-turbo are linked only into the sandboxed thumbnail worker,
so the server runs the worker once to ask it. If thumbnailing is disabled, or
the worker cannot run, a
Thumbnail worker libraries not reported: <reason>line says so (a warning when thumbnailing is enabled). libjpeg-turbo's TurboJPEG API has no runtime version call, so its version is the one it was built against. - The federation worker logs its own libraries as
Federation worker library …, andmerovingian-thumbnail-worker --versionprints the worker's.
Minimum platform versions¶
| Platform | Minimum version |
|---|---|
| Ubuntu | 24.04 LTS |
| Debian | 13 (trixie) |
| Fedora | 40+ |
| RHEL-compatible | RHEL 10 / AlmaLinux 10 |
| OpenSUSE | Tumbleweed |
| FreeBSD | 14.1+ |
| OpenBSD | 7.6+ with the llvm package |
| NetBSD | 10+ with pkgsrc clang |
Older Linux distributions cannot run a glibc-dynamic build linked against newer libraries. Use the portable static Linux tarball (musl) for those hosts.
Resource guidance¶
- Small evaluation / single-user: 1 vCPU, 1 GiB RAM, SQLite backend.
- Small federation-enabled server: 2 vCPUs, 2 GiB RAM, PostgreSQL backend.
- Large federated rooms: Federation joins to rooms with tens of thousands of
members need extra RAM and a higher
security.federation.join_timeoutbudget (see Federation).
Installation¶
Install from a distro package (recommended)¶
Tier 1 and Tier 2 CI produces .deb, .rpm, .pkg, and .tgz packages for
Ubuntu, Debian, Fedora, RHEL/AlmaLinux, OpenSUSE, FreeBSD, OpenBSD, and NetBSD.
The rolling latest GitHub prerelease is rebuilt on every push to main.
# Debian/Ubuntu example
dpkg -i merovingian_0.10.38_amd64.deb
# Fedora/RHEL example
dnf install merovingian-0.10.38-1.fc40.x86_64.rpm
Packages create the merovingian system user and group, install the systemd
unit, and stage the example configuration at /etc/merovingian/
merovingian.conf.example.
Install the portable static Linux tarball¶
For older Linux distributions, use the musl-linked tarball. It has no glibc or runtime package dependencies.
tar xzf merovingian-0.10.38-linux-static-x86_64.tar.gz
cp merovingian-0.10.38-linux-static-x86_64/bin/merovingian-server /usr/local/bin/
cp merovingian-0.10.38-linux-static-x86_64/bin/merovingian-db-migrate /usr/local/bin/
cp merovingian-0.10.38-linux-static-x86_64/libexec/merovingian/merovingian-fed-worker \
/usr/local/libexec/merovingian/
The static tarball does not receive automatic dependency security updates; redeploy a new tarball to pick up OpenSSL/LibSodium/libcurl fixes.
Verifying release artifacts¶
Every release artifact is accompanied by a detached GPG signature (.asc) and a
SHA-256 checksum. Verification uses the maintainer signing key fingerprint
published in docs/release-process.md:
# Import the release signing key.
gpg --keyserver keys.openpgp.org --recv-keys 66DFCC50187C8E46B5ED85FD92A3A264F0A7BE20
# Download the tarball, signature, and checksum for your platform, then verify
the signature.
gpg --verify merovingian-0.10.38-linux-static-x86_64.tar.gz.asc \
merovingian-0.10.38-linux-static-x86_64.tar.gz
# Verify the checksum.
sha256sum -c merovingian-0.10.38-linux-static-x86_64.tar.gz.sha256
A valid signature reports a "Good signature" from The Merovingian Release
Signing Key. An invalid or missing signature, or a mismatched checksum, is a
distribution failure: do not install the artifact.
Build from source¶
The unified build script configures, compiles, and tests in one step:
sh scripts/setup-dev-env.sh # install toolchain and configure build dir
python build.py linux # or `python build.py wsl` on Windows
After a successful build, the binaries are at:
build/src/merovingian-serverbuild/src/merovingian-db-migratebuild/src/federation_worker/merovingian-fed-worker
For detailed platform-specific instructions, sanitizer builds, and packaging
commands, see docs/dev-environment.md.
Directory layout after installation¶
| Path | Purpose |
|---|---|
/etc/merovingian/ |
Configuration files and secret files (master key, DB URI, registration token). |
/var/lib/merovingian/ |
Database files (SQLite), runtime state, and federation queues. |
/var/log/merovingian/ |
Diagnostic log files when --log-file is used. |
/usr/libexec/merovingian/ |
Helper binaries (merovingian-fed-worker, thumbnail worker). |
/usr/bin/ |
User-facing binaries (merovingian-server, merovingian-db-migrate). |
Initial setup¶
1. Create the service user¶
Distro packages create this automatically. For a manual or static install:
groupadd -r merovingian
useradd -r -g merovingian -d /var/lib/merovingian \
-s /sbin/nologin -c "Merovingian homeserver" merovingian
install -d -o merovingian -g merovingian -m 0750 /var/lib/merovingian
install -d -o merovingian -g merovingian -m 0750 /var/log/merovingian
2. Copy and edit the example configuration¶
install -d -m 0755 /etc/merovingian
install -m 0644 config/merovingian.conf.example /etc/merovingian/merovingian.conf
The three values that must match your deployment are:
server.name=example.org
server.public_baseurl=https://matrix.example.org
server.trusted_proxies=127.0.0.1
3. Generate the master key¶
The master key encrypts the Ed25519 signing secret before it is stored in the database. Generate 32 random bytes and protect the file:
(umask 077; openssl rand -out /etc/merovingian/master-key 32)
chmod 0400 /etc/merovingian/master-key
chown merovingian:merovingian /etc/merovingian/master-key
Without a master key, fresh signing keys cannot be created; the server can only load legacy plaintext keys for migration.
4. Configure the database¶
See Database backends. For a quick evaluation, switch to SQLite by uncommenting the SQLite block and commenting out the PostgreSQL keys.
5. Validate the configuration¶
A clean validation exits with status 0. A rejected key, unsafe permission,
or missing secret produces a clear message and a non-zero exit code.
6. Create the first admin account¶
Run this shell as the service account so the password file has the required owner. Use a newly created private directory, not a predictable shared path:
umask 077
password_dir="$(mktemp -d)" || exit 1
chmod 0700 "$password_dir"
trap 'rm -f -- "$password_dir/password"; rmdir -- "$password_dir"' EXIT
openssl rand -base64 24 > "$password_dir/password"
chmod 0400 "$password_dir/password"
merovingian-server --config /etc/merovingian/merovingian.conf \
--bootstrap-admin alice \
--bootstrap-admin-password-file "$password_dir/password"
This creates @alice:<server.name> with admin privileges and then continues
running the server; it is not a one-shot command. The in-memory password buffer
is wiped immediately after the bootstrap attempt. Delete the file from another
service-account shell after the creation message, or stop the server to run the
cleanup trap. Public registration never grants admin privileges.
Configuration¶
The bootstrap configuration uses a simple key=value format. Full-line comments
start with #; inline trailing comments are not supported.
Format rules¶
- One
key=valuepair per line. - Blank lines are ignored.
- Lines beginning with
#are ignored. - Whitespace around keys and values is trimmed.
- Booleans must be exactly
trueorfalse. - Unsigned integers contain digits only.
- Lists are comma-separated.
- Duplicate keys, unknown keys, and malformed lines are rejected.
- Files larger than 1 MiB or lines longer than 4 KiB are rejected.
Configuration validation modes¶
# Validate without starting the server
merovingian-server --check-config /etc/merovingian/merovingian.conf
# Validate and print the startup summary, but do not bind listeners
merovingian-server --dry-run --config /etc/merovingian/merovingian.conf
# Plan whether a reload can be applied without a restart
merovingian-server --plan-config-reload current.conf next.conf
Bootstrap exit codes¶
| Code | Meaning |
|---|---|
0 |
Success, help, version, successful config check, or successful reload plan |
64 |
Usage error |
66 |
Config file open/read failure |
78 |
Config parse failure |
79 |
Config validation failure |
80 |
Runtime start error: hardening self-check refused start, runtime/federation-worker start failure, or --bootstrap-admin failure |
81 |
Listener error: listener bind failure or signal-handler setup failure |
Fail-closed startup¶
Startup rejects configuration before doing any runtime work — binding
listeners, scaffolding the database, or launching federation workers — when
parser or validation findings are present. --check-config <path> runs the
same file metadata, parser, validation, and secret-file permission checks,
but exits before the startup runtime summary.
Rejected cases include:
- unreadable or missing config path
- unsafe config file permissions, or unsafe existing secret file permissions
- oversized config file or config line
- duplicate, unknown, or malformed config keys
- invalid boolean or unsigned-integer values
- empty required server/listener/database values
- non-HTTPS public base URL
- malformed listener bind address
- a cleartext listener on a non-loopback interface
- a TLS listener without certificate/private-key paths, or a missing/unsafe configured certificate or private-key file
- open registration without a token requirement, or token-protected registration without a registration token file
- disabled default encryption for new rooms, or disabled direct-message encryption requirement
- invalid federation default policy, or deny-by-default federation without allowed servers
- malformed federation allowed/denied server entries
- disabled federation TLS validation or JSON signature verification
- missing private/loopback federation deny ranges
- invalid federation transaction size or remote timeout
- invalid media upload size
- disabled private-IP blocking for remote media fetches, or invalid media remote-fetch timeout
- trust-safety transport enabled without a policy server URL, a non-HTTPS policy server URL, or an invalid policy server timeout
- disabled sandboxed media decoding
- disabled token or event-content log redaction
Size and duration value formats¶
Byte-size values accept a positive bounded size with one of these suffixes:
B, KiB, MiB, GiB (e.g. security.media.max_upload_size=50MiB).
Values such as 0MiB, 50MB, -1MiB, 50 MiB, and unbounded are rejected.
Duration values accept a positive bounded duration with one of these
suffixes: s, m (e.g. security.federation.remote_timeout=60s). Values
such as 0s, 30, 30ms, and forever are rejected.
Configuration parameter reference¶
The complete, authoritative accepted-key list lives in
config/merovingian.conf.example. The
following sections describe each group and the situations in which you might
change them.
Server identity — server.*¶
| Key | Default | When to change |
|---|---|---|
server.name |
example.org |
Required. The Matrix server name used in user IDs and federation. Must match the host part served by your reverse proxy. The shipped config/merovingian.conf.example sets example.org. |
server.public_baseurl |
https://matrix.example.org |
Required. The HTTPS URL clients use. Must be HTTPS. |
server.trusted_proxies |
(empty) | Required behind a reverse proxy. Comma-separated list of proxy IPs whose X-Forwarded-For header is trusted for rate limiting. Without this, every client shares one per-IP bucket. The client is the rightmost X-Forwarded-For entry that is not itself a trusted proxy (ADR-0073), so a proxy may append to the header or overwrite it; list every proxy hop you control. The shipped example sets 127.0.0.1. |
CORS policy — server.cors.*¶
| Key | Default | When to change |
|---|---|---|
server.cors.allowed_origins |
* |
Change to an explicit origin list only when embedding web clients in a mixed-trust context. |
server.cors.allow_methods |
GET, POST, PUT, DELETE, OPTIONS |
Change if you add non-standard client methods. |
server.cors.allow_headers |
authorization, content-type |
Usually sufficient for Matrix clients. |
server.cors.allow_credentials |
false |
Set true only with an explicit origin list; * plus true is rejected by the parser. |
server.cors.max_age |
86400 |
Preflight cache lifetime in seconds. |
Your reverse proxy must not emit
Access-Control-Allow-*headers for/_matrix/traffic. Merovingian emits them itself; duplicate values cause browser clients to fail with CORS errors.
Wildcard * is the safe default for Matrix because clients authenticate with
Authorization: Bearer tokens, not browser cookies. The parser rejects
allow_credentials=true combined with a wildcard origin, since the CORS spec
forbids that combination. CORS is not hot-reloadable — a change to any
server.cors.* key requires a restart.
HTTP transport — server.http.*¶
Controls the main request pool and HTTP/1.1 persistent connections
(keep-alive). A worker thread of the request pool is only ever given a
connection that has a request to read: new connections before their first
byte, and kept-alive connections between requests, are held by one connection
dispatcher thread, which closes a new connection that sends nothing within
5 seconds and a kept-alive one that stays idle for keep_alive_idle_seconds.
One client address may hold at most a quarter of the workers at once
(request_threads / 4, at least one); its further requests wait until one of
its own requests finishes, while other clients are served.
| Key | Default | When to change |
|---|---|---|
server.http.request_threads |
32 |
Threads in the main request pool that serves every listener, 4..256. Raise for a busy server with many cores; each client address may use a quarter of them, and remote directory and media proxying at most half. |
server.http.keep_alive |
true |
Set false to restore strict one-request-per-connection behaviour (e.g. in front of a proxy that pools upstream connections itself). |
server.http.keep_alive_idle_seconds |
15 |
Idle window per kept-alive connection, seconds, 1..300. Raise for chatty API clients that re-use connections; lower to close idle connections sooner. |
server.http.keep_alive_max_connections |
256 |
Process-wide cap on connections parked awaiting a next request, 1..4096. Beyond the cap the server answers Connection: close. A parked connection holds no worker thread, so this bounds open descriptors and memory and need not match request_threads; raise it to let more clients re-use their connections. |
server.http.max_connections_per_ip |
256 |
Open connections one client may hold on the client and federation listeners, 1..65535. A further connection is closed at accept time, before a byte is read or a TLS handshake starts. Shared-address users also share this cap. |
server.http.ipv6_client_prefix_length |
64 |
Prefix length IPv6 clients are grouped by for the connection cap, the per-client worker share and the per-IP rate limiter, 1..128. 128 counts each address separately; a shorter prefix groups a whole allocation. |
The parser rejects pool sizes outside 4..256, idle windows outside 1..300
seconds, parked-connection caps outside 1..4096, per-IP caps outside 1..65535
and prefix lengths outside 1..128. These keys are read when the listeners start
and are not hot-reloadable — a change to any server.http.* key requires a
restart.
Fixed limits that are not configurable:
- a request head must arrive within 30 seconds, with no gap over 5 seconds;
- a request body may start slowly, but from 10 seconds into the body it must
have arrived at an average of at least 16 KiB/s, or the server answers
408and closes the connection; - a media upload larger than 1 MiB is read only if its request carries a
valid access token; otherwise the server answers
401(M_MISSING_TOKEN/M_UNKNOWN_TOKEN) without reading the body and closes the connection; - a connection is closed (
Connection: closeon the response) after 1 000 requests or one hour, and clients open a new one.
Behind a reverse proxy, every client arrives from the proxy's address, so
addresses listed in server.trusted_proxies are exempt from the per-IP
connection cap and from the per-client worker share. Limit connections and
concurrent requests per client at the proxy (for example nginx limit_conn);
the per-IP rate limiter still applies to the forwarded client address.
Operational budgets¶
All settings below require a restart, including the federation workers. The runtime copies these policies at startup; SIGHUP does not reconstruct their consumers. Existing config files keep their explicit values after upgrade: update those values or remove the overrides to use the defaults in 0.12.19.
Client traffic has generic/media/sync/federation defaults of 600/120/3000/3000 requests per minute per source address. Login and registration retain 20/minute, administrative traffic retains 30/minute, key/device refinements allow 120/minute and thumbnails 240/minute. Explicit client_rate_limits.per_ip, per_user and tier overrides retain precedence. Several users behind one address share its bucket; prefer narrow prefix overrides when a particular route needs more capacity.
server.client_api budgets bound room timelines, paging, scan work, sliding-sync state and transient sessions. A higher timeline maximum allows a larger client-requested limit; it does not make the client request more messages automatically. Search context can multiply work by page size, so tune both together. A bounded /sync room list is not a capacity guarantee for a user's entire room set.
Notification history retains the newest max_notifications_retained_per_user rows per user (default 1,000), including when push delivery is disabled. Raising retention preserves future rows; it cannot restore entries already pruned.
Six per-user caps bound end-to-end key material, filters, account data and push rules. A request that would take a user over a cap is refused with HTTP 400 M_TOO_LARGE and stores nothing from that request; stored rows are never evicted to make room, so a client at its cap must delete or consume entries first. max_one_time_keys_per_device limits the one-time keys held for one device on /keys/upload (key IDs the device already holds are not counted twice, and a refused upload stores no device, one-time or fallback keys). max_key_signatures_per_user limits the distinct (target user, target key) pairs a user has signed through /keys/signatures/upload. max_filters_per_user limits the filters stored through POST /user/{userId}/filter; a filter identical to one the user already stores (compared in canonical JSON form; new filters are stored in that form) returns the existing filter_id instead of storing a duplicate, even at the cap. max_global_account_data_per_user limits the global (non-room) account-data types a user stores through PUT /user/{userId}/account_data/{type}; updating a type the user already holds always succeeds, and room account data is not counted. max_room_account_data_per_room limits the account-data types a user stores for one room through PUT /user/{userId}/rooms/{roomId}/account_data/{type}; updating a held type always succeeds, the tags and read-marker APIs are never refused (their m.tag and m.fully_read rows still count), and the room must be one the user is or was a member of. The Matrix specification has no way to delete account data, so a user at either cap cannot make room: every new type is refused with M_TOO_LARGE until an operator raises the cap. max_push_rules_per_user limits the user-defined push rules (created through PUT /pushrules/global/{kind}/{ruleId}, counted across all five kinds) a user holds; a PUT that would create one more is refused with M_TOO_LARGE, updating an existing rule always succeeds, server-default rules are not counted, and deleting a rule makes room. Each device keeps one fallback key per algorithm, as the Matrix specification requires; a new fallback key replaces the previous one.
The HTTP generic body cap and the client API body cap both apply to ordinary client JSON requests; raise both if necessary. Federation /send instead uses security.federation.max_transaction_size. Media uploads retain authenticated admission and their media-specific cap. Head byte/count limits remain enforced before bodies. Sync admission is clamped to the configured sync worker count; the per-device and per-user caps cannot exceed global admission.
security.federation.backfill.max_state_ids and max_auth_chain_ids bound advertised lists, including events already in local storage. max_snapshot_events bounds their unique union. max_outbound_calls bounds general recovery calls, max_snapshot_outbound_calls bounds event/auth-chain materialisation, and max_total_outbound_calls plus timeout are shared across the entire incoming PDU recovery. The deadline prevents new work and shortens HTTP timeouts; synchronous discovery, verification or database work already in progress is not preempted. Failed requests consume calls too; remote timeouts are shortened to the remaining deadline. None of these controls bypass signature, hash, authorization or server-ACL checks.
The default 65,536-ID caps cover the supplied 51,463-state/47,596-auth-chain response, but not necessarily the network work to fetch every uncached event. Recovery may still fail on call/time/body caps or remote errors. An ordinary PDU that is still unresolved after recovery is kept in the deferred_pdus table (at most 100 per room, 1000 in total, 16 MiB of JSON) and retried with a back-off from 30 seconds to one hour until it is placed, 10 retries have failed or 24 hours have passed (audit event federation.pdu_deferral_expired); a PDU dropped before R03 is not restored.
State resolution has a separate policy under security.federation.state_resolution, used for local events and federated events alike. Per-group capacity defaults to 65,536, aggregate submitted entries to 131,072, distinct/conflicted keys to 65,536 and auth-walk visits to 131,072. The aggregate limit prevents group count from multiplying the allowed work unchecked. Single-parent state bookkeeping reads the parent's state without fork resolution; these resolver budgets apply when merging forks. Increasing ID recovery alone would leave a large stored snapshot unable to merge under the previous 10,000-entry resolver ceiling.
Pending-join limits apply per room, except pending_join_max_rooms, which is process-wide. Together the default room/byte caps permit up to 128MiB of queued JSON plus object overhead. Larger queue/cache/push settings increase memory and outbound work. Application-service timeouts and response caps apply to all its outbound request types; HTTPS/SSRF and registration-namespace checks remain mandatory.
server.client_api.*:
| Key suffix | Default | Valid range |
|---|---|---|
max_sync_rooms |
1000 |
1..10,000 |
max_sync_events_per_room |
100 |
1..1,000 |
max_search_events_scanned |
10000 |
1..1,000,000 |
max_messages_events_examined |
10000 |
1..1,000,000 |
max_messages_page_size |
500 |
1..1,000 |
max_context_events |
100 |
1..1,000 |
max_search_page_size |
100 |
1..1,000 |
max_search_context_events |
100 |
1..1,000 |
sliding_sync_max_timeline_limit |
100 |
1..1,000 |
sliding_sync_max_room_subscriptions |
256 |
1..4,096 |
sliding_sync_max_required_state_entries |
256 |
1..4,096 |
sliding_sync_connections_per_device |
16 |
1..64 |
max_registration_validation_sessions |
1024 |
1..65,536 |
max_registration_validation_sessions_per_remote |
16 |
1..256 |
max_uia_sessions |
2048 |
1..65,536 |
max_safety_report_rows |
1000 |
1..10,000 |
max_notifications_page_size |
1000 |
1..10,000 |
max_relations_page_size |
500 |
1..1,000 |
max_public_rooms_page_size |
1000 |
1..10,000 |
max_hierarchy_rooms |
1000 |
1..10,000 |
max_threads_page_size |
500 |
1..1,000 |
max_notifications_retained_per_user |
1000 |
1..100,000 |
max_to_device_messages_per_user_device |
10000 |
1..1,000,000 |
to_device_message_ttl_seconds |
604800 |
1..31,536,000 |
max_one_time_keys_per_device |
1000 |
1..100,000 |
max_key_signatures_per_user |
10000 |
1..1,000,000 |
max_filters_per_user |
1000 |
1..100,000 |
max_global_account_data_per_user |
1000 |
1..100,000 |
max_room_account_data_per_room |
100 |
1..100,000 |
max_push_rules_per_user |
200 |
1..10,000 |
max_body_size |
1MiB |
1 byte..64MiB |
server.http.*:
| Key suffix | Default | Valid range |
|---|---|---|
sync_threads |
128 |
4..512 |
sync_max_in_flight |
128 |
1..512 |
sync_max_per_user |
4 |
1..64 |
sync_max_per_device |
2 |
1..16 |
media_fetch_threads |
16 |
1..256 |
media_fetch_max_in_flight |
64 |
1..1,024 |
media_fetch_max_per_client |
8 |
1..256 |
max_start_line_bytes |
8192 |
1..8,192 |
max_header_bytes |
32768 |
1..65,536 |
max_header_count |
100 |
1..200 |
max_body_size |
1MiB |
1 byte..64MiB |
server.push.*:
| Key suffix | Default | Valid range |
|---|---|---|
max_pushers_per_delivery |
25 |
1..256 |
max_in_flight_deliveries |
256 |
1..4,096 |
max_pushers_per_user |
20 |
1..1,000 |
gateway_failure_threshold |
3 |
1..100 |
gateway_cooldown_seconds |
300 |
1..86,400 |
max_pushers_per_user caps the distinct pushers (app_id, pushkey) one user may
register: POST /pushers/set refuses a new one beyond it with 400 M_INVALID_PARAM;
updating or deleting an existing pusher is always allowed. A gateway host (its
case-folded host:port) that cannot be reached gateway_failure_threshold times in a
row (a DNS, connection, TLS or timeout failure) is not contacted for
gateway_cooldown_seconds; after that one delivery per cooldown is let through as a
probe until one reaches it, and deliveries skipped meanwhile are logged
(push.delivery.circuit_open) and retried like failed ones (below). Any HTTP answer, a 5xx or 429 included, counts as
reaching the host: a gateway shared by many users is never closed to all of them
because it keeps refusing one user's pusher. A gateway's rejected list removes only
the pushers whose pushkeys it names. OUT-3, ADR-0151.
The in-flight limit counts recipient batches, not whole events: gateways for one
recipient run sequentially in that recipient's task. For global limit G, each
recipient can hold at most G > 1 ? min(8, G - 1) : 1 batches. At either bound,
only that batch is dropped; dropped batches are not retried. At G=1, delivery
remains serial and there is no reserved parallel capacity for another recipient.
Use at least two slots for single-recipient isolation. Several recipients can
still collectively fill the global budget; this is not a fairness guarantee.
See ADR-0140.
A notification the gateway answers with an HTTP error status, or that could not reach the
gateway, is sent again 5 seconds later, then after 10, 20 and so on up to 10 minutes, and given up
after 8 retries, about 20 minutes after the first attempt (push.delivery.gave_up). One
push-retry thread sends retries; they never hold a request thread. At most 1,024 notifications
wait for a retry, 32 per recipient; past that a retry is dropped and logged
(push.delivery.retry_dropped). A pushkey the gateway rejects stops its retries along with its
pusher, and a retry whose pusher has been removed is not sent. Retries are kept in memory and do
not survive a restart. These limits are fixed. See
ADR-0206.
security.federation.backfill.*:
| Key suffix | Default | Valid range |
|---|---|---|
max_missing_events |
20 |
1..100 |
max_outbound_calls |
16 |
1..256 |
max_state_ids |
65536 |
1..262,144 |
max_auth_chain_ids |
65536 |
1..262,144 |
max_snapshot_events |
131072 |
1..524,288 |
max_snapshot_outbound_calls |
256 |
1..4,096 |
max_total_outbound_calls |
512 |
1..4,096 |
response_max_size |
16MiB |
1 byte..64MiB |
timeout |
45s |
1..300 seconds |
security.federation.*:
| Key suffix | Default | Valid range |
|---|---|---|
accepted_transaction_cache_entries |
50000 |
1..1,000,000 |
audit_event_cache_entries |
10000 |
1..1,000,000 |
key_resolution_cache_entries |
16384 |
1..1,000,000 |
bad_signature_cache_entries |
4096 |
1..1,000,000 |
pending_join_max_rooms |
32 |
1..256 |
pending_join_max_pdus |
32 |
1..4,096 |
outbound_queue_capacity |
4096 |
1..65,536 |
outbound_max_retries |
32 |
1..256 |
pending_join_max_size |
512KiB |
1 byte..16MiB |
bad_signature_per_ip_rate |
30/60s |
Positive N/Ws |
security.federation.query.*:
| Key suffix | Default | Valid range |
|---|---|---|
max_backfill_pdus |
500 |
1..10,000 |
max_missing_events_pdus |
100 |
1..1,000 |
max_missing_events_latest |
100 |
1..1,000 |
max_missing_events_traversal |
4096 |
1..100,000 |
security.federation.state_resolution.*:
| Key suffix | Default | Valid range |
|---|---|---|
max_state_groups |
1000 |
1..4,096 |
max_events_per_state_group |
65536 |
1..262,144 |
max_total_state_events |
131072 |
1..1,048,576 |
max_conflicted_state_keys |
65536 |
1..262,144 |
max_mainline_auth_chain_depth |
10000 |
1..100,000 |
max_auth_chain_walk_events |
131072 |
1..524,288 |
appservice.*:
| Key suffix | Default | Valid range |
|---|---|---|
connect_timeout_seconds |
10 |
1..120 |
total_timeout_seconds |
30 |
1..300 |
response_max_size |
16MiB |
1 byte..64MiB |
max_queued_events |
100000 |
1..10,000,000 |
Application-service connect timeout must not exceed its total timeout. Byte sizes accept the existing binary size syntax. Zero and unlimited values are rejected for these operational bounds. Matrix transaction maxima (50 PDUs/100 EDUs), event-format limits, cryptographic validity rules and parser/sandbox structural ceilings remain fixed. The defaults are deployment starting points, not a verified 100-user throughput promise.
TURN server — server.turn.*¶
VoIP clients request TURN relay credentials through
GET /_matrix/client/v3/voip/turnServer. When no TURN server is configured the
endpoint returns an empty JSON object so clients gracefully disable relay
support.
| Key | Default | When to change |
|---|---|---|
server.turn.server |
(empty) | TURN server URI advertised to clients, e.g. turn:turn.example.org:3478?transport=udp. Leave empty to keep the endpoint returning {}. |
server.turn.username |
(empty) | Static username issued to authenticated clients. Required when server.turn.server is set. |
server.turn.password |
(empty) | Static password issued alongside the username. Required when server.turn.server is set. |
server.turn.ttl_seconds |
86400 |
Credential lifetime advertised in the response. |
Security note. Shared-secret, time-limited TURN usernames are not yet implemented; the current implementation issues the configured static credentials to every authenticated client. Only supply credentials that are acceptable for all users of this homeserver, or run a TURN server that does not require authentication.
OIDC discovery — server.oidc.*¶
GET /_matrix/client/v1/auth_metadata (MSC2965) returns 404 M_UNRECOGNIZED
until server.oidc.enabled=true. Merovingian does not implement an OAuth 2.0
authorization server itself — these keys only describe an external one for
clients to discover.
| Key | Default | When to change |
|---|---|---|
server.oidc.enabled |
false |
Set true once the endpoints below point at a real OIDC provider. |
server.oidc.issuer |
(empty) | Required when enabled. Must be a valid HTTPS URL with no query or fragment. |
server.oidc.authorization_endpoint |
(empty) | The provider's authorization endpoint. |
server.oidc.token_endpoint |
(empty) | The provider's token endpoint. |
server.oidc.registration_endpoint |
(empty) | The provider's dynamic client registration endpoint, if supported. |
server.oidc.revocation_endpoint |
(empty) | The provider's token revocation endpoint, if supported. |
server.oidc.device_authorization_endpoint |
(empty) | The provider's device authorization endpoint, if supported. |
server.oidc.account_management_uri |
(empty) | A user-facing account management page, if the provider offers one. |
server.oidc.account_management_actions_supported |
(empty) | Comma-separated list of account-management actions the URI above supports. |
Identity Service API — server.identity_server.*¶
Used for 3PID (email/phone) invites, binds, unbinds, and requestToken
flows. With trusted_servers empty (the default) every operation that needs
an identity server fails closed rather than silently minting tokens locally.
Merovingian cannot send email or SMS itself, so a user can add an email address
or phone number to their account only through a trusted identity server: the
client asks for the validation token with id_server and id_access_token,
the identity server messages the user, and the address is added only once the
identity server confirms the user completed validation. Without a trusted
identity server the requestToken endpoints answer
M_THREEPID_MEDIUM_NOT_SUPPORTED and adding an email address or phone number
is not possible. 3PIDs are not used for login or password reset.
| Key | Default | When to change |
|---|---|---|
server.identity_server.trusted_servers |
(empty) | Comma-separated allow-list of identity server base URLs (must be HTTPS). A 3PID operation naming an id_server outside this list is refused. |
server.identity_server.default_server |
(empty) | Identity server used when a client omits id_server. Must be HTTPS and must also appear in trusted_servers. |
server.identity_server.allowed_bind_domains |
(empty) | Parsed and validated, but not currently enforced — no bind or requestToken path reads it (see ADR-0051), so setting it restricts nothing today. |
server.identity_server.connect_timeout_seconds |
10 |
Outbound connect timeout for identity server HTTP calls. |
server.identity_server.total_timeout_seconds |
30 |
Outbound total timeout for identity server HTTP calls. Must be >= connect_timeout_seconds. |
Push notifications — server.push.*¶
Push Gateway API delivery (Matrix v1.19 push-gateway-api / CS API
push-notifications module) is disabled by default — pushers can still be
registered via POST /pushers/set, but no notification is ever sent to a
gateway until server.push.enabled=true. This mirrors server.oidc.enabled's
pattern so merging this capability cannot start sending traffic to
client-supplied gateway URLs on upgrade.
| Key | Default | When to change |
|---|---|---|
server.push.enabled |
false |
Set true to actually deliver notifications to registered pushers' gateways. |
server.push.connect_timeout_seconds |
10 |
Outbound connect timeout when contacting a pusher's gateway URL (client-supplied, untrusted). |
server.push.total_timeout_seconds |
30 |
Outbound total timeout for a gateway push request. Must be >= connect_timeout_seconds. |
Email notifications — server.email.*¶
Email pushers (kind: "email" in POST /pushers/set) are refused unless
server.email.smtp_url is set; the section is independent of
server.push.enabled. Only an email address already added to the account as a
3PID can receive notification emails, and an email carries only an unread count,
never message content, room names or sender names. Every key requires a restart.
The libcurl the server links must support smtp/smtps; the server refuses to start with a
relay it cannot speak. RHEL 10, AlmaLinux 10 and Fedora install libcurl-minimal by default,
which has no SMTP: replace it with the full library (dnf swap libcurl-minimal libcurl) before
setting smtp_url. curl --version lists the protocols on its Protocols: line.
| Key | Default | When to change |
|---|---|---|
server.email.smtp_url |
empty | smtps://host[:port] (TLS from the first byte, port 465) or smtp://host[:port] (STARTTLS, port 587). No userinfo, path or query. Empty disables email pushers. |
server.email.require_tls |
true |
Set false only for a cleartext smtp:// relay at a loopback IP literal (127.0.0.0/8 or [::1], a local MTA) with no credentials. A name such as localhost does not qualify. |
server.email.from |
empty | Envelope sender and From: address, a plain local@domain. Required when smtp_url is set. |
server.email.username |
empty | SMTP AUTH user name (printable ASCII, no spaces, at most 256 bytes). Set together with password_file. |
server.email.password_file |
empty | Owner-only file holding the SMTP AUTH password, read for each email. Set together with username. |
server.email.connect_timeout_seconds |
10 |
Outbound connect timeout for the relay (1..300). |
server.email.total_timeout_seconds |
30 |
Outbound total timeout for one email (1..600). Must be >= connect_timeout_seconds. |
server.email.notification_delay_seconds |
600 |
Seconds after a notification before the email is sent (0..86,400). If the user has read everything by then, none is sent. |
server.email.notification_throttle_seconds |
3600 |
Least time between two emails to one address (0..604,800). |
SSO login — server.sso.*¶
m.login.sso is disabled by default; GET /login does not advertise it
and GET /login/sso/redirect[/{idpId}] returns 404 until server.sso.
enabled=true and both authorization_url and redirect_url_allowlist
are populated — a half-configured setup (e.g. enabled=true with an empty
allowlist) is rejected at config-parse time rather than shipping a flow
that advertises itself but then 400s every redirect. Merovingian does not
itself implement an external SSO protocol (CAS/SAML/OIDC) —
authorization_url points at that external system, and completing the
round trip into a usable login token is the job of the operator's own
external-IdP adapter calling homeserver::complete_sso_login; see
docs/auth-identity.md ("SSO login").
redirect_url_allowlist is a security-critical setting: a client's
redirectUrl query parameter on /login/sso/redirect is attacker
-controlled input, and every entry not covered by this allowlist is
rejected before any redirect (or later, a login token) is ever sent there —
see docs/threat-model.md ("Open redirect and login-token exfiltration via
SSO redirectUrl"). List HTTPS URL prefixes, not bare domains — an entry
matches any redirectUrl that starts with it.
identity_providers entries are configured with dotted keys of the form
server.sso.identity_providers.<idpId>.<field>, where <idpId> is the
opaque provider id (may itself contain dots, e.g.
com.example.idp.github) and <field> is one of name (required),
icon (optional mxc:// URI), or brand (optional).
| Key | Default | When to change |
|---|---|---|
server.sso.enabled |
false |
Set true once authorization_url and redirect_url_allowlist are configured. |
server.sso.authorization_url |
(empty) | Required when enabled. HTTPS entry point of the external SSO system /login/sso/redirect[/{idpId}] redirects the browser to. |
server.sso.redirect_url_allowlist |
(empty) | Required when enabled. Comma-separated HTTPS URL prefixes a client's redirectUrl must start with to be accepted. |
server.sso.identity_providers.<idpId>.name |
(empty) | Required per IdP. Human-readable name shown to the user. |
server.sso.identity_providers.<idpId>.icon |
(empty) | Optional mxc:// URI for the IdP's icon. |
server.sso.identity_providers.<idpId>.brand |
(empty) | Optional UI brand hint (see the spec's IdP brand registry). |
Listeners — listeners.client.* and listeners.federation.*¶
| Key | Default | When to change |
|---|---|---|
listeners.client.bind |
127.0.0.1:8008 |
Client-server API and media. |
listeners.client.tls |
false |
Set true only if binding to a public interface without a reverse proxy. |
listeners.client.reverse_proxy |
true |
Set false for a direct public TLS listener; must be true for loopback cleartext. |
listeners.client.tls_certificate_file |
(empty) | Required when tls=true. |
listeners.client.tls_private_key_file |
(empty) | Required when tls=true. |
listeners.federation.bind |
127.0.0.1:8009 |
Federation and key API. Must be separate from the client listener. |
listeners.federation.tls |
false |
Set true only for direct public federation without a proxy. |
listeners.federation.reverse_proxy |
true |
Set false for a direct public TLS listener; must be true for loopback cleartext. |
listeners.federation.tls_certificate_file |
(empty) | Required when federation TLS is enabled. |
listeners.federation.tls_private_key_file |
(empty) | Required when federation TLS is enabled. |
listeners.max_queued_connections |
1024 |
Accepted connections allowed to wait for their first request before new ones are refused. 0 disables the cap. |
listeners.max_queued_connections bounds how many accepted connections may be
waiting, at once, for their first request (they are held by the connection
dispatcher, not by worker threads). Without a cap a connection flood grows
that set until the process runs out of descriptors or memory. Past the cap a
new connection is closed immediately, which sheds load in the one way the
client can observe and retry. Raise it if legitimate bursts are being refused;
lower it to shed load sooner under a smaller memory budget. The long-poll
/sync pool's queue uses the same bound. The reload plan classifies this setting
as reloadable, though the live reload control path is not yet wired; restart the
server to apply a changed value today.
A listener with tls=false must bind to a loopback address (127.0.0.1,
localhost, ::1, or [::1]) and declare reverse_proxy=true, which is
Merovingian's default and matches a reverse-proxy deployment. A non-loopback
(public) listener must use TLS with reverse_proxy=false. When tls=true
both tls_certificate_file and tls_private_key_file must be set:
# Direct public client listener (no reverse proxy)
listeners.client.bind=0.0.0.0:8443
listeners.client.tls=true
listeners.client.reverse_proxy=false
listeners.client.tls_certificate_file=/etc/merovingian/client.pem
listeners.client.tls_private_key_file=/etc/merovingian/client.key
# Direct public federation listener (no reverse proxy)
listeners.federation.bind=0.0.0.0:8448
listeners.federation.tls=true
listeners.federation.reverse_proxy=false
listeners.federation.tls_certificate_file=/etc/merovingian/federation.pem
listeners.federation.tls_private_key_file=/etc/merovingian/federation.key
Configured certificate files must be regular, non-executable, and not
group/other-writable. Configured private-key files must additionally be
owner-only: a regular file (not a symlink) owned by the service user with mode
0400. The key is read through the descriptor that was checked, so it cannot be
swapped between the check and the load (ISO-7). Startup loads the certificate chain and private key, verifies
the key matches the certificate, and fails closed if OpenSSL rejects either
file. The client listener defaults to 127.0.0.1:8008; the federation
listener defaults to 127.0.0.1:8009 so a reverse proxy can own the public
federation port 8448.
Database — database.*¶
| Key | Default | When to change |
|---|---|---|
database.backend |
postgresql |
Set to sqlite for development/evaluation. Only postgresql and sqlite are accepted; only one backend line is allowed. |
database.uri_file |
/etc/merovingian/db-uri |
Path to an owner-only file containing the PostgreSQL URI. |
database.role |
runtime |
Use migration only with merovingian-db-migrate. |
database.migration_role |
(empty) | PostgreSQL role assumed for the DDL/migration phase only. Must be set together with database.runtime_role. |
database.runtime_role |
(empty) | PostgreSQL role assumed to serve requests, dropping DDL rights. Both empty issues no SET ROLE, leaving an existing single-role deployment unchanged. |
database.pool_size |
16 |
Database connections persisted writes keep and use at once; a write waits for a free one rather than opening another (DB-7, ADR-0154). Requires restart. |
database.sqlite_path |
/var/lib/merovingian/merovingian.sqlite3 |
Path for SQLite single-file store. Commented out in the shipped example, which defaults to PostgreSQL. The file itself must not be a symlink and is created 0600; missing directories are created 0700. Directories that already exist may be symlinks (for example /var/lib/merovingian pointing at a data volume). |
PostgreSQL TLS admission¶
Non-local PostgreSQL destinations require sslmode to be explicitly set to
verify-full in the connection options stored in the private URI file.
Omitted, empty or weaker supplied modes are rejected before connecting, even
when the parent has a secure PGSSLMODE default. The minimal federation worker
does not inherit libpq environment defaults. No insecure remote opt-out is
provided, and service-file indirection is rejected.
The existing local exemption covers Unix-socket destinations, literal
localhost and IPv4/IPv6 loopback addresses. Every effective host and
hostaddr entry must be local to use the exemption; an arbitrary DNS name is
not treated as local merely because it resolves to loopback.
For remote deployments, provision trusted certificate authorities and a server
certificate matching the configured host. Configure sslrootcert and any
required client-certificate options in the private connection options rather
than relying on parent-only environment variables. Trust and client-key files
must be accessible to the appropriate service process within its sandbox;
do not relax secret-file admission or forward the parent's environment to
work around a certificate-access failure. Admission tests do not establish
live TLS handshakes or worker certificate-file access. See
ADR-0138.
Registration — security.registration.*¶
| Key | Default | When to change |
|---|---|---|
security.registration.enabled |
false |
Set true to allow public self-registration. |
security.registration.require_token |
true |
Set false only on private test servers; open public registration without a token is rejected by the parser unless explicitly allowed. |
security.registration.token_file |
(empty) | Required when registration is enabled with require_token=true (the default). Owner-only file containing the registration token. The shipped example sets /etc/merovingian/registration-token. |
The token file is read on startup and should contain the registration token
on its first line. Treat it as a secret — owner-only, non-executable, outside
web roots, and rotated whenever it may have been shared too broadly. The
token is hashed with Argon2id at load time; only the hash is retained in
process memory and the plaintext is zeroised after hashing. Changing the
token-file path requires a restart. Successful public registration always
creates a normal user; admin accounts can only be created through
--bootstrap-admin.
At-rest secret protection — security.secrets.*¶
| Key | Default | When to change |
|---|---|---|
security.secrets.master_key_file |
(empty) | Path to the random master key file (32-byte minimum, 4096-byte maximum; all-zero material is refused). Required. The shipped example sets /etc/merovingian/master-key. |
The Ed25519 server signing secret is encrypted at rest with secret_box
(secretbox:v1:...) before being stored in the database, under a key derived
from this file.
A master key is required to start (0.12.5). The server will not write a signing secret it cannot encrypt, so with no usable master key it has no signing key and refuses to start:
start.rejected reason="no server signing key: set security.secrets.master_key_file
so the signing secret can be encrypted at rest"
The file must also be lockable into memory. If sodium_mlock() fails — normally
RLIMIT_MEMLOCK exhaustion — the key is refused rather than used from swappable
memory, and startup fails with the second form of that message. Raise
RLIMIT_MEMLOCK for the service (LimitMEMLOCK= in the systemd unit) or grant
CAP_IPC_LOCK.
Generate one with:
install -m 0400 -o merovingian -g merovingian /dev/null /etc/merovingian/master-key
head -c 32 /dev/urandom > /etc/merovingian/master-key
Secret files must be mode 0400 (0.12.5). Every path the server treats as a
secret — security.secrets.master_key_file, database.uri_file,
security.registration.token_file, and each listener's
tls_private_key_file — must be a regular, non-executable, owner-read-only file
owned by the service account. 0600 was accepted until 0.12.5 despite
docs/hardening.md documenting owner-read-only; the master key file itself
was not validated at all until 0.12.13. Upgrading servers need a one-time
chmod 0400 on each secret file, or startup is rejected with
The write bit matters because the service account is also the account a
compromised worker process runs as: a writable master key lets an attacker who
reaches code execution substitute a key of their own and then decrypt or re-sign
at will. The file is never written after provisioning, so 0400 costs nothing.
A server upgrading from an earlier version that already holds a legacy plaintext signing secret keeps reading it and continues to federate; configure a master key and rotate the signing key to re-encrypt the active secret under the at-rest format.
New access and refresh token issuance also requires a usable master key. Losing the file, changing it to an insecure mode/owner, or exhausting locked memory returns HTTP 503 rather than minting tokens with an unkeyed digest. Existing legacy validation formats are not a fallback for new issuance.
Token lifetimes — security.*_token_lifetime_ms¶
| Key | Default | When to change |
|---|---|---|
security.access_token_lifetime_ms |
3600000 (1 hour) |
Milliseconds, 0..2592000000 inclusive (30-day maximum); 0 disables expiry. |
security.refresh_token_lifetime_ms |
2592000000 (30 days) |
Milliseconds, 0..31536000000 inclusive (365-day maximum); 0 disables expiry. |
Configuration parsing and typed validation report field-specific findings for
negative or oversized lifetimes. These are project admission limits, not Matrix
protocol maxima. Use 0 explicitly to disable expiry; values are not clamped or
replaced, and existing persisted sessions are not rewritten. Direct typed runtime
snapshot/reload admission, checked clock arithmetic and broader stored-expiry
hydration are separate OPS-4 requirements, not established by these policy checks.
See ADR-0141.
For finite access lifetimes, /login and /refresh advertise expires_in_ms from
security.access_token_lifetime_ms; zero omits the optional field. A token past its
TTL is rejected even when its session is not
revoked — the request returns 401 M_UNKNOWN_TOKEN and the audit log records
access_token.rejected with reason token expired. Existing rows written
without an expiry remain valid, so upgrading does not invalidate legacy
sessions.
Failed-login throttle — security.login_throttle.*¶
| Key | Default | When to change |
|---|---|---|
security.login_throttle.max_failures_per_source |
5 |
Failed password logins for one account from one client source (and failed UIA password checks from one device) inside the window before that source is refused. 1 to 1000; 0 is rejected. |
security.login_throttle.max_failures_per_account |
50 |
Failed password logins for one account across all sources inside the window before every source is refused. 1 to 100000; must not be below the per-source value. Lower it on a small server where 50 guesses a quarter of an hour is too generous. |
security.login_throttle.window |
15m |
Counting window, and how long a source or account stays refused once it trips. 1s to 1440m (s and m suffixes only). |
A refused login returns 429 M_LIMIT_EXCEEDED with retry_after_ms, even for
the correct password. Because the per-source counter keys on the client source,
set server.trusted_proxies when running behind a reverse proxy: otherwise every
client shares the proxy's address and one person's failures lock out everyone's
logins for that account. The counters hold at most 100 000 entries each. All
three keys are reloadable. See docs/auth-identity.md "Failed-login throttle".
Encryption policy — security.encryption.*¶
These keys enforce the room encryption policy. The defaults require encryption for direct messages and private rooms and block unencrypted federated private rooms.
| Key | Default | When to change |
|---|---|---|
security.encryption.default_for_new_rooms |
true |
Disable only in constrained test environments. |
security.encryption.require_for_direct_messages |
true |
Should stay true in production. |
security.encryption.require_for_private_rooms |
true |
Should stay true in production. |
security.encryption.allow_unencrypted_public_rooms |
true |
Set false to require encryption even for public rooms. |
security.encryption.block_unencrypted_federated_private_rooms |
true |
Keep true to prevent E2EE bypass across federation. |
Federation policy — security.federation.*¶
| Key | Default | When to change |
|---|---|---|
security.federation.enabled |
true |
Set false to run a non-federating server. |
security.federation.default_policy |
allow |
Set deny for allow-list federation. Requires allowed_servers. |
security.federation.allowed_servers |
(empty) | Comma-separated server names allowed when default_policy=deny. |
security.federation.denied_servers |
(empty) | Comma-separated server names always blocked. |
security.federation.require_valid_tls |
true |
Disable only in controlled test labs. |
security.federation.verify_json_signatures |
true |
Disable only in controlled test labs. |
security.federation.deny_ip_ranges |
private/loopback ranges | IPv4/IPv6 literals or CIDRs blocked during remote discovery/fetching, including public ranges. Keep the defaults; built-in SSRF blocks cannot be disabled. Every entry must be a well-formed address or CIDR range (prefix no longer than the address): a malformed entry is a startup error, because the boundary would otherwise treat it as denying every address. |
security.federation.remote_timeout |
60s |
General outbound federation HTTP timeout. |
security.federation.max_transaction_size |
20MiB |
Inbound /send body cap, enforced at HTTP admission as well as federation parsing. Positive and at most 64MiB. |
Remote lookups that a client triggers (GET/POST /publicRooms?server= and a
room-alias lookup for a remote alias) are not governed by
remote_timeout alone: they are limited to 4 in flight at once across all
clients and 1 per client address, and end after at most 10 s,
whichever of that and remote_timeout is shorter. A request over the limit is
answered 429 M_LIMIT_EXCEEDED with retry_after_ms 1000 and a Retry-After
header; a lookup that runs out of time is answered 502. These limits are
fixed, not configuration keys: they exist to keep half of the request
pool free for requests that never leave this server. Behind a reverse proxy set
server.trusted_proxies, or every client shares one address and so one slot.
Federation join/leave budget — security.federation.join_*¶
| Key | Default | When to change |
|---|---|---|
security.federation.join_timeout |
180s |
Budget for make_join/send_join/make_leave/send_leave. |
security.federation.join_parallelism |
8 |
Concurrent make_join candidate probes. |
security.federation.join_race_deadline |
45s |
Overall wall-clock budget for the whole candidate race. |
security.federation.join_max_candidates |
20 |
Hard cap on via candidates raced. |
security.federation.join_state_key_parallelism |
100 |
Concurrent remote signing-key resolutions while verifying send_join state. |
security.federation.join_response_max_size |
64MiB |
Response body cap specifically for make_join/send_join. Requires restart. |
join_parallelism bounds concurrency, not total elapsed time. A room
with a large via candidate list races in batches of join_parallelism,
and with no overall bound the whole race could take
ceil(candidate_count / join_parallelism) * join_timeout — many minutes for
a large via list. join_race_deadline closes that gap: it is the overall
wall-clock budget for the entire race, independent of the per-candidate
join_timeout. When it elapses without a winner, join_room returns 502
immediately; candidates still in flight are parked in a background queue and
drained on shutdown. It cannot be disabled via config, only extended.
join_max_candidates caps how many via-derived candidates are actually
raced — every candidate is spawned as an OS thread immediately, throttled to
run by join_parallelism but not to spawn, so an unbounded via list
otherwise means unbounded upfront thread creation.
A send_join response's state array carries one m.room.member event per
room member, each signed by that member's home server; join_room verifies
every signature before the event enters the graph rather than trusting the
resident server's response wholesale. join_state_key_parallelism caps
concurrent remote signing-key resolutions for that verification pass —
distinct (sender_domain, key_id) pairs are deduplicated first, so it bounds
concurrent distinct home servers contacted, not concurrent events. Fast
join persists the room's own critical state (create, power levels, join
rules, history visibility, our own membership) and the auth chain
synchronously before join_room returns; the bulk of state — every other
member's m.room.member event — is verified and persisted by a background
task afterward, governed by the same parallelism cap. See
docs/threat-model.md for the partial-state trade-off
this implies.
join_response_max_size exists because a send_join response embeds the
room's full current state as a single HTTP response body, which for a large
room routinely exceeds the general 16 MiB response cap every other
federation call uses. It also sizes the federation-worker IPC channel's
frame budget, which is fixed for the worker process's lifetime — raising
this value takes effect only after both the main process and the worker
restart.
Federation inbound abuse controls — security.federation.*¶
Inbound /send/{txnId} transactions are authenticated by X-Matrix request
signatures before buckets are charged. Buckets are keyed by the verified
remote origin, not the event sender ID, because a remote server can relay
events for many users and an abusive remote can rotate sender IDs.
| Key | Default | When to change |
|---|---|---|
security.federation.max_transaction_pdus |
50 |
Hard cap on PDUs per inbound /send transaction. Matrix v1.19 caps this at 50; higher values are rejected. |
security.federation.max_transaction_edus |
100 |
Hard cap on EDUs per inbound /send transaction. Matrix v1.19 caps this at 100; higher values are rejected. |
security.federation.per_origin_transaction_rate |
600/60s |
Maximum accepted /send transactions per verified remote origin per window. |
security.federation.per_origin_pdu_rate |
6000/60s |
Weighted PDU budget per verified remote origin per window — a transaction with 40 PDUs consumes 40 units. |
security.federation.per_origin_edu_rate |
12000/60s |
Weighted EDU budget per verified remote origin per window. |
security.federation.per_origin_request_rate |
3000/60s |
Per-origin cap on inbound federation requests outside /send (query, backfill, membership, key and state endpoints). /send counts only against the weighted trio above, so a transaction and its contents are never double-counted. |
| security.federation.key_resolution_per_ip_rate | 30/60s | Per-source-IP cap on network-capable origin and relayed-PDU sender-key resolutions. Direct verification-key cache reads do not spend it. Each federation worker shard gets this divided by federation.worker.shards (at least 1), so all shards together stay within it (FED-10, ADR-0153). Requires restart. |
| security.federation.key_resolution_max_in_flight | 16 | Cap on concurrent key resolutions; over it, requests are rejected rather than queued. Main has the whole cap; each federation worker shard gets it divided by federation.worker.shards (at least 1) (FED-10, ADR-0153). Requires restart. |
| security.federation.key_resolution_failure_ttl | 60s | How long a failed (server_name, key_id) resolution is remembered; includes relayed PDU senders. 0s disables. Admission overload is not a failed lookup. Requires restart. |
The three key_resolution_* keys bound network-capable resolutions both
before request authentication and after authentication when a relay names
other PDU sender domains. Resolving a peer's signing key necessarily precedes
verifying the signature that key is for, so an unauthenticated sender can name
any origin in an X-Matrix header and cause .well-known + SRV + DNS discovery
plus an outbound GET /_matrix/key/v2/server against that host — load here, and
a reflection vector at whoever was named. They are keyed on the source IP rather
than the origin because the origin is the field an attacker varies to miss every
cache. Setting default_policy=deny with a populated allowed_servers list
closes the same hole independently, since the policy check runs first.
The same source budget, runtime-wide cap and (server_name, key_id) negative
cache apply to relayed sender-key resolution, including parallel pre-resolution
and authorization fallbacks (FED-10). Cached verification uses a separate read
that performs no DNS or HTTP; it remains available with exhausted outbound limits.
Known-peer rotation can read another fresh cached key while preserving discovery
state. A previously unknown origin still needs an admitted discovery-bearing
resolution even if its persistent key is fresh. Local sender/authorising-key
admission overload returns 503 for /send, not a normal accepted per-event
response. The original transaction remains retryable and its EDUs are deferred;
local denial does not penalize or reset relay trust. Actual remote failures
retain their penalties. Direct membership/invite requests likewise return 503
before acceptance. See ADR-0133.
Main's PDU, membership and invite relay re-verification uses those same controls.
All main relay misses share one unknown-source bucket: no validated client address
is available in these frames, and main does not trust a worker-provided address.
This can defer legitimate cold-key traffic across otherwise unrelated clients.
Matching cached keys still work without a full resolver and still require a valid
sender signature and event-time key validity. Main denial returns the existing
PDU overload status or unaccepted 503 for membership/invites, before mutation.
See ADR-0134.
Backfill/snapshot/auth-chain verification now uses the same admitted key-only
helper and shared unknown-source bucket. Local key denial stops further recovery
and defers the PDU as overload rather than accepted missing state; the unchanged
transaction can retry. Earlier verified outliers are not rolled back. See
ADR-0135.
Fresh post-backfill full-suite verification and independent source-only review
passed. Additional sticky-denial/partial-progress tests and end-to-end worker
socket-HTTP overload coverage for this extension remain pending.
The limits are per runtime/process, not an aggregate across worker shards. See
ADR-0132.
A legitimate new peer needs exactly one resolution before its record is cached,
so the defaults are generous for real traffic. If you tighten them and a new
federation partner intermittently fails to join, look for
key_resolution.throttled at warning level — it names the origin and which
budget denied it.
A request whose X-Matrix signature fails is charged to the source address
(after trusted_proxies and IPv6-prefix grouping, as above), never to the origin
it names, because that origin is unauthenticated until the signature verifies.
Beyond 30 failed signatures per 60 seconds from one address (a fixed limit, not
a config key), that address is answered 429 M_LIMIT_EXCEEDED with a
federation.rate_limited audit event, and other addresses are unaffected. A
real peer that shares an address with a sender of forged requests, including
every peer behind a reverse proxy that has no trusted_proxies configured,
shares that budget. Separately, an origin that sends malformed transactions or
forged relayed PDUs after a valid signature is backed off after three
consecutive failures; the backoff lapses after five minutes without a further
failure.
Rate values use N/Ws or N/Wm syntax (e.g. 300/60s). The six per_origin_*
and transaction keys require restart. An origin that exceeds a bucket gets 429 M_LIMIT_EXCEEDED for
the transaction and a federation.rate_limited audit event; invalid
individual PDUs inside an otherwise-valid transaction still report per-PDU
errors in the 200 transaction response, matching Matrix retry semantics
and avoiding destination-wide backoff for one bad event.
Federation outbound delivery controls¶
The per_origin_* keys above apply only to inbound federation traffic (/send
for the weighted trio, every other federation endpoint for
per_origin_request_rate); they do
not throttle Merovingian's own delivery to other homeservers. Outbound
federation queues an OutboundTransaction record per destination, and a
dispatch worker drains that queue with destination retry state, a circuit
breaker, and exponential backoff — a failing or slow destination is delayed
before the next attempt so it does not spin the sender or block unrelated
destinations. Outbound HTTP also applies the same private/loopback address
rejection and pinned resolved addresses used during federation discovery.
| Key | Default | Reload | Notes |
|---|---|---|---|
security.federation.remote_timeout |
60s |
requires restart | General outbound federation HTTP timeout for calls other than the join/leave dance. |
security.federation.deny_ip_ranges |
private/loopback ranges | requires restart | Adds IPv4/IPv6 destination blocks to the mandatory built-in SSRF policy, on main and worker discovery paths. |
security.federation.key_resolution_* |
see above | requires restart | Budgets pre-authentication remote-key resolution. Snapshotted into the federation runtime config at startup, which SIGHUP does not rebuild. |
federation.worker.relay_threads |
32 |
requires restart | Thread pool for worker paths that can block on outbound HTTP or synchronous main-process relays. |
Federation worker — federation.worker.*¶
When a user joins a large federated room, inbound PDU verification, state
resolution, and the membership state machine can saturate the main thread
pool and make all connected clients unresponsive. The federation worker
moves that work into one or more dedicated child processes, each with its
own thread pool. It is mandatory when federation is enabled; startup
fails fatally if the worker binary cannot be launched, and there is no
in-process fallback — requests return 503 while a crashed worker restarts.
| Key | Default | When to change |
|---|---|---|
federation.worker.threads |
4 |
Thread pool for endpoints answered entirely from the worker's own local snapshot (make_join/make_leave/make_knock, backfill, directory/state queries, get_missing_events, hierarchy) — these never block on main, so this can stay small. |
federation.worker.relay_threads |
32 |
Thread pool for endpoints that can block on a synchronous IPC round-trip to main (PDU-bearing send, send_join/send_leave/send_knock, invite, profile/key queries, event/{eventId}) or on outbound HTTP. Deliberately separate and generously sized from threads, since sharing one pool would let a burst of slow relay calls starve the fast local endpoints — see docs/architecture.md, "Federation worker relay pool separation". |
federation.worker.shards |
1 |
Number of independent worker processes. Requests are routed by fnv1a_32(room_id) % shards; non-room endpoints go to shard 0. Must be >= 1. The shipped example sets 2. |
federation.worker.request_timeout_seconds |
120 |
Base per-request IPC timeout in seconds. The actual IPC timeout for inbound federation requests is max(request_timeout_seconds, security.federation.remote_timeout) + 10 s, so a worker-side outbound HTTP call can complete before main gives up. A request slower than the effective timeout returns 504 to the remote server. The shipped example sets 30. |
federation.worker.ipc_max_in_flight_requests |
256 |
Per-channel cap on concurrent IPC requests from each worker process to main. Requests that arrive while the channel is at the cap receive an explicit main_overloaded error and the worker answers the remote with a retryable 503 M_UNKNOWN, so nothing is silently dropped. Larger values increase memory use on main; smaller values raise the chance of transient retries from legitimate traffic. Requires restart. |
federation.worker.ipc_max_dispatch_queue_count |
1024 |
Cap on request frames from one worker waiting for main's dispatch thread. A worker that exceeds it, or the byte cap below, has its channel closed and is restarted. 0 disables the cap (not recommended). Requires restart. |
federation.worker.ipc_max_dispatch_queue_bytes |
134217728 (128 MiB) |
Cap on the bytes of those queued frames. 0 disables the cap (not recommended). Requires restart. |
federation.worker.apply_hardening |
true |
Apply seccomp/capability sandboxing to workers. Keep true in production. |
federation.worker.binary |
(empty) | Absolute path to merovingian-fed-worker; empty uses the compile-time libexec path ($libexecdir/merovingian/merovingian-fed-worker). |
federation.worker.database_uri_file |
/etc/merovingian/fed-worker-db-uri |
Secret file holding a PostgreSQL connection URI for a separate, least-privilege worker login (ADR-0062 part 2) — see docs/database-persistence.md, "Federation worker least-privilege role", and packaging/postgresql/provision-federation-worker-role.sql. Required (the file must exist and be readable) when database.backend=postgresql and federation is enabled, unless allow_shared_database_credentials=true. Ignored for database.backend=sqlite. Same secret-file permission rules as database.uri_file (owner-only, regular file). |
federation.worker.allow_shared_database_credentials |
false |
Explicit opt-out: lets the worker connect with main's own database credentials instead of a separate role, when database_uri_file is not provisioned. Every startup logs CRITICAL while this applies. Ignored for database.backend=sqlite. |
federation.worker.allow_without_landlock |
false |
Explicit opt-out (ADR-0062 part 3): the worker restricts its own filesystem access with Linux Landlock before handling any inbound request. Requires Linux 5.13+ with Landlock enabled (CONFIG_SECURITY_LANDLOCK=y, and included in the lsm= boot parameter if that parameter is set). On a kernel without Landlock the worker refuses to start unless this is true, in which case it runs with no filesystem sandbox and logs CRITICAL on every start — a worker compromised through a memory-safety bug can then open any file this process's Unix permissions allow, including the operator master key and TLS private keys. Any other Landlock failure (not merely "unavailable") is always fatal regardless of this setting. |
The worker communicates with the main process over an AF_UNIX SOCK_STREAM
socket pair inherited at spawn. Every frame is encrypted with an ephemeral
crypto_kx key exchange and crypto_secretstream_xchacha20poly1305 AEAD —
no sensitive material crosses the channel in plaintext. The server signing
key is never forwarded; the worker reads it from the same database, and
client access tokens are stripped from every request before forwarding. If
the worker crashes, the supervisor restarts it with exponential back-off
(1s, 2s, 4s, 8s, capped at 30s).
Application Service API — appservice.*¶
Matrix v1.19 Application Service API (bridges and bots). Empty by
default — with no registration files configured, the entire appservice
surface (as_token auth, m.login.application_service, namespace
exclusivity) is inert.
| Key | Default | When to change |
|---|---|---|
appservice.registration_files |
(empty) | Comma-separated list of paths to appservice registration documents. Each file is first parsed as a bounded block-style YAML subset; if that fails, JSON is tried as a fallback. Comments and plain (unquoted) scalars are supported. Anchors, aliases, tags, block scalars, tabs, document markers, and non-empty flow collections are unsupported. See a real bridge's documentation for its required as_token/hs_token/sender_localpart/namespaces fields, or the example in docs/matrix-v1.19-spec/application-service-api.md#registration. Requires restart — the registry is parsed once at startup and never re-read on SIGHUP. |
appservice.max_queued_events |
100000 |
The most events queued for one appservice (the appservice_outbox table). A bridge that is down keeps accumulating the events it is interested in; at this cap further events for that appservice are not queued, counted in merovingian_appservice_outbox_dropped_total{appservice_id}, and the first refusal and every thousandth after it is logged at warning level and written to the audit log as appservice.outbox.full naming the appservice. The oldest events are kept, so a bridge that returns still receives them in order, but it misses the events that arrived while the queue was full and must resynchronise another way (ADR-0191). Raise it only for a bridge you expect to be down for a very long time. Requires restart. |
A registration file with a duplicate id or as_token against another
configured registration is rejected — per spec this MUST be unique — and
drops every registration from the batch rather than guessing which one
should win; check the startup log for start.appservice_registration_rejected
events. as_token/hs_token are held in an mlocked core::SecretBuffer and
never appear in logs or diagnostics.
Identity assertion (?user_id= with the as_token) acts only as a user that
exists and is not deactivated: a bridge must create each virtual user with
POST /register (type: m.login.application_service) before asserting it,
otherwise the request is 403 M_FORBIDDEN. The bridge's own
sender_localpart user is created at startup. A bridge cannot register, log in
as or assert a user inside another bridge's exclusive namespace
(M_EXCLUSIVE), and an as_token request is refused (403 M_FORBIDDEN) on the
Account Management endpoints (/account/password, /account/deactivate,
/account/3pid*); only GET /account/whoami accepts assertion.
Outbound delivery (PUT /_matrix/app/v1/transactions/{txnId}) is wired into
the event pipeline: every event is queued durably, one event per transaction,
for each appservice interested in it. An appservice is interested in an event
when the sender, or the state_key of an m.room.member event (an invite,
kick or ban names its target there, so a virtual user is told it was invited
before it joins), is a local user in its users namespaces, when a user
joined to the room is, or when the room matches its rooms or aliases
namespaces (any alias of the room). POST /createRoom queues the events it
creates (create, membership, power levels, join rules, ...) like any other. A
registration with receive_ephemeral: true also receives m.typing,
m.receipt and m.presence data in the transaction's ephemeral array: typing and receipts for
rooms it is interested in (a private read receipt only for a user in its
namespaces), presence for users in its namespaces or sharing a room with one of
its users. Ephemeral data is held in memory, at most 512 items per appservice,
the latest per room, user or receipt slot, so it is not redelivered after an
outage or a restart (ADR-0190).
An appservice can test the connection with POST
/_matrix/client/v1/appservice/{appserviceId}/ping, authenticated with its
own as_token; the homeserver calls POST /_matrix/app/v1/ping on the
registered url and answers {"duration_ms": N}, or 400 M_URL_NOT_SET,
403 M_FORBIDDEN, 502 M_CONNECTION_FAILED, 502 M_BAD_STATUS (with the
appservice's status and body) or 504 M_CONNECTION_TIMEOUT. The call is
bounded by appservice.total_timeout_seconds. An appservice can publish a room to the
directory of one of the protocols it declares with PUT
/_matrix/client/v3/directory/list/appservice/{networkId}/{roomId}, and
POST /publicRooms lists those rooms for third_party_instance_id (the
instance_id from GET /thirdparty/protocol/{protocol}) or together with the
ordinary directory for include_all_networks. The outbound query hooks
(GET /_matrix/app/v1/users/{userId}, GET /_matrix/app/v1/rooms/{roomAlias}) are called on a local miss
inside the owning appservice's namespace. Every client request that makes the server
call an appservice (the ping, and the GET /thirdparty/* lookups) first takes a slot in the
client-outbound budget (ADR-0079) and is answered 429 M_LIMIT_EXCEEDED when none is free. /_matrix/client/v3/thirdparty/*
(protocols, protocol/{protocol}, location, location/{protocol},
user, user/{protocol}) is implemented, backed by the outbound
GET /_matrix/app/v1/thirdparty/* calls to every appservice that declares a
matching protocol — see docs/todos/capability-gaps.md, "Application
Service API".
Media repository — security.media.*¶
| Key | Default | When to change |
|---|---|---|
security.media.max_upload_size |
50MiB |
Maximum local upload size. Match your reverse-proxy body-size limit. |
security.media.max_total_size |
250GiB |
Cap on stored local media bytes, in the database; cached remote media has its own budget below. Media is read from the database per request, so this bounds storage, not memory. Empty means no limit. |
security.media.max_size_per_user |
10GiB |
Per-user media quota in bytes. Keep it at or above max_upload_size. Empty means no limit. |
security.media.max_records |
1000000 |
Cap on local media records. Each record's metadata is still held in memory (roughly 300-500 bytes), so this also bounds memory: 1,000,000 records is about 400 MB. 0 means no limit. |
security.media.allowed_mime_types |
built-in list | Comma-separated allow-list; keep application/octet-stream so encrypted-room attachments are accepted. |
security.media.quarantine_unknown_mime |
true |
Quarantine uploads whose MIME type is not in the allow-list. |
security.media.block_private_ip_fetches |
true |
Block private/loopback origins when fetching remote media. |
security.media.remote_fetch_enabled |
false (the example config sets true) |
Opt-in for live remote media fetching. While it is false (the default) a download or thumbnail of media hosted on another server is answered 404 M_NOT_FOUND before any server discovery or outbound call, on the legacy /_matrix/media/v3/ routes and the authenticated /_matrix/client/v1/media/ routes alike. When it is true, fetches run on a dedicated media fetch pool, so a slow remote server never holds a main request thread, with at most server.http.media_fetch_max_in_flight (64) fetches running or waiting and server.http.media_fetch_max_per_client (8) for one client; over either the answer is 429 M_LIMIT_EXCEEDED with retry_after_ms 1000. Requests for the same remote file at the same time share one fetch, and each fetch has a 30 s total deadline. A request that carries allow_remote=false is never fetched remotely, whatever this is set to. |
security.media.remote_fetch_timeout |
30s |
Parsed and validated, but the live path does not read it: remote fetches use the fixed 30 s client-outbound deadline (bounded further by security.federation.remote_timeout when that is shorter). |
security.media.decode_in_sandbox |
true |
Decode/thumbnail media inside a sandboxed child process. |
security.media.enable_av_scanner |
true |
Does not launch a real antivirus engine — with it on, uploads are checked only for the EICAR test signature (media::content_matches_eicar_test_signature). See the warning below. |
security.media.local_upload_policy |
allow-after-scan |
allow/allow-after-scan/quarantine/deny. |
security.media.remote_fetch_media_policy |
quarantine |
Same values; defaults to quarantine because federated origins are unaccountable. |
security.media.remote_media_cache_ttl_seconds |
86400 |
How long admitted remote media is served from the local copy before it is fetched again. |
security.media.remote_media_cache_max_entries |
50000 |
Maximum remote media files kept; when full, the least recently used one is deleted to make room. 0 disables the cache: every request for remote media goes to the origin, and only the latest fetched file is kept. |
security.media.remote_media_cache_max_size |
25GiB |
Bytes of cached remote media. The least recently used remote files are deleted to stay within it; a single file larger than this is refused with 507. Empty means no limit. |
security.media.thumbnail_cache_size |
64MiB |
Bytes of generated thumbnails kept in memory, least recently used first out, so a repeated thumbnail request does not run the decoder again. A cached thumbnail is served only while its media is still servable. Empty or 0 disables the cache. Requires a restart. |
security.media.thumbnail_max_concurrent_per_user |
2 |
Thumbnail decodes one requester (the user, or the client address when unauthenticated) may run at once, 1..64. A further request is refused with 429 M_LIMIT_EXCEEDED. Requires a restart. |
security.media.pending_upload_ttl_seconds |
86400 |
How long an mxc:// URI reserved by POST /_matrix/media/v1/create stays usable without content (the spec's recommended 24 hours), 60..604800. Returned to the client as unused_expires_at. Requires a restart. |
security.media.max_pending_uploads_per_user |
10 |
Live reservations one user may hold, 1..1000. Past it create answers 429 M_LIMIT_EXCEEDED. Requires a restart. |
security.media.max_pending_uploads |
10000 |
Live reservations the whole server may hold, 1..1000000. Requires a restart. |
security.media.pending_upload_max_wait_ms |
20000 |
The longest a download of a reserved but not yet uploaded media ID waits (never longer than the request's timeout_ms) before 504 M_NOT_YET_UPLOADED, 0..60000. The wait runs on the media fetch pool, never on a main request thread, and at most server.http.media_fetch_threads - 1 waits run at once; past that a download answers 504 at once. Requires a restart. |
security.media.url_preview_enabled |
false |
Serve GET /_matrix/client/v1/media/preview_url (and the deprecated /_matrix/media/v3/preview_url). The server then fetches URLs your users name; every fetch and redirect is refused if it resolves to a private, loopback, link-local or reserved address or one in security.federation.deny_ip_ranges. While false the endpoint answers 404 M_UNRECOGNIZED. Requires a restart. |
security.media.url_preview_max_page_size |
1MiB |
Largest page a preview fetches; a larger one is not previewed. At most 64MiB. Requires a restart. |
security.media.url_preview_max_image_size |
5MiB |
Largest og:image a preview fetches and stores (also capped by max_upload_size). The image is stored as the requesting user's media and counts against their quota. At most 64MiB. Requires a restart. |
security.media.url_preview_timeout_seconds |
10 |
Total time for one preview (page, redirects and image), 1..30. Requires a restart. |
security.media.url_preview_max_redirects |
3 |
Redirects a preview follows, each checked like the first hop, 0..5. Requires a restart. |
security.media.url_preview_cache_ttl_seconds |
3600 |
How long a preview is served from memory before the URL is fetched again, 0..86400 (0 disables the cache). Failures are cached for at most 5 minutes. Requires a restart. |
security.media.url_preview_cache_max_entries |
1000 |
Previews kept in memory, least recently used first out, 0..100000. Requires a restart. |
The three local capacity limits bound media your users upload, which would otherwise
be unbounded: upload spam grows it until the database fills. Size limits must be empty
(no limit) or use the suffixes B, KiB, MiB or GiB; anything else is rejected at
startup rather than read as no limit. The effective limits are logged at startup. An upload that would cross any limit is refused with
403 M_FORBIDDEN (the spec's answer for "The user has reached a quota for uploaded content"; a
file type the server does not permit gets the same, and an oversized upload 413 M_TOO_LARGE);
nothing is evicted, because clients hold mxc:// URIs
for what is already stored and eviction would break those links rather than shed
load. Bytes are counted over stored blobs, so a deduplicated upload consumes a
record but no additional bytes.
Remote media is stored under one @remote-media:<origin> owner per origin and is
charged only to its own budget, remote_media_cache_max_entries and
remote_media_cache_max_size, never to the local limits, so caching other servers'
media cannot make a local upload fail. Size the disk for both budgets together.
Repeated requests for the same remote media
within remote_media_cache_ttl_seconds are served from the stored copy without
contacting the origin, including after a restart; remote media held in quarantine
is answered 451 from the cache rather than fetched again. Media bytes live only in
the database and are read for each request, so memory use does not grow with stored
media.
Encrypted-room media can never be scanned, under any configuration, by design. Matrix E2EE attachments are encrypted client-side before upload; the homeserver only ever receives and stores an opaque
application/octet-streamciphertext blob and never holds the decryption key — that key travels only inside the (also encrypted) room event, which the server cannot read either. No setting below, no proxy placement, and no future scanner integration changes this without breaking E2EE's confidentiality guarantee. Anything below that mentions a "scanner verdict" applies only to plaintext media in unencrypted rooms.
Two security.media.* keys are not fully wired end to end yet:
security.media.enable_av_scanner is parsed, but does not configure or
launch an antivirus engine — it only changes how the media policy treats a
scanner verdict supplied by an upstream caller. security.media.remote_fetch_timeout
is parsed and validated, but the live remote-fetch path still uses
hard-coded discovery and outbound HTTP timeouts.
local_upload_policy and remote_fetch_media_policy select the acceptance
disposition for authenticated local uploads and for bytes fetched from a
federated origin, independently. Each accepts:
allow— accept unconditionally; the scanner verdict is ignored.allow-after-scan— accept only when the scanner verdict is clean; quarantine or reject otherwise. This is the only behaviour that existed before these keys were introduced.quarantine— always hold for manual admin review via the/_merovingian/admin/media/{quarantine,release,remove}routes.deny— reject unconditionally.
remote_fetch_media_policy defaults to quarantine rather than mirroring
local_upload_policy's allow-after-scan default: unlike a local upload, a
federated origin has no accountable local identity behind it, and Merovingian
does not run a real AV scanner for any media source today, so there is
never a genuine "clean" verdict to trust for remote-fetched bytes. Operators
who accept that risk, or who front the server with a real scanning proxy
that populates the scanner verdict, can set this to allow-after-scan or
allow explicitly.
Trust and safety — security.trust_safety.*¶
The trust-safety transport is opt-in and fail-closed by default.
| Key | Default | When to change |
|---|---|---|
security.trust_safety.enabled |
false |
Set true to consult a remote policy service for registration, room creation, inbound federation, and media downloads. |
security.trust_safety.policy_server_url |
(empty) | HTTPS URL of the policy service. Required when enabled. |
security.trust_safety.policy_server_timeout |
5s |
Policy request timeout. |
security.trust_safety.policy_server_allow_without_result |
false |
Set true to allow the guarded workflow when the policy service is unreachable. Defaults fail-closed. |
When enabled, Merovingian POSTs a small JSON decision request — surface,
entity, server_name — to the configured HTTPS endpoint. The response
body is expected to be JSON with at least:
action— one ofallow,deny,quarantine,lock_account,suspend_account.rule_id— optional, recommended for audit correlation.summary/reason— optional operator-facing text.
If the policy server is unreachable, returns a non-2xx status, omits a
usable decision, or sends malformed JSON, the guarded workflow is rejected
unless security.trust_safety.policy_server_allow_without_result=true.
Logging redaction — security.logging.*¶
| Key | Default | When to change |
|---|---|---|
security.logging.redact_tokens |
true |
Keep true so tokens do not leak into logs. |
security.logging.redact_event_content |
true |
Keep true so message content does not leak into logs. |
security.logging.structured |
true |
Structured log format. |
Client rate limits — client_rate_limits.*¶
Two independent wall-clock token-bucket tiers are enforced on every client-server request:
- Per-IP, keyed by
(effective_client_ip, normalized_route). - Per-user, keyed by the authenticated
user_idplus the normalized route for requests that present a valid access token.
The longest matching <target> prefix wins; path parameters such as
roomId, deviceId, and mediaId are coalesced into placeholders so a
single cap covers all rooms/devices/media. Every entry rejects a zero-window
or zero-cap policy at startup. Changes require a server restart.
| Key | Default | When to change |
|---|---|---|
client_rate_limits.per_ip.<target> |
see defaults below | Per-IP cap for requests matching <target> prefix. |
client_rate_limits.per_user.<target> |
see defaults below | Per-user cap keyed by authenticated user_id. |
client_rate_limits.default_per_ip |
600/60s |
Fallback cap for unmatched targets. |
client_rate_limits.tier.<auth_sensitive\|media\|sync\|federation\|admin\|generic> |
see engine defaults | Per-tier override. An unknown tier name is a config parse error. |
Default route-aware policies applied when no override is configured:
| Endpoint class | Default policy |
|---|---|
| Login / registration | 20/60s per IP; 5/60s per user on /login |
| Device and key APIs | 120/60s per IP |
| Media uploads and downloads | 120/60s per IP |
| Media thumbnails | 240/60s per IP |
Search (/_matrix/client/v3/search) |
20/60s per IP |
Classic sync (/_matrix/client/v3/sync) and unstable sliding-sync aliases |
3000/60s per IP |
Stable sliding sync (/_matrix/client/v4/sync) |
600/60s generic per-IP default |
| Generic client APIs | 600/60s per IP fallback |
Example overrides:
client_rate_limits.per_ip./_matrix/client/v3/login=20/60s
client_rate_limits.per_user./_matrix/client/v3/login=5/60s
client_rate_limits.default_per_ip=600/60s
Client startup or opening a large room can request many different thumbnails in a short burst. Those requests share a per-IP thumbnail bucket, whose 240/minute default accommodates ordinary browsing without widening upload or full-download limits. For installations still seeing thumbnail HTTP 429 responses, increase the thumbnail prefixes only, for example:
client_rate_limits.per_ip./_matrix/client/v1/media/thumbnail=480/60s
client_rate_limits.per_ip./_matrix/media/v3/thumbnail=480/60s
Restart to apply, then review actual 429 responses and resource use before
raising the cap further. These overrides preserve upload/download limits.
A client_rate_limits.tier.media override would change all media operations.
Classic sync and the unstable sliding-sync aliases have a 3000/60s per-IP
default. The current classifier leaves stable /v4/sync in the generic tier
(default 600/60s). Investigate immediate responses, shared IPs and retry loops
before tuning the tier used by the route. Search has a separate 20/60s per-IP default.
Hiding warnings does not prevent HTTP 429s.
Per-module log levels — log_modules.*¶
| Key | Default | When to change |
|---|---|---|
log_modules.<module> |
unset | Override the level for a specific module. |
log_modules.* |
unset | Set the default level for all unlisted modules. |
The default is info. --debug enables debug output at the console sink;
module/default settings still filter both console and file output. To enable
debugging for a module, use --debug and log_modules.<module>=debug.
Use log_modules.*=debug only when debug output is wanted for unlisted modules
as well. See docs/log-filtering.md for the module list.
Reloadability policy¶
Configuration keys are classified as reloadable or restart-required.
Restart-required keys affect stable process identity or secret-source
selection; reloadable keys are runtime policy or limit values intended to be
applied through a future reload path without a full homeserver restart.
Configuration parsing and validation are restart-safe today, but the live
reload control path (SIGHUP/admin socket) is not yet wired — --plan-config-reload
only reports what would happen.
| Key or key group | Policy |
|---|---|
server.name |
Restart required |
database.uri_file |
Restart required |
database.role |
Restart required |
database.migration_role |
Restart required |
database.runtime_role |
Restart required |
listeners.*.tls_certificate_file |
Restart required |
listeners.*.tls_private_key_file |
Restart required |
listeners.*.reverse_proxy |
Restart required |
security.registration.token_file |
Restart required |
security.federation.* |
Restart required |
server.client_api.* |
Restart required |
client_rate_limits.* |
Restart required |
listeners.max_queued_connections |
Reloadable |
log_modules.* |
Restart required |
security.secrets.master_key_file |
Restart required |
federation.worker.* |
Restart required |
server.cors.* |
Restart required |
server.http.* |
Restart required |
server.identity_server.* |
Restart required |
server.push.* |
Restart required |
server.email.* |
Restart required |
appservice.* |
Restart required |
database.pool_size |
Restart required |
server.turn.* |
Reloadable |
security.trust_safety.* |
Reloadable |
security.access_token_lifetime_ms / security.refresh_token_lifetime_ms |
Reloadable |
security.login_throttle.* |
Reloadable |
Other listeners.* keys (except reverse_proxy) |
Reloadable |
security.registration.* (except token_file) |
Reloadable |
security.encryption.* |
Reloadable |
security.media.* |
Reloadable |
security.logging.* |
Reloadable |
server.oidc.* |
Reloadable |
server.sso.* |
Reloadable |
--plan-config-reload <current> <next> compares two validated configs and
reports the reload action:
Reload plan: changes=1 reloadable=1 restart_required=0
Reload action: reloadable
security.media.remote_fetch_timeout=reloadable
Reload plan: changes=1 reloadable=0 restart_required=1
Reload action: restart required
server.name=restart_required
A successful plan always exits with status 0, even when the action says a
restart is required, because the planning operation itself succeeded.
Runtime config snapshot¶
The runtime config snapshot owns the currently validated in-memory config and can apply a candidate config only when the reload plan has no restart-required changes:
| Outcome | Meaning |
|---|---|
unchanged |
Candidate config matches the current runtime config. |
applied |
Candidate config changed only reloadable keys and replaced the in-memory snapshot. |
restart_required |
Candidate config changed at least one restart-required key and was not applied. |
The snapshot is an internal foundation for future live reload — it is not yet connected to SIGHUP, an admin socket, or any external control API.
Startup hardening self-check¶
Startup logs a fixed checklist of hardening signals and refuses to start
unless every check reports enabled (src/main.cpp). Most checks are real
compile-time macros or runtime probes (ELF inspection, /proc/self/status,
pledge/Capsicum queries, getrlimit/prctl); a check reports unknown
only when its probe cannot run on the current platform (e.g. the Linux-only
probes on non-Linux, non-BSD platforms), never as a placeholder.
| Check | Current signal source |
|---|---|
compiler hardening |
Compile-time stack-protector + FORTIFY_SOURCE + PIE macros |
linker hardening |
ELF probe (RELRO, bind-now, noexecstack); unknown for statically-linked binaries |
PIE |
ELF probe; unknown if the probe cannot run or the binary is static |
RELRO |
ELF probe |
stack protector |
Compile-time macro |
FORTIFY_SOURCE |
Compile-time macro |
seccomp |
Runtime probe via /proc/self/status (Linux); unknown if the filter isn't applied or the platform isn't Linux |
pledge/unveil |
Runtime probe on OpenBSD; reports enabled (not applicable) on other platforms |
capsicum |
Runtime probe on FreeBSD; reports enabled (not applicable) on other platforms |
privilege drop |
Runtime non-root check on Linux; unknown on platforms without a probe |
filesystem restrictions |
Runtime non-root check on Linux; unknown on platforms without a probe |
core dump policy |
getrlimit(RLIMIT_CORE) probe on Linux; unknown elsewhere |
no_new_privs |
PR_SET_NO_NEW_PRIVS probe on Linux; unknown elsewhere |
capability bounding |
Capability bounding-set drop probe on Linux; unknown elsewhere |
secret redaction policy |
Enabled by validated logging defaults |
Production packaging¶
Production package assets are intentionally separated from the bootstrap
config: packaging/systemd/merovingian.service, packaging/openrc/merovingian,
packaging/rc.d/merovingian, and Dockerfile. These assets are deployment
scaffolds until the production gates in
docs/todos/production-milestone.md pass —
do not publish them as a production release while runtime listeners, durable
storage, federation verification, or hardening checks remain incomplete.
The server refuses to start unless every check in the table above reports
enabled, and two deployment details decide that:
- Build optimised. Meson's default buildtype is
debug(-O0), which leaves_FORTIFY_SOURCEundefined, so theFORTIFY_SOURCEcheck staysunknown. The packaging scripts and theDockerfilepass--buildtype=release; if you build from source, do the same (meson setup build --buildtype=release -Dhardening=true). Meson prints a warning at configure time if you do not. - Run with no capabilities. A non-root process cannot drop its own
capability bounding set. The systemd unit empties it with
CapabilityBoundingSet=; for the Docker image, start the container withdocker run --cap-drop=ALL --security-opt=no-new-privileges ..., or thecapability boundingcheck staysunknown. - Provide the master key. The image's
/etc/merovingian/merovingian.confis the example config, which setssecurity.secrets.master_key_fileto/etc/merovingian/master-key. The image ships no key, so mount one there (generated as in Generate the master key, mode0400, owned by themerovingianuser) or point the setting at your own path. Without it--check-configand startup reject the configuration.
Database backends¶
SQLite (development and evaluation)¶
SQLite is a single-file store with no separate server process. It is not recommended for production because it lacks connection pooling, concurrent write scaling, and role separation.
Remove or comment out the PostgreSQL keys when switching to SQLite. Only one
database.backend line may be active.
PostgreSQL (production)¶
Keep the default backend and store the connection URI in an owner-only secret file:
echo "postgresql://merovingian:secret@localhost/merovingian" \
| sudo tee /etc/merovingian/db-uri
sudo chmod 600 /etc/merovingian/db-uri
sudo chown merovingian:merovingian /etc/merovingian/db-uri
database.backend=postgresql
database.uri_file=/etc/merovingian/db-uri
database.role=runtime
database.pool_size=16
Use database.role=migration only with the offline migration tool. The live
server requires database.role=runtime. Startup fails closed if the URI file
is missing, unreadable, or empty; the server never falls back to an ephemeral
in-memory database. The programmatic-only in-memory backend exists for tests
and cannot be selected in the config file.
Offline migration planning¶
merovingian-db-migrate plans schema migrations without starting the runtime:
The live server applies pending migrations automatically on startup.
Running the server¶
Command-line interface¶
merovingian-server [--dry-run]
merovingian-server [--dry-run] --config <path>
merovingian-server [--config <path>] --bootstrap-admin <localpart> \
--bootstrap-admin-password-file <path>
merovingian-server --check-config <path>
merovingian-server --plan-config-reload <current> <next>
merovingian-server --help
merovingian-server --version
| Flag | Purpose |
|---|---|
--config <path> |
Path to merovingian.conf. |
--dry-run |
Validate config and print the startup summary without binding listeners. |
--check-config <path> |
Validate the config file and exit. |
--bootstrap-admin <localpart> |
Create an admin account during startup, then continue serving. Requires --bootstrap-admin-password-file. |
--bootstrap-admin-password-file <path> |
Nonempty, service-owned mode-0400 regular password file, at most 4096 bytes; symlinks are rejected and the first line is read into locked memory. |
--plan-config-reload <current> <next> |
Compare two configs and report reloadability. |
--debug |
Enable debug-level console diagnostics. |
--log-file <path> |
Write trace/debug diagnostics to a file. |
The thumbnail worker (libexecdir/merovingian/merovingian-thumbnail-worker) takes
one flag of its own: --version prints its version and its libpng and
libjpeg-turbo lines in the form shown in
Library versions in the startup log,
after entering its sandbox as it does for every request.
Start the server¶
The process binds the configured listeners, launches the federation worker
(when enabled), runs a hardening self-check, and logs Listeners active;
awaiting traffic. Send SIGINT or SIGTERM to stop.
systemd¶
Distro packages install packaging/systemd/merovingian.service. Enable and start
it:
The unit runs as the merovingian user, restricts filesystem access to
/var/lib/merovingian and /var/log/merovingian, and applies
NoNewPrivileges, PrivateTmp, ProtectSystem=strict, and other hardening.
Smoke test¶
After starting:
Expect a JSON response listing supported Matrix client-server versions.
Reverse proxy¶
Merovingian is designed to sit behind a reverse proxy. The proxy should:
- Own public TLS.
- Forward
OriginandAuthorizationheaders unmodified. - Not add
Access-Control-Allow-*headers for/_matrix/routes. - Route
/_matrix/client/and/_matrix/media/to127.0.0.1:8008. - Route
/_matrix/federation/and/_matrix/key/to127.0.0.1:8009. - Serve
/.well-known/matrix/clientand/.well-known/matrix/serverdirectly (or forward them, but keep their own CORS headers). - Overwrite
X-Forwarded-Forwith the direct TCP peer IP (never append to a client-supplied value) soserver.trusted_proxiescan enforce per-client rate limits correctly.
Set Merovingian's listeners to loopback cleartext behind the proxy:
listeners.client.bind=127.0.0.1:8008
listeners.client.tls=false
listeners.federation.bind=127.0.0.1:8009
listeners.federation.tls=false
server.trusted_proxies=127.0.0.1
nginx example¶
Terminates TLS in nginx, serves the .well-known discovery JSON inline, and
routes client/media traffic to 8008 and federation/key traffic to 8009 by
path. Replace matrix.example.org with your server.public_baseurl host.
server {
listen 80;
server_name matrix.example.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name matrix.example.org;
ssl_certificate /etc/letsencrypt/live/matrix.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.example.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
# Do NOT add Access-Control-Allow-* here — Merovingian emits CORS headers
# on all /_matrix/ responses; duplicate values break browser clients.
location = /.well-known/matrix/client {
default_type application/json;
add_header Access-Control-Allow-Origin "*" always;
return 200 '{"m.homeserver":{"base_url":"https://matrix.example.org"}}';
}
location = /.well-known/matrix/server {
default_type application/json;
add_header Access-Control-Allow-Origin "*" always;
return 200 '{"m.server":"matrix.example.org:443"}';
}
location /_matrix/federation/ {
proxy_pass http://127.0.0.1:8009;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
# $remote_addr, not $proxy_add_x_forwarded_for — the latter lets a
# client forge another user's rate-limit bucket.
proxy_set_header X-Forwarded-For $remote_addr;
}
location /_matrix/key/ {
proxy_pass http://127.0.0.1:8009;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
location /_matrix/client/ {
proxy_pass http://127.0.0.1:8008;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
# Media needs its own block: falling through to the catch-all below
# breaks upload/download CORS preflight, and nginx's default
# client_max_body_size (1 MiB) silently 413s uploads before Merovingian
# ever sees them. Match security.media.max_upload_size (default 50 MiB).
location /_matrix/media/ {
proxy_pass http://127.0.0.1:8008;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 50m;
}
location / {
return 403;
}
}
# Native federation listener for servers that skip .well-known delegation.
# Optional if every remote server follows .well-known/matrix/server, but
# harmless to keep.
server {
listen 8448 ssl http2;
server_name matrix.example.org;
ssl_certificate /etc/letsencrypt/live/matrix.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.example.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /_matrix/federation/ {
proxy_pass http://127.0.0.1:8009;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
location /_matrix/key/ {
proxy_pass http://127.0.0.1:8009;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
}
location / {
return 403;
}
}
Apache serves the discovery files from static files — create them once before reloading:
mkdir -p /var/www/merovingian/.well-known/matrix
printf '{"m.homeserver":{"base_url":"https://matrix.example.org"}}' \
> /var/www/merovingian/.well-known/matrix/client
printf '{"m.server":"matrix.example.org:443"}' \
> /var/www/merovingian/.well-known/matrix/server
Apache httpd example¶
This example assumes mod_ssl, mod_headers, mod_proxy,
mod_proxy_http, and mod_rewrite are enabled. Apache owns public ports 443
and 8448; Merovingian listens only on loopback ports 8008 and 8009. The
443 vhost handles both client and delegated federation traffic by path.
# Port 8448 must be declared before the VirtualHost blocks that use it.
Listen 8448
# ── HTTP → HTTPS redirect ─────────────────────────────────────────────────────
# Redirect all plain-HTTP requests to HTTPS so no Matrix credentials or tokens
# are ever sent in the clear.
<VirtualHost *:80>
ServerName matrix.example.org
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
# ── Primary HTTPS block (client-server API + delegated federation) ────────────
# Handles: Matrix clients (/_matrix/client/), media (/_matrix/media/), and
# federation delegated from port 443 via /.well-known/matrix/server.
<VirtualHost *:443>
ServerName matrix.example.org
# ── TLS ───────────────────────────────────────────────────────────────────
# Terminate TLS here; Merovingian binds to cleartext loopback only.
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/matrix.example.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/matrix.example.org/privkey.pem
# Disable TLS 1.0 and 1.1 — both have known practical attacks.
# Matrix spec requires TLS; older versions are non-compliant.
SSLProtocol -all +TLSv1.2 +TLSv1.3
# ── Security response headers ──────────────────────────────────────────────
# HSTS: instructs browsers to enforce HTTPS for one year and opts the domain
# into browser-bundled preload lists for first-visit protection.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Prevent MIME-type sniffing that could cause a browser to execute an
# uploaded file served with a safe content-type.
Header always set X-Content-Type-Options "nosniff"
# Block this domain from being embedded in a frame on another origin,
# protecting the login page against clickjacking.
Header always set X-Frame-Options "DENY"
# Do NOT add Access-Control-Allow-* here — Merovingian emits CORS headers on
# all /_matrix/ responses. Adding them at the proxy level creates duplicate
# values (e.g. "*, *") that browsers reject; clients show "Failed to connect"
# even though the server returns HTTP 200.
# ── Forwarding headers ────────────────────────────────────────────────────
# Pass the original Host: header to Merovingian so it knows the server name
# when constructing federation responses and verifying X-Matrix signatures.
ProxyPreserveHost On
# Tell Merovingian the downstream connection arrived over HTTPS. Without
# this, code that inspects the forwarded protocol sees cleartext.
RequestHeader set X-Forwarded-Proto "https"
# Overwrite X-Forwarded-For with the IP Apache received the TCP connection
# from. The unset+set pair prevents a client from injecting a fake IP to
# steal another client's rate-limit budget (IP-bucket forgery).
# Requires server.trusted_proxies=127.0.0.1 in merovingian.conf so
# Merovingian reads this header for per-client rate limiting instead of
# using the raw peer address (127.0.0.1 for all proxied traffic).
RequestHeader unset X-Forwarded-For
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
# ── Proxy routing ─────────────────────────────────────────────────────────
# Exclude /.well-known/ from proxying so the Alias directives below are
# reached. Without the "!" exclusion, Apache forwards well-known requests
# to Merovingian, which returns 404 because it does not own those paths.
ProxyPass "/.well-known/" "!"
# /_matrix/federation/ and /_matrix/key/ go to the federation listener (8009)
# so X-Matrix signature auth is applied, not the client access-token gate.
ProxyPass "/_matrix/federation/" "http://127.0.0.1:8009/_matrix/federation/"
ProxyPassReverse "/_matrix/federation/" "http://127.0.0.1:8009/_matrix/federation/"
# /_matrix/key/ exposes Merovingian's signing keys for remote servers to
# verify federation request signatures and PDU event signatures.
ProxyPass "/_matrix/key/" "http://127.0.0.1:8009/_matrix/key/"
ProxyPassReverse "/_matrix/key/" "http://127.0.0.1:8009/_matrix/key/"
# Client-server API and media go to the client listener (8008).
# Apache does not impose a default body-size limit, so no special handling
# is needed for media uploads; if you add LimitRequestBody, set it to at
# least the value of security.media.max_upload_size in merovingian.conf.
ProxyPass "/_matrix/client/" "http://127.0.0.1:8008/_matrix/client/"
ProxyPassReverse "/_matrix/client/" "http://127.0.0.1:8008/_matrix/client/"
# ── Static discovery files ────────────────────────────────────────────────
# Served by Apache directly (see the shell snippet above) so they are
# available even when Merovingian is restarting. The ProxyPass "!" above
# ensures requests for these paths are never forwarded to Merovingian.
Alias "/.well-known/matrix/client" "/var/www/merovingian/.well-known/matrix/client"
Alias "/.well-known/matrix/server" "/var/www/merovingian/.well-known/matrix/server"
<Directory "/var/www/merovingian/.well-known/matrix">
Require all granted
</Directory>
# ── Access control ────────────────────────────────────────────────────────
# Default-deny: block every path so a misconfiguration never accidentally
# exposes internal services. Apache Location directives merge in document
# order with later entries winning, so these specific allows must come AFTER
# the "Require all denied" catch-all below.
<Location "/">
Require all denied
</Location>
<Location "/_matrix/client/">
Require all granted
</Location>
<Location "/_matrix/federation/">
Require all granted
</Location>
<Location "/_matrix/key/">
Require all granted
</Location>
# /.well-known discovery — CORS is required here because browser clients
# fetch these from a different origin (e.g. element.io or localhost). This
# is NOT a duplicate of Merovingian's CORS: Apache serves these files
# directly from disk and never proxies them to Merovingian.
<Location "/.well-known/matrix/client">
Require all granted
ForceType application/json
Header always set Access-Control-Allow-Origin "*"
</Location>
<Location "/.well-known/matrix/server">
Require all granted
ForceType application/json
Header always set Access-Control-Allow-Origin "*"
</Location>
</VirtualHost>
# ── Native federation listener (port 8448) ────────────────────────────────────
# Handles direct federation connections from servers that do not follow the
# .well-known/matrix/server delegation to port 443. Optional if all remote
# servers support .well-known discovery, but harmless to keep.
<VirtualHost *:8448>
ServerName matrix.example.org
# Same certificate as port 443.
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/matrix.example.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/matrix.example.org/privkey.pem
SSLProtocol -all +TLSv1.2 +TLSv1.3
# HSTS on the federation port prevents protocol-downgrade during server
# discovery even when the remote server connects directly to port 8448.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
RequestHeader unset X-Forwarded-For
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
# Only federation and key endpoints are reachable on port 8448.
# The client-server API is never exposed here.
ProxyPass "/_matrix/federation/" "http://127.0.0.1:8009/_matrix/federation/"
ProxyPassReverse "/_matrix/federation/" "http://127.0.0.1:8009/_matrix/federation/"
ProxyPass "/_matrix/key/" "http://127.0.0.1:8009/_matrix/key/"
ProxyPassReverse "/_matrix/key/" "http://127.0.0.1:8009/_matrix/key/"
# Default-deny: block everything not explicitly allowed above.
<Location "/">
Require all denied
</Location>
<Location "/_matrix/federation/">
Require all granted
</Location>
<Location "/_matrix/key/">
Require all granted
</Location>
</VirtualHost>
Caddy example¶
Caddy terminates TLS automatically with Let's Encrypt. Use a single
matrix.example.org site block that serves the well-known discovery
files inline and routes /_matrix/ traffic to the loopback listeners.
Federation is reached either via the same :443 block (with /.well-known/
matrix/server pointing at :443) or a separate :8448 site.
# ── Client-server API + delegated federation (port 443) ──────────────────────
# Caddy automatically obtains and renews a Let's Encrypt certificate for this
# site — no ssl_certificate directives are needed. Caddy also adds HSTS,
# enforces modern TLS, and enables OCSP stapling by default.
matrix.example.org {
# ── Matrix homeserver discovery (served inline, not proxied) ─────────────
# These two well-known endpoints are required by the Matrix spec and are
# served inline by Caddy so they remain available while Merovingian restarts.
#
# /.well-known/matrix/client: tells Matrix clients the base URL of the
# homeserver. Clients request this before logging in. CORS is required
# here because browser clients fetch it from a different origin
# (e.g. element.io). This is NOT a duplicate of Merovingian's CORS —
# Caddy serves this path directly and never proxies it.
@clientDiscovery path /.well-known/matrix/client
handle_response @clientDiscovery {
header Content-Type application/json
header Access-Control-Allow-Origin "*"
respond `{"m.homeserver":{"base_url":"https://matrix.example.org"}}` 200
}
# /.well-known/matrix/server: tells remote homeservers where to send
# federation traffic. "m.server":"matrix.example.org:443" delegates
# federation to this site block so no separate port 8448 DNS entry is
# required. Remote servers fetch this during server discovery.
@serverDiscovery path /.well-known/matrix/server
handle_response @serverDiscovery {
header Content-Type application/json
header Access-Control-Allow-Origin "*"
respond `{"m.server":"matrix.example.org:443"}` 200
}
# ── Federation and key-server API ─────────────────────────────────────────
# Routes /_matrix/federation/ and /_matrix/key/ to the federation listener
# (8009). The split from port 8008 (client) is intentional: federation uses
# X-Matrix signature auth, not Bearer tokens, and must not be routed through
# the client-server access-token gate.
@federation path /_matrix/federation/* /_matrix/key/*
reverse_proxy @federation 127.0.0.1:8009
# ── Client-server API and media ───────────────────────────────────────────
# /_matrix/client/ handles login, registration, sync, messages, etc.
# /_matrix/media/ must be listed alongside — without it media requests fall
# through to the `respond 403` catch-all below, failing the browser CORS
# preflight and breaking uploads, downloads, and user avatars.
@client path /_matrix/client/* /_matrix/media/*
reverse_proxy @client 127.0.0.1:8008
# ── Catch-all: deny everything else ───────────────────────────────────────
# Block any path not matched above so a misconfiguration never accidentally
# exposes an internal service on this hostname.
respond 403
}
# ── Native federation listener (port 8448) ────────────────────────────────────
# Handles direct federation connections from servers that do not follow the
# .well-known/matrix/server delegation to port 443. Optional if all remote
# servers support .well-known discovery, but harmless to keep. Only federation
# and key endpoints are forwarded; all other paths return 403.
matrix.example.org:8448 {
@federation path /_matrix/federation/* /_matrix/key/*
reverse_proxy @federation 127.0.0.1:8009
respond 403
}
Caddy ships sane defaults for HSTS, modern TLS, and OCSP stapling, so no
extra header directives are required for those. CORS preflight is still
emitted by Merovingian; the Access-Control-Allow-Origin lines above are
only for the discovery JSON, which Caddy serves directly.
Traefik example¶
Traefik v3 with the file provider. Use routers + services split by path
prefix; the static discovery files are served by a dedicated file
provider or a tiny HTTP backend.
# traefik.yml (excerpt)
entryPoints:
# Redirect all plain-HTTP traffic to HTTPS so no Matrix credentials are
# sent in the clear.
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
# Main TLS entry point for client-server API and delegated federation.
websecure:
address: ":443"
# Native Matrix federation port. Required for servers that do not follow
# .well-known/matrix/server delegation; optional but harmless otherwise.
federation:
address: ":8448"
# dynamic.yml
http:
routers:
# ── Client-server API + media ──────────────────────────────────────────────
# Routes /_matrix/client/ and /_matrix/media/ to the client listener (8008).
# Media must be included here: omitting it causes media requests to match no
# router, return a CORS error, and break uploads, downloads, and avatars.
client-server:
rule: "Host(`matrix.example.org`) && (PathPrefix(`/_matrix/client/`) || PathPrefix(`/_matrix/media/`))"
service: merovingian-client
entryPoints: [websecure]
tls: { certResolver: letsencrypt }
# ── Federation + key API on port 443 (delegated) ──────────────────────────
# Routes /_matrix/federation/ and /_matrix/key/ to the federation listener
# (8009) so X-Matrix signature auth is applied, not the client access-token
# gate on 8008.
federation-443:
rule: "Host(`matrix.example.org`) && (PathPrefix(`/_matrix/federation/`) || PathPrefix(`/_matrix/key/`))"
service: merovingian-federation
entryPoints: [websecure]
tls: { certResolver: letsencrypt }
# ── Native federation listener on port 8448 ───────────────────────────────
# Accepts connections from servers that do not follow .well-known delegation.
# The wildcard Host rule is safe here because only the federation entryPoint
# binds port 8448 — no other services are reachable on this port.
federation-8448:
rule: "Host(`matrix.example.org`)"
service: merovingian-federation
entryPoints: [federation]
tls: { certResolver: letsencrypt }
services:
# Client listener (8008): login, registration, sync, messages, media, keys.
merovingian-client:
loadBalancer:
servers: [{ url: "http://127.0.0.1:8008" }]
# Federation listener (8009): server-to-server PDU exchange, key fetching,
# and X-Matrix signature authentication.
merovingian-federation:
loadBalancer:
servers: [{ url: "http://127.0.0.1:8009" }]
/.well-known/matrix/client and /server are served by Merovingian
itself; with the wildcard CORS default no Traefik middleware is needed.
Do not add a headers middleware that sets Access-Control-Allow-Origin —
this would create duplicate header values that browsers reject.
HAProxy example¶
HAProxy is the cheapest option for high-traffic deployments because it does not buffer requests. The frontend terminates TLS; the backends forward to the loopback listeners. ACLs route by path prefix so client and federation traffic land on the correct backend.
# ── HTTPS frontend (port 443) ─────────────────────────────────────────────────
# Terminates TLS and routes to backends by path-prefix ACL. ACLs are evaluated
# in order; the first matching use_backend rule wins.
frontend ft_https
bind *:443 ssl crt /etc/haproxy/certs/matrix.example.org.pem alpn h2,http/1.1
# Redirect any plain-HTTP request to HTTPS so no Matrix credentials are
# sent in the clear (applies when the client connects on port 80 to the
# same listener, e.g. if bind *:80 is also present).
http-request redirect scheme https code 301 if !{ ssl_fc }
# Path-prefix ACLs — determines which backend handles each request.
acl is_client path_beg /_matrix/client/
acl is_media path_beg /_matrix/media/
acl is_federation path_beg /_matrix/federation/
acl is_key path_beg /_matrix/key/
# Client-server API and media both target the client listener (8008).
use_backend bk_merovingian_client if is_client || is_media
# Federation and key-server API target the federation listener (8009) so
# X-Matrix signature auth is applied, not the client access-token gate.
use_backend bk_merovingian_federation if is_federation || is_key
# /.well-known/matrix/{client,server} falls through to the client backend;
# Merovingian does not own those paths but the client listener returns 404,
# which is sufficient — serve them from a separate static backend if needed.
use_backend bk_merovingian_client
default_backend bk_merovingian_client
# ── Client-server backend ─────────────────────────────────────────────────────
# Serves the client-server API and media repository on port 8008.
backend bk_merovingian_client
# Append the real client IP to X-Forwarded-For so Merovingian can use it
# for per-client rate limiting. Requires server.trusted_proxies=127.0.0.1
# in merovingian.conf; without that, all clients share one rate-limit bucket.
option forwardfor header X-Forwarded-For
# Tell Merovingian the downstream connection arrived over HTTPS.
http-request set-header X-Forwarded-Proto https
server merovingian 127.0.0.1:8008 check
# ── Federation backend ────────────────────────────────────────────────────────
# Serves the server-to-server API and signing-key endpoints on port 8009.
backend bk_merovingian_federation
option forwardfor header X-Forwarded-For
http-request set-header X-Forwarded-Proto https
server merovingian 127.0.0.1:8009 check
# ── Native federation listener (port 8448) ────────────────────────────────────
# Accepts direct federation connections from servers that do not follow the
# .well-known/matrix/server delegation to port 443. Optional if all remote
# servers support .well-known discovery, but harmless to keep. All traffic on
# this port is forwarded to the federation backend; the backend's Merovingian
# router returns 403 for any path that is not /_matrix/federation/ or
# /_matrix/key/.
frontend ft_federation_native
bind *:8448 ssl crt /etc/haproxy/certs/matrix.example.org.pem alpn h2,http/1.1
default_backend bk_merovingian_federation
HAProxy does not edit response headers unless told to; CORS preflight
therefore reaches the client as Merovingian emits it. Do not add
http-response set-header Access-Control-Allow-Origin to the backends —
this would create duplicate header values that browsers reject.
Cloudflare example¶
Cloudflare's CDN terminates TLS and can route to an origin over HTTPS
or HTTP. The two gotchas are (a) Cloudflare adds its own
Cf-Connecting-IP and may strip Authorization if caching is on for
the route, and (b) Origin request headers are passed through, so
Merovingian's preflight handling still works.
# Cloudflare dashboard or Terraform equivalent
record:
- name: matrix
type: A
value: 203.0.113.10 # origin server public IP
proxied: true
origin_rules:
- name: "Matrix client + delegated federation (port 443)"
condition: { hostname: "matrix.example.org" }
destination: { port: 8008 } # client traffic; the origin server's nginx then splits by path
- name: "Federation native (port 8448)"
condition: { hostname: "matrix.example.org", port: 8448 }
destination: { port: 8009 }
ssl: full
For a Cloudflare-fronted deployment the cleanest split is to put nginx
in front of Merovingian on the origin box (the nginx example above
already handles that). Cloudflare then connects to nginx's :443 over
HTTPS and forwards Origin, Authorization, and Cf-Connecting-IP
unmodified. Make sure the Cloudflare cache is off for /_matrix/
routes (set cache level to "Bypass" on the page rule) and that
"Authenticated Origin Pulls" is enabled so the origin only accepts
connections from Cloudflare.
Smoke test for every proxy¶
After deploying, run this from the host that resolves
matrix.example.org. The 200 response MUST include the
Access-Control-Allow-Origin line; if it does not, the browser will
block the preflight and Element will fail to join a room.
curl -X OPTIONS \
-H "Origin: vector://vector" \
-H "Access-Control-Request-Method: GET" \
-i https://matrix.example.org/_matrix/client/v3/versions
Expected response (200 + the CORS headers Merovingian emits):
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400
Vary: Origin
Run the same preflight against a media endpoint. This is the check that catches a
missing /_matrix/media/ proxy route — uploads, downloads, and avatars break
even when client traffic works:
curl -X OPTIONS \
-H "Origin: vector://vector" \
-H "Access-Control-Request-Method: GET" \
-i https://matrix.example.org/_matrix/media/v3/config
A non-2xx here (typically 403 from a catch-all location /) is the classic
symptom: the browser reports "Response to preflight request doesn't pass access
control check: It does not have HTTP ok status" and the request fails with
net::ERR_FAILED. Merovingian itself answers this OPTIONS with 200 + CORS on
the client-server listener, so a failure is always a proxy routing gap.
User management¶
Create the first admin¶
Use --bootstrap-admin before opening public registration:
merovingian-server --config /etc/merovingian/merovingian.conf \
--bootstrap-admin alice \
--bootstrap-admin-password-file /run/merovingian/admin-pw
Enable public registration¶
security.registration.enabled=true
security.registration.require_token=true
security.registration.token_file=/etc/merovingian/registration-token
Generate the token:
openssl rand -base64 48 | sudo tee /etc/merovingian/registration-token
sudo chmod 600 /etc/merovingian/registration-token
Register a user¶
TOKEN=$(sudo cat /etc/merovingian/registration-token)
curl -s -X POST http://127.0.0.1:8008/_matrix/client/v3/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"alice\",\"password\":\"CorrectHorse7!\", \
\"auth\":{\"type\":\"m.login.registration_token\",\"token\":\"$TOKEN\"}}"
Log in and get an access token¶
curl -s -X POST http://127.0.0.1:8008/_matrix/client/v3/login \
-H 'Content-Type: application/json' \
-d '{
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": "@alice:example.org"},
"password": "CorrectHorse7!",
"device_id": "MY_DEVICE"
}'
Federation¶
Enable or disable federation¶
Federation is enabled by default:
Set it to false to run a non-federating server.
Allow-list federation¶
security.federation.default_policy=deny
security.federation.allowed_servers=matrix.org,example.net
security.federation.denied_servers=bad.example
Deny-by-default federation requires a non-empty allowed_servers list.
Federation worker¶
When federation is enabled, the worker is mandatory. Startup fails if the worker
cannot be launched. Tune federation.worker.shards to distribute CPU across
multiple worker processes for busy servers.
Federation join diagnostics¶
Large remote room joins are the most resource-intensive federation operation.
If joins to big rooms time out or return 502:
- Increase
security.federation.join_timeout. - Increase
security.federation.join_response_max_size(requires restart). - Tune
federation.worker.relay_threadsfor more concurrent join traffic. - Check that
security.federation.join_race_deadlinefits within your reverse proxy's own request timeout.
Media repository¶
Local uploads¶
security.media.max_upload_sizebounds local uploads. Match this value in your reverse proxy (e.g.client_max_body_sizein nginx).- Unknown MIME types are quarantined by default.
- Encrypted-room attachments are uploaded as
application/octet-stream; keep that type in the allow-list.
Remote media fetching¶
Remote fetching is off unless configured. The example config
(config/merovingian.conf.example) turns it on, so a server set up from it can download
attachments sent from other servers; a config without the line refuses every remote download
with 404:
Fetched bytes default to quarantine because federated origins are unaccountable
and no AV engine is integrated today.
Inbound federation media serving¶
Locally uploaded media that is not quarantined or removed is automatically
available to remote homeservers through
GET /_matrix/federation/v1/media/download/{mediaId}. The endpoint is
authenticated with X-Matrix request signatures, bypasses the federation worker
because the worker has no access to the local media store, and returns a
multipart/mixed response per Matrix v1.19 (an empty JSON metadata part
followed by the media bytes). No extra configuration is required.
Sandbox¶
security.media.decode_in_sandbox=true runs the thumbnail/decoder worker as a
separate process with resource limits and (on Linux) a seccomp-bpf filter. Keep
it enabled.
Logging and diagnostics¶
Console and file logging¶
merovingian-server --debug --log-file /var/log/merovingian/debug.log \
--config /etc/merovingian/merovingian.conf
--debug lowers the console sink threshold to debug. --log-file enables
the file sink, whose threshold is trace. The path must be a regular file owned
by the server's user, or not exist yet: it is created or tightened to 0600 and
truncated at start-up. A symlink (including /dev/stderr), FIFO, device or
another user's file is refused: no file output is written, and an error on the
console says so (ADR-0146). Both sinks still obey the
configured module/default levels, so set the relevant modules to debug
or trace to receive those diagnostics.
Per-module log levels¶
log_modules.http_server=info
log_modules.client_server=debug
log_modules.rate_limit=debug
log_modules.*=debug
Restart the server for log-module changes to take effect. See
docs/log-filtering.md for the module list.
Audit log¶
High-signal events (rate-limit hits, login rejections, access-token rejections, request rejections, locked/suspended-account request rejections, registration policy denials) are written to the structured audit log. Query it through the admin endpoint:
The endpoint lists the most recent 1 024 audit rows the running process holds; its
first line ends in evicted=<n> once older rows have left that window, and the
audit_log table keeps every row. Rejections that any unauthenticated client can
trigger (access_token.rejected, rate_limit.exceeded, request.rejected) are
written at most 10 times per kind per 60 seconds; the first row after a busier
window carries suppressed=<n> in its reason. actor, target and reason are
recorded as at most 255 bytes. See docs/observability-audit.md,
"Audit volume bounds".
Admin API¶
All /_merovingian/admin/* routes require a normal client access token
(Authorization: Bearer <token>) belonging to a user whose account has the
admin flag set: a missing or invalid token returns 401, a valid token for
a non-admin user returns 403. Implemented routes:
| Method | Path | Purpose |
|---|---|---|
GET |
/_merovingian/admin/health |
Health summary. |
GET |
/_merovingian/admin/metrics |
Prometheus-format server metrics. |
GET |
/_merovingian/admin/media/metrics |
Media-repository metrics. |
GET |
/_merovingian/admin/audit |
Audit log query (?category=, ?event_type=). |
POST |
/_merovingian/admin/media/quarantine/{mediaId} |
Quarantine a stored media item. |
POST |
/_merovingian/admin/media/release/{mediaId} |
Release a quarantined media item. |
POST |
/_merovingian/admin/media/remove/{mediaId} |
Remove a stored media item. |
/_merovingian/admin/accounts/{userId}, /_merovingian/admin/review/{targetType}/{targetId},
and /_merovingian/admin/shutdown appear in observability::admin_routes()
(include/merovingian/observability/observability.hpp,
src/observability/observability.cpp) but are not wired into either HTTP
dispatcher (src/homeserver/local_http_router.cpp,
src/homeserver/client_server.cpp) — they are unreachable today.
Maintenance¶
Backups¶
Back up the database, the matching master key and all deployment configuration
as one recovery set. Media bytes and delivery queues are in the database;
there is no separate media-blob directory to reconstruct. Include the private
connection files, appservice registrations, registration token and TLS files
referenced by the config, including any stored outside /etc/merovingian.
Without the matching master key, encrypted signing secrets cannot be recovered.
Preserve server.name, the signing key identity and the binary/package
version with the backup. Protect recovery sets as secrets, encrypt off-host
copies and restrict their access.
The examples below are POSIX commands for a root shell on a systemd
installation using the default paths and a database named merovingian.
Adapt service names, paths and database maintenance credentials first. For
PostgreSQL these examples use the local OS postgres maintenance account;
remote installations need an authorized backup/restore account and the same
explicit TLS policy as their connection configuration. Do not put passwords
on the command line or use the restricted federation-worker credential.
Stop the server and confirm its workers have exited and there are no other writers. This gives a consistent recovery point across database state and configuration; copying a live SQLite file is not this procedure.
set -eu
umask 077
backup="/var/backups/merovingian/$(date -u +%Y%m%dT%H%M%SZ)"
install -d -m 0700 "$backup"
systemctl stop merovingian
cp -a /etc/merovingian "$backup/config"
merovingian-server --version > "$backup/version.txt"
Copy any referenced files outside that config directory into the protected recovery set as well, recording their original paths. Then run the appropriate database step:
PostgreSQL: install PostgreSQL client tools compatible with the server.
pg_dump captures this database's objects, data, ownership and grants, but not
cluster roles or passwords. Retain secure, deployment-specific role definitions,
memberships and credentials separately so the restored grants name existing
roles. The role scripts under packaging/postgresql/ describe the expected main,
migration, runtime and worker boundaries; use the definitions for the backed-up
release rather than granting the worker broad access to get a restore working.
sudo -u postgres pg_dump --format=custom --dbname=merovingian > "$backup/database.dump"
pg_restore --list "$backup/database.dump" > "$backup/database.list"
SQLite: with the server stopped, use SQLite's backup command rather than copying a file while it may be written. The server currently pins DELETE journal mode, and worker reads refuse WAL databases.
sqlite3 -readonly /var/lib/merovingian/merovingian.sqlite3 ".backup '$backup/database.sqlite3'"
test "$(sqlite3 -readonly "$backup/database.sqlite3" 'PRAGMA integrity_check;')" = ok
Record checksums after all files have been added, copy the recovery set to its protected off-host destination, and restart only after the database command succeeded. An archive listing or integrity check does not prove that a whole homeserver can be restored.
(cd "$backup" && find . -type f ! -name SHA256SUMS -exec sha256sum {} + > SHA256SUMS)
systemctl start merovingian
Restore and recovery validation¶
Restore to a fresh isolated destination using the same release first;
perform an upgrade separately after recovery. Keep the original instance
stopped. Never run two publicly reachable servers with the same server name
and signing identity. Keep client/federation exposure and outbound federation
blocked during validation, while allowing any database connection required.
The commands below assume a root shell, backup pointing at the recovered
set, and no existing destination database/file; they deliberately do not drop
or overwrite a live database. Start a fresh root shell with fail-fast handling;
replace RECOVERY_SET with the actual recovered directory:
Stop on any failed restore or validation command. Do not proceed to startup until that failure is resolved.
- Verify the recovery set with
(cd "$backup" && sha256sum --check SHA256SUMS). Install the recorded release and recreate the service account. Restore the saved config and its external dependencies to their original paths on the replacement host. Remap saved numeric ownership if the service UID changed. The master key, private database URI files, registration token and TLS private keys must be owned by the effective service UID and mode0400; the config and parent directories must also remain accessible to that account. Do not generate a replacement master key or changeserver.name. - Restore the appropriate database as below. For PostgreSQL, recreate the
original roles, memberships and login credentials before restoring;
archive ownership and grants depend on them. The provisioning scripts use
CREATE ROLE, so do not blindly rerun them against roles that already exist. Ensure both main and the worker connect to the restored database, with their original separate privilege boundaries.
PostgreSQL, after the deployment's roles exist (the example login/database
owner is merovingian):
sudo -u postgres createdb --owner=merovingian merovingian
sudo -u postgres pg_restore --exit-on-error --single-transaction --dbname=merovingian < "$backup/database.dump"
SQLite, on a fresh destination with no existing file or sidecars:
install -d -o merovingian -g merovingian -m 0700 /var/lib/merovingian
test ! -e /var/lib/merovingian/merovingian.sqlite3
test ! -L /var/lib/merovingian/merovingian.sqlite3
install -o merovingian -g merovingian -m 0600 "$backup/database.sqlite3" /var/lib/merovingian/merovingian.sqlite3
test "$(sqlite3 -readonly /var/lib/merovingian/merovingian.sqlite3 'PRAGMA integrity_check;')" = ok
- Validate configuration as the service account:
sudo -u merovingian merovingian-server --check-config /etc/merovingian/merovingian.conf. This checks config and secret-file admission; it does not prove a database connection, migration, worker launch or successful startup. - Start the isolated service and inspect its startup/hardening results,
database/schema admission and worker readiness. Compare the published server
signing key and server name with the recovery record. Through the loopback
client listener, check
/_matrix/client/versions, authenticate a known test account, read existing room history and download an existing media item. Confirm both main and worker database access work without widening grants. - Stop the isolated instance before transferring it to the live deployment, then restore the intended listener/proxy exposure and network access. Keep the original stopped. Retain the pre-restore and pre-upgrade recovery sets; rollback requires a compatible binary, database and matching key/config set, not merely reinstalling an older binary over a migrated database.
These steps are an operator procedure, not evidence of an end-to-end recovery certification. Rehearse them on disposable data and record the actual restore, startup and client checks for your deployment before relying on a backup.
Upgrades¶
- Review the
CHANGELOG.mdfor the target version. - Stop the server.
- Back up the database and master key.
- Install the new package or tarball.
- Validate the config with
--check-config. - Start the server.
Schema migrations are applied automatically on startup.
Version and release metadata¶
The canonical version is meson.build. When the version changes, every file
listed in docs/versioning.md must be updated in the same
commit.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
duplicate configuration key at startup |
Two active database.backend lines or any other duplicate key. |
Remove the duplicate. |
Config validation failure (exit 79) |
Unsafe listener bind, missing secret file, open registration without token, etc. | Read the specific message and adjust merovingian.conf. |
| Client shows "Failed to connect" | Proxy emits duplicate Access-Control-Allow-Origin headers, or /.well-known/matrix/client is missing/404. |
Remove proxy CORS headers; serve the well-known discovery file. |
429 Too Many Requests for all clients |
server.trusted_proxies is unset behind a reverse proxy. |
Set server.trusted_proxies and ensure the proxy overwrites X-Forwarded-For with the direct peer IP. |
502 send_join failed: timeout |
Large room join exceeds security.federation.join_timeout or the proxy's request timeout. |
Raise join_timeout and join_race_deadline; ensure they fit inside the proxy timeout. |
502 send_join failed: response_too_large |
send_join response exceeds security.federation.join_response_max_size. |
Raise the value and restart both main and worker processes. |
| Federation worker does not start | merovingian-fed-worker binary is missing or federation.worker.binary points to the wrong path. |
Check binary location and permissions. |
| Encrypted media downloads fail | application/octet-stream was removed from security.media.allowed_mime_types. |
Restore it. |
Security checklist¶
Before opening a server to real users:
- [ ]
server.nameandserver.public_baseurlmatch the public DNS/TLS setup. - [ ] A 32-byte master key is generated and stored with owner-only permissions.
- [ ] PostgreSQL is used for production; SQLite is only for evaluation.
- [ ] Database URI, registration token, and TLS private keys are owner-only and outside web roots.
- [ ] A reverse proxy terminates public TLS; Merovingian binds to loopback only.
- [ ]
server.trusted_proxiesis set so per-IP rate limits work correctly. - [ ] The proxy overwrites
X-Forwarded-Forwith the direct peer IP. - [ ] The proxy does not emit
Access-Control-Allow-*headers for/_matrix/traffic. - [ ] Public registration is token-protected (or disabled).
- [ ] Federation deny/allow lists match your trust model.
- [ ]
security.federation.require_valid_tlsandsecurity.federation.verify_json_signaturesaretrue. - [ ]
security.media.decode_in_sandboxistrue. - [ ]
security.logging.redact_tokensandsecurity.logging.redact_event_contentaretrue. - [ ] Log file permissions are restricted.
- [ ] Regular database and master-key backups are configured.
For the complete security architecture and threat model, see
docs/threat-model.md and
docs/hardening.md.
Security audit follow-up (0.12.17)¶
Public directory publication survives restart. Schema 18 leaves older rooms unpublished; an authorized joined user can republish them through the directory visibility endpoint. POST /_matrix/client/v3/publicRooms requires client authentication; GET remains public. Presence status messages accept at most 1024 UTF-8 bytes; invalid or oversized updates return 400 without changing presence state.