4
matrix synapse
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

matrix-synapse

Deploys Matrix Synapse as a single-replica StatefulSet whose entire configuration is one homeserver.yaml out of a 1Password item, plus an optional second workload: an nginx Deployment that serves the /.well-known/matrix/* delegation documents. No database is part of this chart.

Image ghcr.io/element-hq/synapse
Versions not listed on purpose — read the current pair off matrix-synapse/Chart.yaml, see Chart index
Workloads StatefulSet (1 replica); optional delegation nginx Deployment + Service
Storage one RWO PVC, two subPaths: /data and /media_store
Routing two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled). ingress.domain survives both gates — see §12.1. The Ingress is one object with two host rules; Gateway mode is two HTTPRoute objects — see Routing. In Gateway mode the media upload limit is not enforced
External dependencies whatever database: in the homeserver.yaml names (external PostgreSQL, or SQLite under /data)
Secrets 1 × OnePasswordItem
Install-test ci/matrix-synapse/ (fixtures + seed job + test values, INSTALL_TIMEOUT=600)

Prerequisites

  • 1Password operator in the cluster, plus one item carrying a field named literally homeserver.yaml — see the fields below.
  • A complete Synapse configuration, including a signing key and a log config on the volume. synapse generate produces them; the chart does not.
  • A reachable database as named in that file. Nothing here deploys one.
  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for both hostnames pointing at the ingress controller when the delegation is on. In Gateway mode the cluster issuer is not needed from this release: both certificates sit on the Gateway's listeners (two listeners, or one wildcard covering both names). What is needed instead is the Gateway-API CRDs plus a Gateway whose listener admits routes from this namespace — and since Matrix federation is HTTPS-only, a listener without a valid certificate for these names is a federation outage that no probe in this release can see.
  • The server name decided before the first start. It cannot be changed afterwards — see The server name is decided once.

Installation

The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.

helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update

helm install synapse jlab/matrix-synapse \
  --namespace matrix --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml:

secrets:
  configSecretRef: op://vault/matrix-synapse-config

matrix:
  serverName: example.com
  delegation:
    enabled: true
    serverUrl: example.com

ingress:
  domain: matrix.example.com
  clusterIssuer: letsencrypt

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — including a warning when matrix.serverName and matrix.delegation.serverUrl disagree. Read it; it is the per-release version of this page.

What the 1Password item must contain

The chart renders one OnePasswordItem; the operator turns it into a Kubernetes Secret whose keys are the field names of the item. This chart reads exactly one of them.

Values key Kubernetes Secret Fields
secrets.configSecretRef <release>-matrix-synapse-config-secret homeserver.yaml — the complete Synapse configuration file, mounted read-only at /data/homeserver.yaml

A field spelled differently produces a Secret without that key: the pod never starts, and the reason appears only as a volume error in kubectl describe pod — the Secret itself exists, so the operator looks as if it had done its job.

Four settings inside that file are dictated by the chart's mounts, and getting them wrong is not a config error the app reports clearly:

Setting Must be Why
media_store_path /media_store The second PVC subPath. The root filesystem is read-only, so a path outside /data, /media_store and /tmp fails on the first upload.
signing_key_path under /data The federation identity. Losing it makes this server a stranger to every server that ever spoke to it.
log_config under /data Anywhere else is unwritable.
a listener on port 8008, type http — The Service and all three probes target that port by name.

/data is the PVC and homeserver.yaml is mounted on top of it: a homeserver.yaml already lying in the volume (from a synapse generate run, for instance) is shadowed and never read. What runs is the item's content.

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders every workload with replicas: 0. Takes precedence over both replicas values. PVC, Secret, Services and Ingress stay.
global object {} Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it.

secrets

Key Type Default Description
secrets.configSecretRef string null Required. op:// path of the item holding homeserver.yaml.

matrix

Key Type Default Description
matrix.replicas int 1 Replica count; 0 is allowed. See One replica, one volume, one config.
matrix.serverName string null Required. Rendered into SYNAPSE_SERVER_NAME — which the running server does not read. See The server name is decided once.
matrix.timezone string UTC TZ on the container.
matrix.securityContext.pod object non-root 1000/1000, fsGroup: 1000, fsGroupChangePolicy: Always The uid matches the gosu default the upstream image used before the chart pinned a user, so existing PVC data stays accessible.
matrix.securityContext.container object readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root With an explicit non-root user the image skips its own gosu privilege drop and runs directly as that user.
matrix.livenessProbe object GET /health on port http, period 10s, timeout 5s, 3 failures
matrix.readinessProbe object GET /health on port http, period 10s, timeout 5s, 3 failures
matrix.startupProbe object GET /health on port http, period 10s, 30 failures (= 300s) Gates liveness and readiness; covers migrations and cache warmup.
matrix.env list [] Appended after SYNAPSE_SERVER_NAME, SYNAPSE_REPORT_STATS (no) and TZ. Rendered through tpl; value, secretKeyRef, configMapKeyRef. Additive, not overriding (§10) — and Synapse takes its real configuration from homeserver.yaml, not from the environment.
matrix.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
matrix.resources.requests object cpu: 1, memory: 1Gi, ephemeralStorage: 1Gi
matrix.resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.

matrix.delegation

Key Type Default Description
matrix.delegation.enabled bool false Renders the nginx ConfigMap, Deployment and Service, and adds the second Ingress host. Off by default — and off usually means federation does not work.
matrix.delegation.replicas int 1 Replica count; 0 is allowed and makes the .well-known documents 404 while remote servers keep asking.
matrix.delegation.serverUrl string null Required while the delegation and the Ingress are enabled. The delegating host — the domain in every user ID. With the Ingress off it is rendered into no object at all.
matrix.delegation.image.repository string nginx Auxiliary image, pinned independently of appVersion. Renovate holds it to even (stable) nginx minors — see Renovate.
matrix.delegation.image.tag string 1.30.4
matrix.delegation.securityContext.pod object non-root 101/101 The image's unprivileged nginx user.
matrix.delegation.securityContext.container object readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root The generated nginx.conf redirects the pid file and every temp path into the /tmp emptyDir and listens on the unprivileged port 8080.
matrix.delegation.livenessProbe object GET /.well-known/matrix/server on port http, period 10s, timeout 3s, 3 failures Weak by construction — see What a Ready pod proves.
matrix.delegation.readinessProbe object same, period 10s, timeout 3s, 3 failures
matrix.delegation.startupProbe object same, period 5s, 30 failures (= 150s)
matrix.delegation.resources.limitFactor number 3
matrix.delegation.resources.requests object cpu: 125m, memory: 64Mi, ephemeralStorage: 50Mi
matrix.delegation.resources.limits object unset

The delegation Deployment has no env block — it is the one container in this repo configured purely by its ConfigMap.

storage

Key Type Default Description
storage.amount string 10Gi Size of the single PVC. Not null-safe on purpose: an erased block yields an invalid PVC instead of a degraded workload.
storage.storageClass string longhorn-encrypted Storage class of the PVC.

ingress

Key Type Default Description
ingress.enabled bool true false omits the Ingress and frees clusterIssuer — but not domain. Mutually exclusive with httpRoute.enabled.
ingress.domain string null Required while the Ingress, the HTTPRoute or the delegation is enabled. Where Synapse actually answers, the value baked into both .well-known documents, and the homeserver HTTPRoute's hostname while httpRoute.hostnames is empty.
ingress.clusterIssuer string null Required while the Ingress is enabled. cert-manager ClusterIssuer. Unused and unnecessary in Gateway mode.
ingress.className string nginx Ingress class. No counterpart in Gateway mode — the class sits on the Gateway.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode, and for this chart that matters twice over: the live Ingress carries a body size here and a configuration-snippet. See What Gateway mode costs.

TLS secrets: tls-<release>-matrix-synapse-ingress for ingress.domain, and tls-<release>-matrix-synapse-delegation for matrix.delegation.serverUrl. In Gateway mode the chart renders neither — both certificates belong on the Gateway's listeners.

httpRoute

The Gateway-API alternative to the Ingress, off by default. Added with group 3 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).

Key Type Default Description
httpRoute.enabled bool false Renders <release>-matrix-synapse-httproute and — while matrix.delegation.enabled — <release>-matrix-synapse-delegation-httproute, instead of the one Ingress. Mutually exclusive with ingress.enabled: with both true the render aborts naming both values.
httpRoute.parentRefs list [] The Gateway(s) the homeserver route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases.
httpRoute.hostnames list [] Empty means [ingress.domain], so the homeserver hostname has a single source of truth. Does not cover the delegation host — deliberately; see below.
httpRoute.delegation.parentRefs list [] The delegation route's own parent. Empty falls back to httpRoute.parentRefs. Worth setting, because the server name is usually an apex domain published by a different Gateway — somebody else's website normally owns it.
httpRoute.delegation.hostnames list [] Empty means [matrix.delegation.serverUrl], and it deliberately does not fall back to httpRoute.hostnames: the server name is what remote homeservers resolve and is a different thing from the homeserver's own hostname. The migration order depends on telling them apart — move the homeserver host first, confirm it answers, move the server name last. A chart-global list covering both would erase that distinction.

networkPolicy

Two objects, one per workload. Synapse: inbound 8008 only; outbound DNS, TCP 5432 (Postgres), 443 and 8448 (federation, remote media, push gateways, identity servers). The delegation nginx: inbound 8080 only; outbound DNS and nothing else. One probeSources and one ingressController govern both; egress.extraPorts deliberately reaches only Synapse.

Value Type Default Meaning
networkPolicy.enabled bool true Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured.
networkPolicy.allowedNamespaces list [] Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it.
networkPolicy.probeSources list [] CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready.
networkPolicy.ingressController object kube-system / app.kubernetes.io/name: rke2-traefik The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed.
networkPolicy.egress.extraPorts list [] Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off.

Operating notes

Routing: Ingress or Gateway API

Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.

The shapes are not symmetrical, and that is forced rather than chosen. The Ingress is one object with two host rules:

ingress.domain               /                     synapse service
matrix.delegation.serverUrl  /.well-known/matrix   delegation service

In the Gateway API that has to become two HTTPRoute objects, because spec.hostnames applies to the whole object — one route can carry several hostnames only when they want the same rules, and these two want different paths and different backends.

#114 describes this chart as rendering its Ingresses "in a loop over several apps". It does not; that is freesailarr. This chart renders one object with two host rules.

Rule Why
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths two data paths onto one Service under one hostname is a conflict at DNS level already. For Matrix it is worse: /.well-known/matrix tells every other homeserver in the federation where to find this one, so a server name resolving to two data paths makes federation succeed or fail per remote server and per retry. A migration runs over a second hostname, and the delegation host moves last, after the homeserver host answers.
httpRoute.parentRefs must not be empty — the render aborts a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer — which here means federation is down, not just the web client.
The delegation route is gated on matrix.delegation.enabled the same switch the Ingress's second host rule uses. Without the delegation workload there is nothing to route to.
No URLRewrite filter in either mode the delegation nginx answers on the full /.well-known/matrix/* path, so nothing is stripped — which is also why the #118 prefix/redirect assurance has no precondition to meet here.

One thing Gateway mode makes easier, and it is the awkward part of Matrix delegation

The server name is normally an apex domain that already serves a website. Under ingress-nginx two Ingress objects cannot both claim one hostname — the admission webhook rejects the second claim on the same host and path — which is exactly why the delegation rule has to live inside this chart's single Ingress object rather than beside it.

HTTPRoutes do not work that way: several routes may attach to the same listener for the same hostname, and the implementation merges them by match specificity, a longer path winning. So in Gateway mode the /.well-known/matrix route can sit next to whatever else owns that apex, without either side knowing about the other. It is one of the two places in the whole sweep where the Gateway API is a better fit rather than a translation (the other is freesailarr's per-rule rewrite).

What Gateway mode costs this chart, and one of the two is not what it looks like

Measured on the live Ingress on 2026-10-12 — all of it operator-supplied through ingress.annotations; this chart sets none of it:

nginx.ingress.kubernetes.io/proxy-body-size:    50m
nginx.org/client-max-body-size:                 50m
nginx.ingress.kubernetes.io/configuration-snippet: |
  more_set_headers "X-Forwarded-For $remote_addr";
  more_set_headers "X-Forwarded-Proto $scheme";
  more_set_headers "Host $host:$server_port";

1. The body size is the real loss. Synapse's media repository takes uploads and its own ceiling is max_upload_size in homeserver.yaml — which this chart cannot see, because the whole configuration arrives from 1Password. The effective limit is the smaller of the two, and in Gateway mode the proxy half becomes the Gateway's default: neither number anyone can point at is necessarily the one that decides. There is no body-size field in the Gateway API — it is implementation policy, not routing — so the limit is documented rather than rebuilt. Set the Gateway's half to match max_upload_size before pointing DNS at it. (nginx.org/client-max-body-size is the same limit spelled for NGINX Inc's ingress controller rather than ingress-nginx — a hedge across both — and it goes for the same reason.)

2. The snippet is not load-bearing, and that took checking rather than assuming. It looks like what Synapse's x_forwarded setting depends on — client IP for rate limiting, and federation. It is not: more_set_headers is the headers-more module's output-header directive ("Replaces (if any) or adds (if not any) the specified output headers"), while the request-side directive is a different one, more_set_input_headers ("it operates on input headers (or request headers)"). So those three headers are added to the response to the client; Synapse never sees them, and what it reads with x_forwarded: true is the request's X-Forwarded-For, which ingress-nginx populates itself.

So for a migration: not translatable, and not needed. The Gateway API's ResponseHeaderModifier could set static response headers but knows no nginx variables ($remote_addr, $scheme, $host), so rebuilding it is impossible anyway — and pointless. Do not re-create it at the Gateway in the belief that Synapse needs it, and do not read its absence as the cause of a rate-limiting or federation problem.

This is a reading of the directive's documented semantics, stated as such: nothing here observed a Synapse instance with and without the snippet.

ingress.domain needs its own guard

This chart is the reason §12.1 exists. The delegation nginx serves ingress.domain inside /.well-known/matrix/{server,client} — the endpoints through which federation finds the homeserver. Putting the Ingress behind ingress.enabled without a second required() on the domain would publish

{"m.server": ":443"}     {"m.homeserver": {"base_url": "https://"}}

— rendered cleanly, pod green, federation broken. The ConfigMap therefore carries its own required(), independent of the Ingress gate.

Two hostnames that are not interchangeable

With the delegation on, the Ingress publishes two hosts and the chart cannot check that you got the split right:

  • matrix.delegation.serverUrl is the delegating host — the domain in every user ID (@user:example.com). Remote servers look up .well-known here, so this name must equal the server_name of the running homeserver.
  • ingress.domain is where Synapse actually answers. It appears inside the documents and needs no relation to the server name.

Only the path /.well-known/matrix is routed to nginx on serverUrl; everything else on that host is not served by this release. Both hosts need a certificate, and cert-manager only issues the second one once serverUrl resolves to this cluster — a pending certificate there is a federation outage, not a cosmetic problem. Check what is really served, from outside the cluster:

curl -s https://<serverUrl>/.well-known/matrix/server

Without delegation, federation likely does not work

With matrix.delegation.enabled: false nothing here serves /.well-known/matrix/server. A remote server that finds no such document falls back to port 8448 of the server name — and this release exposes no 8448 anywhere: the container publishes 8008 only, and the Service maps port 80 onto it. Unless the delegation is served by something else (a static .well-known on the server-name host, or an SRV record), federation stays broken while clients on ingress.domain work fine.

With ingress.enabled: false the documents exist only on the in-cluster Service <release>-matrix-synapse-delegation-service; whatever terminates TLS in front of the cluster has to route https://<server name>/.well-known/matrix there. Matrix federation is HTTPS-only, so nothing reaches this homeserver until something does.

Changing the item does not restart Synapse

Pointing secrets.configSecretRef at a different item changes the rendered StatefulSet and rolls the pod. Changing the content inside the same item does not: Helm never sees that content, so nothing in the pod template changes — and because the file is a subPath mount, the kubelet does not refresh it in the running container either. Both halves have to be undone by hand:

kubectl -n <namespace> rollout restart statefulset/<release>-matrix-synapse-sfs

The operator's operator.1password.io/auto-restart annotation is not a substitute and is deliberately not set: it only ever restarts Deployments, and this is a StatefulSet (verified against onepassword-operator 1.12.0) — setting it would advertise a guarantee that does not exist. See style guide §9.

The delegation nginx is the opposite case: its ConfigMap is hashed into the pod template (checksum/config), so a changed ingress.domain rolls it automatically.

The server name is decided once, and not by matrix.serverName

matrix.serverName is rendered into SYNAPSE_SERVER_NAME, which the image only consults when it generates a configuration — which is not what happens here. The running server reads its name from server_name: in the mounted homeserver.yaml. Changing matrix.serverName alone rolls the pod and changes nothing else.

And the name cannot be changed afterwards at all: it is baked into every user ID, room ID and event this server has ever signed. A server that needs a different name is a new server with a new database, not an upgrade. Decide it before the first start — together with matrix.delegation.serverUrl, which has to match it.

What a Ready pod proves — and what it does not

All three Synapse probes hit /health, which answers 200 as soon as the HTTP listener is up. It says nothing about the database, the signing key or federation: a homeserver.yaml pointing at an unreachable database usually fails at start (CrashLoopBackOff, log only), but every problem that appears later leaves the pod Ready.

The delegation probe is weaker still. It requests /.well-known/matrix/server, and that endpoint is a static return 200 in the generated nginx.conf — it passes with a wrong ingress.domain baked into the document. Read the body, not the probe.

The volume belongs to this release

/data (the SQLite database if used, the signing key, the log config) and /media_store (all uploaded media) are two subPaths of one ReadWriteOnce claim, <release>-matrix-synapse-pvc. It is an ordinary PVC in the release, not a volumeClaimTemplate, and it carries no resource-policy annotation: helm uninstall deletes it, and with a Delete reclaim policy the media and the signing key go with it. Adding the annotation to the live PVC afterwards does not help — Helm reads it from the release manifest. Back the volume up first.

Growing storage.amount only takes effect if the StorageClass allows volume expansion; shrinking it is refused by Kubernetes and fails the next upgrade on the PVC instead of touching the data. Media is what grows, and it grows without an upper bound unless the homeserver.yaml sets retention.

Files in the volume owned by another uid — restored from a backup, written by an older release — are not readable, and the signing key (mode 0600) is the first thing to fail.

Everything else the process writes goes to /tmp, an emptyDir that counts against the container's ephemeral-storage limit. Raise matrix.resources.requests.ephemeralStorage to move it; the limit follows through matrix.resources.limitFactor.

One replica, one volume, one config

Every pod of the StatefulSet mounts the same RWO claim and the same homeserver.yaml — there is no volumeClaimTemplate and no per-pod configuration. Additional pods either stay Pending on a node without the volume, or start as a second full homeserver on the same data. Scaling Synapse means running workers with distinct configurations, which this chart does not deploy.

Why two policies and not one

The two workloads' needs are opposite. Synapse federates and must be able to reach any homeserver there is; the delegation nginx returns two static JSON documents and calls nobody. A single policy selecting both pods would have to permit the union — handing full federation reach and the database port to an nginx whose entire job is to return a constant — and it would read as one decision rather than as a widening.

networkPolicy.egress.extraPorts does not reach the delegation policy for the same reason: an entry meant for Synapse's database has no business widening that.

Federation cannot be narrowed, and narrowing the probe ports can stop it

A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination. So 443 and 8448 go to any destination — which is not laziness but the specification: a federating homeserver may contact any other one. What the cap still buys is that Synapse can open no other outbound port.

And mind what the two inbound ports carry. Inbound federation reaches Synapse through the Ingress, and every remote homeserver that wants to federate fetches /.well-known/matrix from the delegation server. Setting probeSources without a correct ingressController therefore does not merely hide a web UI: it stops federation and stops it being discoverable, and that failure appears on other people's servers before it appears on yours.

5432 is the standard Postgres port, not the configured one — the connection lives inside secrets.configSecretRef, which this chart never parses.

The probed port is open inside the cluster by default

networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.

It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.

Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.

The policy is only a boundary where the CNI enforces one

The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.

Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.

Upgrades and backup

  • Back up /data before every app-version bump. Schema migrations run at start and are one-way; a helm rollback returns the old image to a database the new one has already rewritten.
  • The signing key is the irreplaceable part. Snapshot it with the database and the media store, not separately.
  • Raise matrix.startupProbe.failureThreshold before a large jump: a kubelet restart mid-migration is the failure mode the 300s default budget is calibrated against.
  • App major jumps go stepwise (Versioning) — and a major is also the moment to bump the CI fixture image (below).
  • The delegation nginx tag is reviewed on every app-update round; Renovate offers only even (stable) nginx minors.

CI

ci/matrix-synapse/ holds test-values.yaml (op:// ref against the stub CRD, synapse.ci.local as server name, matrix.ci.local as domain, delegation on), fixtures.yaml and settings.env (INSTALL_TIMEOUT=600). Synapse needs a signing key and a log config on its PVC before it starts, so the claim is backed by a pre-bound hostPath PV and a seed Job runs synapse generate into that directory first, with UID/GID set to 1000 so the 0600 key is readable by the server container. The static homeserver.yaml in the fixture uses SQLite under /data.

That fixture pins a Synapse image Renovate does not follow (ci/** is deliberately unmanaged, so the throwaway infrastructure stays on its major lines). Bump ghcr.io/element-hq/synapse in ci/matrix-synapse/fixtures.yaml by hand when the chart moves a major, or the seed job generates a config for a different version than the one under test. See Renovate and CI/CD.

The Gateway mode is held at render level, by ci/httproute/matrix-synapse.yaml plus ci/run-httproute-match-test.sh, which runs over every chart on every PR (CI/CD). This chart is the one in its group where "both routing objects" means two different hostnames, two different paths and two different backends, and the overlay covers both: the fixture already sets ingress.domain and has the delegation on with matrix.delegation.serverUrl: ci.local, so the homeserver route renders on its host at / pointing at the Synapse Service, and the delegation route renders on its host matching PathPrefix /.well-known/matrix and pointing at the delegation Service. 4 assertions — a parentRefs and a backendRefs check per route — and both references are held against the Services of the same render by port number, because backendRefs takes a PortNumber and cannot name the port http the way the Ingress does.

An overlay rather than a fixture change, because the install-test runs one configuration per chart and this chart's fixture is the one with the pre-bound PV and the synapse generate seed job.

What that does not cover: that either hostname answers — which for Matrix is also whether federation works. No Gateway controller runs in CI, no route reaches Accepted: True, and Matrix federation is HTTPS-only while nothing in this mode renders a certificate. Nor the body-size limit, which has no counterpart in that mode; that loss is documented in values.yaml and printed by NOTES.txt, and documentation is not something a test can assert. Nothing in CI reads NOTES.txt: helm template does not emit it and no script under ci/ reads it, so its text carries review-only assurance.

See also