Deploy on Kubernetes
Create the chart files below in a directory outside the repository. Envoy Gateway terminates TLS and sends h2c to the backend Service. Read the deployment overview for ports, connection budgets, health, shutdown, the ingress contract, and security requirements.
Image requirement: use an image that contains the configuration flag
change. Every :self-hosted image published after 2026-09-14 has it. Pin an
exact build with ghcr.io/xmtp/backend:sha-<commit>.
Prerequisites
Section titled “Prerequisites”- Kubernetes 1.34 or later. Native gRPC probes are stable since 1.27.
The
preStop.sleepaction is stable in 1.34 and enabled by default since 1.30. This guide uses the stable versions of both features. - Envoy Gateway v1.7.0, with its Gateway API v1.4.1 CRDs. This is the
selected controller version. Its policy schemas are part of the local checks.
Use a cluster that can provision a
LoadBalancerService for Envoy. - Helm 4.2.4 and kubeconform 0.8.0 for the commands below, plus
kubectl. - An external database that meets the
database requirements.
The chart does not install PostgreSQL. Create an existing Secret named
xmtp-databasein namespacexmtp, with the database URL in keyurl. Use your secret manager or secure input from a file. Do not put the URL in Helm values, TOML, or command arguments. - A DNS name and certificate. Obtain a trusted certificate and private key
for
xmtp.example.com. Keep the private key outside version control. - Access controls that meet the security requirements.
The minimal TOML below has no authentication. Restrict the Gateway load
balancer to trusted clients before enabling the route, or configure
[auth]. - Optional controllers: the ServiceMonitor needs Prometheus Operator
v0.89.0 CRDs and a Prometheus instance that selects its labels. CPU
autoscaling needs Metrics Server. Neither is installed by this chart.
Install the CRDs before the chart. With
serviceMonitor.enabledand no CRD, the install fails withno matches for kind "ServiceMonitor" in version "monitoring.coreos.com/v1".
Gateway API’s h2c backend protocol is conformance-tested across Envoy Gateway, Cilium, Istio, GKE, NGINX Gateway Fabric, and Traefik. The latter five are UNTESTED alternatives for this guide. Conformance results do not verify this backend deployment. See the Gateway API conformance reports.
Create the chart
Section titled “Create the chart”Create xmtp-chart/templates. Save each fence at the path in its title.
The default value set creates only a Deployment, ConfigMap, and private Service.
Replace the image tag before installation. sha-REPLACE_WITH_FULL_COMMIT is a
placeholder, not a published image.
apiVersion: v2name: xmtp-backenddescription: XMTP backend with an external database and optional Envoy Gateway routetype: applicationversion: 0.1.0kubeVersion: ">=1.34.0-0"image: repository: ghcr.io/xmtp/backend tag: sha-REPLACE_WITH_FULL_COMMITreplicas: 1databaseSecret: name: xmtp-database key: urlconfig: | [server] # Change this to a reverse-DNS name you own. Never change it again once # clients have connected: every client database is bound to it. identifier = "org.example.xmtp"
[database] url = "env:XMTP_DATABASE_URL"resources: requests: cpu: 250m memory: 256Mi limits: memory: 1Gigateway: enabled: false hostname: xmtp.example.com certificateSecret: xmtp-tlsserviceMonitor: enabled: false labels: {}autoscaling: enabled: false minReplicas: 2 maxReplicas: 4 targetCPUUtilizationPercentage: 70podDisruptionBudget: enabled: false minAvailable: 1The ConfigMap holds no secrets. The checksum changes the pod template when the
configuration changes. The process mounts a file, so its arguments must use
--config-file. --config accepts inline TOML and is not correct here.
apiVersion: v1kind: ConfigMapmetadata: name: {{ .Release.Name }}-configdata: config.toml: |{{ .Values.config | indent 4 }}apiVersion: apps/v1kind: Deploymentmetadata: name: {{ .Release.Name }}spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicas }} {{- end }} strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 selector: matchLabels: app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: app.kubernetes.io/instance: {{ .Release.Name }} annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum | quote }} spec: automountServiceAccountToken: false terminationGracePeriodSeconds: 45 securityContext: runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 seccompProfile: type: RuntimeDefault containers: - name: backend image: {{ printf "%s:%s" .Values.image.repository .Values.image.tag | quote }} args: ["--config-file", "/etc/xmtp/config.toml"] env: - name: XMTP_DATABASE_URL valueFrom: secretKeyRef: name: {{ .Values.databaseSecret.name | quote }} key: {{ .Values.databaseSecret.key | quote }} ports: - name: grpc containerPort: 5050 - name: metrics containerPort: 9464 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] resources:{{ toYaml .Values.resources | indent 12 }} startupProbe: grpc: port: 5050 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 120 readinessProbe: grpc: port: 5050 periodSeconds: 5 timeoutSeconds: 2 livenessProbe: grpc: port: 5050 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 lifecycle: preStop: sleep: seconds: 15 volumeMounts: - name: config mountPath: /etc/xmtp readOnly: true volumes: - name: config configMap: name: {{ .Release.Name }}-configstartupProbe allows 120 failures at 5 s intervals: about 600 s for startup.
Increase this budget if migrations need more time. In apps/backend/src/main.rs,
run awaits server::initialize(config) before TcpListener::bind.
Initialization finishes primary database migrations before it returns. The RPC
port cannot pass a probe until that work finishes. Kubernetes delays readiness
and liveness probes until the startup probe succeeds.
The image has no shell. Use native grpc: probes and preStop.sleep, with no
exec command. The 15 s sleep gives endpoint changes time to reach the Gateway
before SIGTERM. This delay needs a real rollout test; it is not a guarantee.
The sleep runs inside the termination grace period. The source defaults are
DEFAULT_DRAIN_DURATION_MS = 10_000 in apps/backend/src/config.rs and
OTLP_FLUSH_TIMEOUT = 5 s in crates/xmtp_logging/src/telemetry.rs.
The chart therefore uses 45 s = 15 s sleep + 10 s drain + 5 s flush + 15 s margin.
A 30 s grace period would leave no margin. Increase it if you increase the sleep
or the configured drain. See shutdown for client
reconnect behavior.
apiVersion: v1kind: Servicemetadata: name: {{ .Release.Name }} labels: app.kubernetes.io/instance: {{ .Release.Name }}spec: type: ClusterIP selector: app.kubernetes.io/instance: {{ .Release.Name }} ports: - name: grpc port: 5050 targetPort: grpc appProtocol: kubernetes.io/h2c - name: metrics port: 9464 targetPort: metricsGateway and TLS
Section titled “Gateway and TLS”Use an HTTPRoute for the whole hostname. GRPCRoute does not cover HTTP/1.1
gRPC-Web or its CORS preflight. An HTTPRoute and a GRPCRoute with the same
hostname conflict under the Gateway API rules. Do not add a GRPCRoute beside
this route. appProtocol: kubernetes.io/h2c selects HTTP/2 to the backend for
both client transports.
The GatewayClass is cluster-scoped. Its name includes the release namespace and name so separate releases do not claim the same class.
{{- if .Values.gateway.enabled }}apiVersion: gateway.networking.k8s.io/v1kind: GatewayClassmetadata: name: {{ .Release.Namespace }}-{{ .Release.Name }}spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller---apiVersion: gateway.networking.k8s.io/v1kind: Gatewaymetadata: name: {{ .Release.Name }}spec: gatewayClassName: {{ .Release.Namespace }}-{{ .Release.Name }} listeners: - name: https hostname: {{ .Values.gateway.hostname | quote }} port: 443 protocol: HTTPS tls: mode: Terminate certificateRefs: - group: "" kind: Secret name: {{ .Values.gateway.certificateSecret | quote }} allowedRoutes: namespaces: from: Same---apiVersion: gateway.networking.k8s.io/v1kind: HTTPRoutemetadata: name: {{ .Release.Name }}spec: parentRefs: - name: {{ .Release.Name }} sectionName: https hostnames: - {{ .Values.gateway.hostname | quote }} rules: - matches: - path: type: PathPrefix value: / timeouts: request: 0s backendRequest: 0s backendRefs: - name: {{ .Release.Name }} port: 5050---apiVersion: gateway.envoyproxy.io/v1alpha1kind: BackendTrafficPolicymetadata: name: {{ .Release.Name }}spec: targetRefs: - group: gateway.networking.k8s.io kind: HTTPRoute name: {{ .Release.Name }} timeout: http: requestTimeout: 0s maxStreamDuration: 0s---apiVersion: gateway.envoyproxy.io/v1alpha1kind: ClientTrafficPolicymetadata: name: {{ .Release.Name }}spec: targetRefs: - group: gateway.networking.k8s.io kind: Gateway name: {{ .Release.Name }} tls: alpnProtocols: ["h2", "http/1.1"] timeout: http: requestReceivedTimeout: 0s streamIdleTimeout: 0s{{- end }}These are Envoy Gateway v1.7.0 timeout fields. requestReceivedTimeout removes
the client request read deadline. streamIdleTimeout removes the stream
inactivity deadline in either direction, including response sends.
The route timeouts and backend policy remove the upstream response deadline
and maximum stream duration. Here 0s disables each limit. This permits long
subscriptions, including quiet periods. Apply the shared
security controls because idle streams can retain
resources. No body buffering, gRPC conversion, CORS rewrite, or retry policy is
added. All ingress checks still need a client test.
Create the certificate Secret separately from Helm. The required manifest has
this shape. Replace both placeholders with PEM data from your certificate
provider before applying it. tls.crt must contain the certificate chain.
Do not commit the populated file or send it to Helm as a value.
apiVersion: v1kind: Secretmetadata: name: xmtp-tls namespace: xmtptype: kubernetes.io/tlsstringData: tls.crt: | REPLACE_WITH_PEM_CERTIFICATE_CHAIN tls.key: | REPLACE_WITH_PEM_PRIVATE_KEYOptional monitoring and scaling
Section titled “Optional monitoring and scaling”{{- if .Values.serviceMonitor.enabled }}apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: {{ .Release.Name }} labels:{{ toYaml .Values.serviceMonitor.labels | indent 4 }}spec: selector: matchLabels: app.kubernetes.io/instance: {{ .Release.Name }} endpoints: - port: metrics path: /metrics interval: 30s{{- end }}{{- if .Values.autoscaling.enabled }}---apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: {{ .Release.Name }}spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: {{ .Release.Name }} minReplicas: {{ .Values.autoscaling.minReplicas }} maxReplicas: {{ .Values.autoscaling.maxReplicas }} metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}{{- end }}{{- if .Values.podDisruptionBudget.enabled }}---apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: {{ .Release.Name }}spec: minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} selector: matchLabels: app.kubernetes.io/instance: {{ .Release.Name }}{{- end }}Save this second value set beside the chart. Match serviceMonitor.labels to
your Prometheus selector. The monitor selects Services in its own namespace.
The Gateway exposes only the application port. Apply network policy or cluster
network controls to keep the metrics port limited to monitoring clients.
gateway: enabled: trueserviceMonitor: enabled: true labels: release: prometheusautoscaling: enabled: truepodDisruptionBudget: enabled: trueThe CPU request enables utilization-based scaling. CPU is only a starting
signal; validate capacity with subscription load. Size the database for
maxReplicas plus the rolling update surge, using the shared
connection budget. Terminating pods can
keep connections during the grace period, so reserve capacity for them too.
The PDB limits voluntary evictions. It does not control Deployment rollouts or
prevent node failures. With one replica, minAvailable: 1 blocks an eviction.
Use at least two replicas when you enable it.
Install
Section titled “Install”Set an immutable image tag in values.yaml, and prepare the database Secret and
access controls, before you run these commands.
Install the pinned controller, which includes its Gateway API and Envoy policy CRDs:
helm install eg oci://docker.io/envoyproxy/gateway-helm \ --version v1.7.0 --namespace envoy-gateway-system --create-namespace \ --wait=watcher --timeout 10mkubectl create namespace xmtpkubectl apply -f certificate.yamlUse a certificate renewal process that updates xmtp-tls before expiry.
For an existing namespace, omit kubectl create namespace. Create the database
Secret there before the next command. Install Prometheus Operator and Metrics
Server before using enabled.yaml; otherwise enable only the Gateway with
--set gateway.enabled=true in place of -f enabled.yaml.
helm upgrade --install xmtp ./xmtp-chart --namespace xmtp \ -f enabled.yaml --rollback-on-failure --wait=watcher --timeout 15mkubectl -n xmtp rollout status deployment/xmtp --timeout=15mkubectl -n xmtp get gateway xmtpkubectl -n xmtp get httproute xmtp -o yamlkubectl -n xmtp get backendtrafficpolicy,clienttrafficpolicyThese commands use Helm 4 spellings: --rollback-on-failure replaces
--atomic, and --wait=watcher names the wait strategy. The 15-minute Helm
budget exceeds the startup probe budget. Check Gateway Accepted and
Programmed, route Accepted and ResolvedRefs, and policy Accepted
conditions. Helm readiness alone does not prove that the route works.
Point the DNS name at the Gateway address. Use https://xmtp.example.com as the
SDK backend URL. For a configuration edit, change values.yaml and repeat the
upgrade command. The checksum causes a rollout. An external Secret change does
not change the checksum; restart the Deployment to refresh its environment.
Check migration limits before any
image upgrade.
Local manifest checks
Section titled “Local manifest checks”Run these checks from the directory that contains xmtp-chart and
enabled.yaml. They need no cluster. Use Python 3 with PyYAML to create local
JSON schemas from the pinned upstream CRDs:
import jsonfrom pathlib import Pathfrom urllib.request import urlopen
import yaml
sources = { "gateway.networking.k8s.io": ( "https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/" "v1.4.1/config/crd/standard/", ["gatewayclasses", "gateways", "httproutes"], ), "gateway.envoyproxy.io": ( "https://raw.githubusercontent.com/envoyproxy/gateway/" "v1.7.0/charts/gateway-helm/crds/generated/", ["backendtrafficpolicies", "clienttrafficpolicies"], ), "monitoring.coreos.com": ( "https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/" "v0.89.0/example/prometheus-operator-crd/", ["servicemonitors"], ),}
def strict(schema): if isinstance(schema, dict): if "properties" in schema and "additionalProperties" not in schema: schema["additionalProperties"] = False for value in schema.values(): strict(value) elif isinstance(schema, list): for value in schema: strict(value)
for group, (base, resources) in sources.items(): for resource in resources: with urlopen(f"{base}{group}_{resource}.yaml") as response: crd = yaml.safe_load(response) for version in crd["spec"]["versions"]: if not version["served"]: continue schema = version["schema"]["openAPIV3Schema"] strict(schema) # Kubernetes supplies metadata outside the custom resource schema. schema["properties"]["metadata"] = {"type": "object"} kind = crd["spec"]["names"]["kind"].lower() path = Path("schemas") / group / f"{kind}_{version['name']}.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(schema))An unreachable source fails this script. Do not skip missing schemas. Run it, then check both named value sets with Kubernetes 1.34 schemas:
python3 prepare-schemas.pyhelm lint --strict ./xmtp-charthelm lint --strict ./xmtp-chart -f enabled.yamlUse Bash with pipefail so a failed render also fails the pipeline:
set -o pipefailhelm template xmtp ./xmtp-chart --namespace xmtp --kube-version 1.34.0 | \ kubeconform -strict -summary -kubernetes-version 1.34.0 \ -schema-location default \ -schema-location 'schemas/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'helm template xmtp ./xmtp-chart --namespace xmtp --kube-version 1.34.0 \ -f enabled.yaml | \ kubeconform -strict -summary -kubernetes-version 1.34.0 \ -schema-location default \ -schema-location 'schemas/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'Clean up
Section titled “Clean up”Each platform terminates TLS and programmes DNS differently, so check TLS at the Gateway, external DNS, gRPC-Web and CORS through your controller, and the route timeouts against the shared ingress checks after you install.
Remove a test install with:
helm uninstall xmtp --namespace xmtpkubectl -n xmtp delete secret xmtp-tlshelm uninstall eg --namespace envoy-gateway-systemUninstall the controller only if this test owns it. Confirm that its cloud load balancer is gone. Helm can leave CRDs behind. The external database, database Secret, DNS record, and certificate renewal process are not chart resources. Remove disposable test resources separately; retain production data.
As a legacy footnote, ingress-nginx is retired. Its best-effort maintenance ended in March 2026. Do not use it for a new installation. See the retirement notice.

