Skip to content

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.md are 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

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:8008 and 127.0.0.1:8009) with TLS disabled and reverse_proxy=true. A reverse proxy owns public TLS and routes traffic to the loopback listeners. Public listeners must use TLS and set reverse_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-worker child 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 …, and merovingian-thumbnail-worker --version prints 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_timeout budget (see Federation).

Installation

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-server
  • build/src/merovingian-db-migrate
  • build/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

merovingian-server --check-config /etc/merovingian/merovingian.conf

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=value pair per line.
  • Blank lines are ignored.
  • Lines beginning with # are ignored.
  • Whitespace around keys and values is trimmed.
  • Booleans must be exactly true or false.
  • 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 408 and 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: close on 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

Configuration rejected: <field>: secret file must be a regular owner-only
non-executable file

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-stream ciphertext 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 of allow, 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_id plus 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
Reload plan: changes=0 reloadable=0 restart_required=0
Reload action: no changes

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_SOURCE undefined, so the FORTIFY_SOURCE check stays unknown. The packaging scripts and the Dockerfile pass --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 with docker run --cap-drop=ALL --security-opt=no-new-privileges ..., or the capability bounding check stays unknown.
  • Provide the master key. The image's /etc/merovingian/merovingian.conf is the example config, which sets security.secrets.master_key_file to /etc/merovingian/master-key. The image ships no key, so mount one there (generated as in Generate the master key, mode 0400, owned by the merovingian user) or point the setting at your own path. Without it --check-config and 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.

database.backend=sqlite
database.sqlite_path=/var/lib/merovingian/merovingian.sqlite3

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:

merovingian-db-migrate --plan 1 2 --migrations migrations/

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

merovingian-server --config /etc/merovingian/merovingian.conf

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:

systemctl enable --now merovingian.service

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:

curl -s http://127.0.0.1:8008/_matrix/client/v3/version

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 Origin and Authorization headers unmodified.
  • Not add Access-Control-Allow-* headers for /_matrix/ routes.
  • Route /_matrix/client/ and /_matrix/media/ to 127.0.0.1:8008.
  • Route /_matrix/federation/ and /_matrix/key/ to 127.0.0.1:8009.
  • Serve /.well-known/matrix/client and /.well-known/matrix/server directly (or forward them, but keep their own CORS headers).
  • Overwrite X-Forwarded-For with the direct TCP peer IP (never append to a client-supplied value) so server.trusted_proxies can 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:

security.federation.enabled=true

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_threads for more concurrent join traffic.
  • Check that security.federation.join_race_deadline fits within your reverse proxy's own request timeout.

Media repository

Local uploads

  • security.media.max_upload_size bounds local uploads. Match this value in your reverse proxy (e.g. client_max_body_size in 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:

security.media.remote_fetch_enabled=true

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:

curl 'http://127.0.0.1:8008/_merovingian/admin/audit?category=policy'

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:

set -eu
umask 077
backup=/var/backups/merovingian/RECOVERY_SET
test -d "$backup"

Stop on any failed restore or validation command. Do not proceed to startup until that failure is resolved.

  1. 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 mode 0400; the config and parent directories must also remain accessible to that account. Do not generate a replacement master key or change server.name.
  2. 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
  1. 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.
  2. 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.
  3. 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

  1. Review the CHANGELOG.md for the target version.
  2. Stop the server.
  3. Back up the database and master key.
  4. Install the new package or tarball.
  5. Validate the config with --check-config.
  6. 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.name and server.public_baseurl match 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_proxies is set so per-IP rate limits work correctly.
  • [ ] The proxy overwrites X-Forwarded-For with 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_tls and security.federation.verify_json_signatures are true.
  • [ ] security.media.decode_in_sandbox is true.
  • [ ] security.logging.redact_tokens and security.logging.redact_event_content are true.
  • [ ] 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.