Skip to content

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.

  • 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.

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.

KeyTypeDefaultPurpose
identifierstringnone, requiredStable name for this deployment. 1 to 256 bytes, no whitespace or control characters.
min_libxmtp_versionsemantic versionunsetLowest libxmtp version a client may use. Unset admits every version.
listensocket address0.0.0.0:5050Address the plaintext gRPC listener binds.
log_leveloff, error, warn, info, debug, traceinfoGlobal log level. The --log-level flag overrides it.
log_formattext, jsontextLog output format.
request_loggerbooleantrueEmit one INFO event when each gRPC response finishes or is cancelled.
max_drain_duration_msinteger10000How 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.

KeyTypeDefaultPurpose
metrics_listensocket address or empty string0.0.0.0:9464Separate Prometheus listener. An empty string disables it.
otlp_endpointHTTP or HTTPS URLunsetOTLP gRPC endpoint. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT. No endpoint disables OTLP export.
otlp_logsbooleanfalseExport logs through OTLP when an endpoint is set.
service_namestringxmtp-backendNon-empty telemetry service name.
sample_rationumber from 0 to 11.0Trace sampling ratio. Backend metrics do not depend on it.
resource_attributesmap 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.

KeyTypeDefaultPurpose
urlPostgreSQL URLnone, requiredPrimary connection string.
replica_urlPostgreSQL URLunsetOne physical read replica. Reads route to it.
max_connectionsinteger20Connections per request pool. With a replica there are two pools.
max_statement_timeout_msinteger5000PostgreSQL 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.

KeyTypeDefaultPurpose
max_publish_duration_msinteger10000Ceiling on one publish request, from first lock to commit. Must be greater than database.max_statement_timeout_ms.
max_barrier_wait_msinteger1000How long the boundary task waits for the allocation barrier before it retries later.
KeyTypeDefaultPurpose
poll_interval_msinteger100Interval between tailer polls of the read database. Polls do not overlap.
max_gap_rangesinteger10000Unresolved gap ranges the tailer keeps before it fails affected streams.
keepalive_interval_msinteger30000Send-idle time before the server sends a Ping.
max_pong_wait_msinteger90000How long the server waits for the matching Pong. Must be greater than keepalive_interval_ms.
KeyTypeDefaultPurpose
group_message_secondspositive integer7776000Expiry period for group application messages.
welcome_secondspositive integer7776000Expiry period for welcomes.
key_package_secondspositive integer7776000Expiry 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.

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.

KeyTypeDefaultPurpose
enabledbooleannone, requiredWhether this section applies. false serves without authentication.
jwks_urlURLunsetFetch public signing keys over HTTPS. HTTP is allowed only on loopback hosts.
keysarray of key tablesunsetInline 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_keysmap of name to stringunsetNamed 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.
audiencesarray of stringsunsetRequire a matching audience when set. Must not be empty.
issuersarray of stringsunsetRequire a matching issuer when set. Must not be empty.
required_scopesarray of strings[]Require every listed scope.
leeway_secondsinteger from 0 to 30060Allow this clock tolerance when validating token times.
jwks_refresh_secondspositive integer300Interval between signing-key refreshes, before jitter.
jwks_max_stale_secondspositive integer3600Maximum 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 = true
jwks_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.

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"
KeyTypeDefaultPurpose
max_scw_cache_entriesinteger10000Entries in the per-instance smart-contract-wallet signature-verdict cache. LRU eviction.

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.

KeyTypeDefaultPurpose
max_group_membersinteger, 1 to 65535250Largest group a client will create or grow. Advisory.
max_installations_per_inboxinteger, 1 to 6553510Installations a client keeps for one inbox. Advisory.
commit_log_enabledbooleantrueWhether clients write and read the commit log on this deployment.

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.

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.

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.

Terminal window
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.toml

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.

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, and grpc-status-details-bin.

Buffering a streaming response is the failure that is hardest to notice: unary calls keep working and only subscriptions break.

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.

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.