Run the backend
The XMTP backend is one binary. It serves native gRPC, gRPC-Web, and the standard gRPC health service on a single port. It keeps no local state, so you can run several instances behind a load balancer and scale them horizontally. All durable state lives in PostgreSQL.
A client reconnecting to a different instance does not need anything from the instance it left. It reconnects with its own topic cursors.
Requirements
Section titled “Requirements”- PostgreSQL 17 or later.
- One TOML configuration document.
- A TCP port for the gRPC listener. The default is
5050.
The backend applies its schema from a single migration. Startup never deletes a database.
Configuration
Section titled “Configuration”Pass inline TOML with --config or XMTP_CONFIG. For a mounted file, use
--config-file instead. Supply exactly one source. The database URL is required.
Every other key has a default. Unknown keys, invalid values, and inconsistent size relationships
fail startup.
A string of the form env:NAME reads the environment variable NAME at startup.
A missing variable fails startup. Keep secrets in environment variables. Resolved
values never appear in errors or logs.
A minimal file needs the deployment identifier and the database URL:
#:schema https://raw.githubusercontent.com/xmtp/libxmtp/self-hosted/docs/schemas/backend-v1.json[server]# Change this to a name you own.identifier = "org.example.xmtp"
[database]url = "env:XMTP_DATABASE_URL"The JSON schema at that URL validates the file in editors that support Taplo.
[server]
Section titled “[server]”| Key | Type | Default | Purpose |
|---|---|---|---|
identifier | string | none, required | Stable name for this deployment. 1 to 256 bytes, no whitespace or control characters. |
min_libxmtp_version | semantic version | unset | Lowest libxmtp version a client may use. Unset admits every version. |
listen | socket address | 0.0.0.0:5050 | Address the plaintext gRPC listener binds. |
log_level | off, error, warn, info, debug, trace | info | Global log level. The --log-level flag overrides it. |
log_format | text, json | text | Log output format. |
request_logger | boolean | true | Emit one INFO event when each gRPC response finishes or is cancelled. |
max_drain_duration_ms | integer | 10000 | How long shutdown waits for in-flight unary requests. Open streams fail at once. |
The convention for identifier is a reverse-DNS name you control, such as
org.example.xmtp. Change the example to your own name before you deploy.
min_libxmtp_version is compared on major, minor, and patch only, so a
prerelease tag does not change the verdict. A client below the minimum refuses to
build, and a running client that learns of a raised minimum stops.
Both are client-side checks, made by a client that reads this deployment’s published configuration. The backend only publishes the minimum: it does not reject requests by version, so raising the minimum does not stop a client that predates configuration support, or one that reached the backend before the change and has not refreshed.
[telemetry]
Section titled “[telemetry]”| Key | Type | Default | Purpose |
|---|---|---|---|
metrics_listen | socket address or empty string | 0.0.0.0:9464 | Separate Prometheus listener. An empty string disables it. |
otlp_endpoint | HTTP or HTTPS URL | unset | OTLP gRPC endpoint. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT. No endpoint disables OTLP export. |
otlp_logs | boolean | false | Export logs through OTLP when an endpoint is set. |
service_name | string | xmtp-backend | Non-empty telemetry service name. |
sample_ratio | number from 0 to 1 | 1.0 | Trace sampling ratio. Backend metrics do not depend on it. |
resource_attributes | map of strings | {} | Extra resource attributes. service.name, service.version, and xmtp.backend.identifier are reserved. |
Keep the metrics listener private. Resource attributes are exported as supplied.
Never put secrets in them. The backend exports server.identifier as the
xmtp.backend.identifier resource attribute and includes it on every request
completion log and every authentication rejection log, so one log stream can
carry several deployments.
[database]
Section titled “[database]”| Key | Type | Default | Purpose |
|---|---|---|---|
url | PostgreSQL URL | none, required | Primary connection string. |
replica_url | PostgreSQL URL | unset | One physical read replica. Reads route to it. |
max_connections | integer | 20 | Connections per request pool. With a replica there are two pools. |
max_statement_timeout_ms | integer | 5000 | PostgreSQL statement_timeout applied to every statement. |
Configure at most one physical read replica. Do not point replica_url at a load balancer that selects independently lagging replicas.
Publish and Query use the primary. Newest, identifier lookup, and subscriptions use the selected read database, so those reads can lag behind a successful publish.
The tailer holds one dedicated connection to the read database, beyond the
request pools. Without a replica, budget max_connections + 1 connections per
instance. With a replica, budget max_connections on the primary and
max_connections + 1 on the replica.
[publishing]
Section titled “[publishing]”| Key | Type | Default | Purpose |
|---|---|---|---|
max_publish_duration_ms | integer | 10000 | Ceiling on one publish request, from first lock to commit. Must be greater than database.max_statement_timeout_ms. |
max_barrier_wait_ms | integer | 1000 | How long the boundary task waits for the allocation barrier before it retries later. |
[streams]
Section titled “[streams]”| Key | Type | Default | Purpose |
|---|---|---|---|
poll_interval_ms | integer | 100 | Interval between tailer polls of the read database. Polls do not overlap. |
max_gap_ranges | integer | 10000 | Unresolved gap ranges the tailer keeps before it fails affected streams. |
keepalive_interval_ms | integer | 30000 | Send-idle time before the server sends a Ping. |
max_pong_wait_ms | integer | 90000 | How long the server waits for the matching Pong. Must be greater than keepalive_interval_ms. |
[retention]
Section titled “[retention]”| Key | Type | Default | Purpose |
|---|---|---|---|
group_message_seconds | positive integer | 7776000 | Expiry period for group application messages. |
welcome_seconds | positive integer | 7776000 | Expiry period for welcomes. |
key_package_seconds | positive integer | 7776000 | Expiry period for key packages. |
Each default is 90 days. Identity updates, commit-log entries, and group commits and proposals do not expire. Startup checks that each period fits the stored timestamp type against the primary database clock. Expiry is currently metadata: rows are not pruned or hidden at read time.
[auth]
Section titled “[auth]”Omit this section to disable caller authentication. When the section is present it
must say enabled = true or enabled = false; a section without it fails startup,
so authentication can never switch off by accident. To enable caller authentication,
set enabled = true and at least one mechanism: api_keys, jwks_url, or a
non-empty keys array. Do not set both jwks_url and keys. An otherwise empty
enabled [auth] section fails startup. Requests then require a valid bearer token,
except for gRPC health, ConfigurationService, and CORS preflight. Streams are
checked when they open.
With enabled = false the backend serves every RPC without a credential check,
validates no other key in the section, loads no keys, and fetches no JWKS.
Environment references are resolved for the whole document before any section is
validated, so an env: value naming a variable that is not set still fails
startup, even in a disabled section.
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled | boolean | none, required | Whether this section applies. false serves without authentication. |
jwks_url | URL | unset | Fetch public signing keys over HTTPS. HTTP is allowed only on loopback hosts. |
keys | array of key tables | unset | Inline public signing keys. Each key needs a unique, non-empty kid of at most 256 bytes, an alg, and a PEM SubjectPublicKeyInfo public_key. |
api_keys | map of name to string | unset | Named static API keys. Names must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. Values must be 32 to 8192 bytes of printable ASCII without whitespace. Values must be unique across names. Set at most 256 keys. |
audiences | array of strings | unset | Require a matching audience when set. Must not be empty. |
issuers | array of strings | unset | Require a matching issuer when set. Must not be empty. |
required_scopes | array of strings | [] | Require every listed scope. |
leeway_seconds | integer from 0 to 300 | 60 | Allow this clock tolerance when validating token times. |
jwks_refresh_seconds | positive integer | 300 | Interval between signing-key refreshes, before jitter. |
jwks_max_stale_seconds | positive integer | 3600 | Maximum time since a successful key fetch before the backend drains and exits. |
Supported key algorithms are RS256, RS384, RS512, ES256, ES384, and
EdDSA. The stale-key budget must cover the refresh interval, up to 10% jitter
rounded up to whole seconds, and a 10 s fetch timeout.
For example, supply your key-set URL and expected token claims:
[auth]enabled = truejwks_url = "env:XMTP_JWKS_URL"audiences = ["xmtp"]issuers = ["https://auth.example.com"]required_scopes = ["xmtp:access"]To use only API keys, set a name for each secret value:
[auth.api_keys]worker = "env:XMTP_WORKER_API_KEY"To accept API keys and JWTs, set both mechanisms:
[auth]jwks_url = "env:XMTP_JWKS_URL"audiences = ["xmtp"]issuers = ["https://auth.example.com"]required_scopes = ["xmtp:access"]
[auth.api_keys]worker = "env:XMTP_WORKER_API_KEY"The backend checks API keys before JWTs. An API key match uses the key name as the principal. Scope, audience, and issuer checks apply only to JWTs.
Caller quotas are not implemented. A valid token does not prove group membership.
[chains]
Section titled “[chains]”A map from CAIP-2 chain id to an HTTP or HTTPS RPC URL. Configure one route per
chain you need for smart-contract-wallet signature verification. An empty map
still serves identities that do not need chain verification; smart-contract-wallet
operations on an unconfigured chain return UNAVAILABLE.
[chains]"eip155:1" = "env:XMTP_RPC_MAINNET"[validation]
Section titled “[validation]”| Key | Type | Default | Purpose |
|---|---|---|---|
max_scw_cache_entries | integer | 10000 | Entries in the per-instance smart-contract-wallet signature-verdict cache. LRU eviction. |
[limits]
Section titled “[limits]”Every structural limit is one named key. See Limits for the values and
what each one constrains. Lowering a limit here can require clients to send
smaller batches. Startup rejects inconsistent pairs: max_query_limit must be at
least default_query_limit, max_envelope_bytes at most max_request_bytes, and
max_update_adds at most max_stream_topics. max_request_bytes and
max_response_bytes must each stay at or below the fixed 25 MiB transport
ceiling.
Group policy the backend publishes to clients. The backend does not enforce these values; clients read them and apply them locally.
| Key | Type | Default | Purpose |
|---|---|---|---|
max_group_members | integer, 1 to 65535 | 250 | Largest group a client will create or grow. Advisory. |
max_installations_per_inbox | integer, 1 to 65535 | 10 | Installations a client keeps for one inbox. Advisory. |
commit_log_enabled | boolean | true | Whether clients write and read the commit log on this deployment. |
Published configuration
Section titled “Published configuration”Port 5050 also serves xmtp.backend.v1.ConfigurationService/GetConfiguration.
The call needs no credential, so a client can learn what a deployment requires
before it holds one. The response is built once at startup and does not change
while the process runs. It carries the identifier, the backend version, the
minimum client version, a summary of [auth], the retention periods, the
published limits, the [mls] policy, and the configured chain ids.
It never carries the JWKS URL, chain RPC URLs, public key material, clock leeway, refresh timing, or any database, telemetry, or listener setting. The encoded response must stay under 64 KiB; a very long chain, key, audience, issuer, or scope list fails startup.
Run it locally
Section titled “Run it locally”To send notifications, configure one or more delivery channels. See Push configuration for APNs, FCM, and HTTPS settings and credential setup.
just backend up starts PostgreSQL, the backend, an Anvil chain, and Toxiproxy.
The backend listens on 127.0.0.1:5050.
just backend run starts only the backend, outside Docker, against the database
from just backend db-up.
Run the container image
Section titled “Run the container image”The published image is ghcr.io/xmtp/backend. Mount a configuration file and
pass --config-file. The entry point is the backend binary, so the container command
is only its arguments.
docker run --rm -p 127.0.0.1:5050:5050 \ -e XMTP_DATABASE_URL=postgres://user:password@host:5432/xmtp_backend \ -v ./config.toml:/config.toml:ro \ ghcr.io/xmtp/backend:self-hosted --config-file /config.tomlPorts and health
Section titled “Ports and health”Port 5050 serves native gRPC, gRPC-Web, and the standard gRPC health service.
The separate Prometheus listener defaults to port 9464; see
[telemetry]. Keep it private. There is no HTTP health path.
Probe health with a gRPC health client against the listener, for example
grpc-health-probe -addr=127.0.0.1:5050. The backend reports both the aggregate
health status and a named status for each RPC service it serves. On shutdown it
marks the aggregate status and every named service NOT_SERVING before
connections drain, so a load balancer can remove the instance before requests
fail.
Load balancer requirements
Section titled “Load balancer requirements”The backend listener is plaintext and is not a public endpoint. Terminate HTTPS at a trusted load balancer and pass requests to the backend without protocol conversion. Browser clients speak gRPC-Web to the same port, and that only works if the load balancer leaves the traffic alone.
The load balancer must:
- Pass gRPC-Web requests through without converting them to or from native gRPC.
- Stream responses through without buffering. Frames must reach the client as the backend emits them.
- Pass CORS preflight requests to the backend. The backend answers them itself.
- Preserve request headers, including authorization and version headers.
- Preserve gRPC trailers and status details, including
grpc-status,grpc-message, andgrpc-status-details-bin.
Buffering a streaming response is the failure that is hardest to notice: unary calls keep working and only subscriptions break.
Security
Section titled “Security”Optional API key and JWT authentication is available. Configure [auth]
to require valid bearer credentials. Caller quotas are not implemented. A valid token does not
prove group membership.
Without [auth], the service is unauthenticated. Do not expose an unauthenticated
service to untrusted traffic. Put it on a private network, or behind a trusted
load balancer that restricts access. Public application traffic must use TLS.
See the deployment overview for shared deployment requirements.
Use an API key from a client
Section titled “Use an API key from a client”Pass the key value through the client auth callback. Add Bearer and one space
before the key value. The middleware does not add this prefix. The header name defaults to
authorization when name is omitted.
This JavaScript example uses the Node bindings:
import { AuthCallback, BackendBuilder } from "@xmtp/node-bindings";
const builder = new BackendBuilder("https://backend.example.com");builder.authCallback( new AuthCallback(async () => ({ value: "Bearer <key>", expiresAtSeconds: 4102444800, })),);const backend = await builder.build();Replace <key> with your secret value. The example expiry is January 1, 2100.
This expiry controls the client credential cache, not the lifetime of the server key.
In Swift, return the same credential shape from the auth callback: the bearer value
and an expiry in Unix seconds, with no header name.
In Android, return the same credential shape from the auth callback: the bearer value
and an expiry in Unix seconds, with no header name.
Never log key values. To rotate a key, add the new key under a new name and restart the backend. Change clients to use the new key. Remove the old key and restart the backend again.

