Table of Contents
- matrix-synapse
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- One thing Gateway mode makes easier, and it is the awkward part of Matrix delegation
- What Gateway mode costs this chart, and one of the two is not what it looks like
- ingress.domain needs its own guard
- Two hostnames that are not interchangeable
- Without delegation, federation likely does not work
- Changing the item does not restart Synapse
- The server name is decided once, and not by matrix.serverName
- What a Ready pod proves — and what it does not
- The volume belongs to this release
- One replica, one volume, one config
- Why two policies and not one
- Federation cannot be narrowed, and narrowing the probe ports can stop it
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
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 generateproduces them; the chart does not. - A reachable database as named in that file. Nothing here deploys one.
- cert-manager with the
ClusterIssuernamed iningress.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.serverUrlis the delegating host — the domain in every user ID (@user:example.com). Remote servers look up.well-knownhere, so this name must equal theserver_nameof the running homeserver.ingress.domainis 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
/databefore every app-version bump. Schema migrations run at start and are one-way; ahelm rollbackreturns 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.failureThresholdbefore 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
- Chart style guide — §12.1 is written from this chart
- Versioning — what a bump of
7.1.0+upv1.159.0means - Renovate — the nginx even-minor constraint, and the unmanaged CI fixture
- Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline