Table of Contents
- stalwart
- Prerequisites
- Installation
- First install: eight steps, none optional
- DNS a mail server cannot run without
- Configurable values
- Global
- secrets
- dataStore — the only part of Stalwart the chart configures
- bootstrap
- ports
- service
- nodes
- networkPolicy
- externalServices — Kubernetes only, these do not configure Stalwart
- certificate
- ingress
- httpRoute
- mtaSts — the MTA-STS policy routes
- securityContext and probes
- valkeySubchart and valkey (subchart)
- Never change valkey/ and stalwart/ in the same pull request
- Traps
- Fail-open: a node whose role is missing becomes a full node
- NET_BIND_SERVICE is needed to exec, not to bind
- Why externalTrafficPolicy must be Cluster
- The port duplication cannot be avoided
- Routing: Ingress or Gateway API
- The mail certificate stays in Gateway mode, and must not be cleaned up
- Gateway mode drops the CORS configuration, and that breaks the webmail
- MTA-STS: the chart registers a route, it does not create a domain
- MTA-STS on a subdomain: the parent's wildcard does not cover it
- One Ingress per cluster issuer, and why it cannot be one object
- The Ingress ban trap
- A missing config.json opens an ephemeral store and looks healthy
- The /healthz rate limit sets the probe intervals
- Empty probeSources means open, and that is the safer default
- The data store username is public and cannot be otherwise
- The recovery admin is one field, not two
- Durations are integer milliseconds, not 15s
- What a green pod proves here, and what it does not
- Distribution is a promise, and the price is a Pending pod
- The two node-inclusion policies have opposite defaults
- nodeAffinityPolicy can be dropped without a word
- A colliding spread constraint aborts the render
- Upgrades and backup
- CI
- See also
stalwart
Deploys Stalwart — an all-in-one mail server (SMTP, IMAP, POP3, JMAP, ManageSieve, plus CalDAV/CardDAV/WebDAV) — cluster-capable from the start, with roles rather than symmetry: one StatefulSet per entry of the nodes map, each passing its key in STALWART_ROLE.
Read the next paragraph before anything else, because it decides how this page is organised.
Since v0.16 Stalwart keeps its entire configuration inside the data store and manages it over the JMAP API — over 2 400 settings in 117 object types, including every listener, the blob store, the in-memory store, TLS objects, domains and the cluster roles. The only thing left on disk is a small config.json naming the data store: "It only needs to contain the DataStore object." The REST API from v0.15 is gone.
So this chart configures almost nothing, and that is its defining property, not a gap. It renders config.json, the workloads, the Services, the NetworkPolicies, the Ingress and a cert-manager Certificate. Domains, listeners, stores, TLS objects and cluster roles are operator work in the WebUI. That is why First install is eight steps long and why half of this page is Traps.
| Image | docker.io/stalwartlabs/stalwart (the glibc variant; -alpine is built from the same source and carries the same setcap, so it is not an escape from the capability deviation) |
| Versions | not listed on purpose — read the current pair off stalwart/Chart.yaml, see Chart index |
| Workloads | one StatefulSet per node group, plus one config-seeding init container each |
| Storage | no PVC. Both image volumes are emptyDirs — state lives in Postgres, S3 and Valkey |
| Routing | two mutually exclusive modes for the WebUI and JMAP only, Ingress (default) or Gateway API (httpRoute.enabled) — one object in either mode, pointed at a single node group (ingress.nodeGroup), never per group. SMTP, IMAP and ManageSieve pass through neither. In Gateway mode the CORS configuration the webmail depends on is gone — see Routing |
| External dependencies | Postgres (required), S3 (blob store), Valkey (in-memory store + cluster coordinator) |
| Secrets | up to 3 × OnePasswordItem (data store, recovery admin, S3) |
| Network | one NodePort Service per public port + per-group ClusterIP and headless Services + NetworkPolicy on by default |
| Subchart | optional valkey — this repo's own chart, from the published index, off by default and gated on valkeySubchart.enabled |
| Install-test | ci/stalwart/ — and it proves much less than a green run suggests, see CI |
Prerequisites
- Postgres, reachable from the cluster, with a database and a role for Stalwart. This is the data store and the chart will not render without its host.
- S3-compatible object storage for the blob store. The filesystem blob store is documented as "available only for single node installations", and RocksDB likewise — two nodes force both stores to be external.
- A Valkey (or Redis), for the in-memory store. Not optional in a cluster: the coordinator's
Defaultvariant "reuses the Redis connection already configured on the in-memory store", so a multi-node install has no coordination path without one. Either pointexternalServices.valkey.hostat an instance or setvalkeySubchart.enabled: trueto deploy this repo's own valkey chart as a subchart. Redis/Valkey is admissible as the in-memory store only — never as the data or blob store. - cert-manager, and for the mail certificate most likely a DNS-01 issuer — see TLS.
- 1Password operator, plus the items in What the 1Password items must contain.
- Inbound reachability on port 25 with matching reverse DNS. Nothing in the chart can provide this, and without it a mail server is not operable. See DNS.
- An HA proxy in front of the cluster that forwards 25, 465 and 993 and can set
send-proxy-v2. The whole source-address design rests on it; see WhyexternalTrafficPolicymust beCluster.
No full-text search service is needed: the search store has a Default variant that indexes into the data store ("no additional services are required"). No Elasticsearch, no Meilisearch — but the FTS load lands on the same Postgres, which belongs in the sizing.
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 mail jlab/stalwart \
--namespace mail --create-namespace \
-f my-values.yaml
A first-install my-values.yaml. Note bootstrap.enabled: true — it is required on a first install, for the reason in step 1:
bootstrap:
enabled: true # first install only; set to false in step 7
ingress:
domain: mail.example.com
clusterIssuer: letsencrypt-dns
certificate:
clusterIssuer: letsencrypt-dns
extraDnsNames:
- smtp.example.com
- imap.example.com
dataStore:
postgres:
host: postgres.db.svc.cluster.local
externalServices:
s3:
host: s3.example.com
valkey:
host: mail-valkey.mail.svc.cluster.local
secrets:
dataStoreSecretRef: op://vault/stalwart-datastore
recoveryAdminSecretRef: op://vault/stalwart-recovery-admin
s3SecretRef: op://vault/stalwart-s3
helm install prints the chart's NOTES.txt, which is the per-release version of this page: it names the node groups and their ports, the exact listener numbers to create, the ClusterRole names, the 1Password fields, and a loud banner for as long as recovery mode is on. Read it.
What the 1Password items must contain
One Kubernetes Secret key is created per field of the item, and the templates reference those keys by name — a missing field leaves the container in CreateContainerConfigError. An empty field fails the same way as a missing one: upstream's secret resolver treats an empty environment variable as not found.
| Value | Item fields | Notes |
|---|---|---|
secrets.dataStoreSecretRef |
password |
Only the password. The username cannot live here — see why |
secrets.recoveryAdminSecretRef |
recovery-admin |
The complete value admin:<password>, in one field. Not two fields — see why |
secrets.s3SecretRef |
access-key, secret-key |
Exposed to the container as STALWART_S3_ACCESS_KEY / STALWART_S3_SECRET_KEY for the BlobStore object to reference |
Changing what is inside an item restarts nothing. Helm never sees a secret's content, so there is nothing to hash. Repointing one of the values above at a different vault item does roll the pods — the chart stamps checksum/secret-ref over the rendered OnePasswordItems, which covers the op:// path. After rotating a password inside an item, restart by hand, once per group:
kubectl -n mail rollout restart statefulset/mail-stalwart-full-sfs
kubectl -n mail rollout restart statefulset/mail-stalwart-mx-sfs
The 1Password operator's auto-restart annotation is not set and must not be: verified against operator 1.12.0 it enumerates Deployments only, so on a StatefulSet it is a silent no-op advertising a guarantee it does not provide (§9).
First install: eight steps, none optional
-
bootstrap.enabled: true,nodes.mx.replicas: 0. The setup wizard everyone reaches for runs in bootstrap mode, and bootstrap mode is entered only when there is noconfig.json: "The first time Stalwart is started without aconfig.json, it enters bootstrap mode." This chart always renders one, so the wizard never appears. Against an empty database that would leave a server with no listeners, no domain and no administrator, and nothing to fix it with.The documented path for a templated
config.jsonis recovery mode: "templateconfig.jsononto the host, start Stalwart once withSTALWART_RECOVERY_MODE=1and aSTALWART_RECOVERY_ADMINsupplied from a secrets manager… and then restart Stalwart without the recovery variables." It opens the management endpoint on 8080 and keeps every mail port closed. With a second group running the wizard would be unusable anyway — each request would land on a random pod. -
Open the management UI and create the permanent administrator.
kubectl -n mail port-forward svc/mail-stalwart-full-http-service 8080:8080then
http://localhost:8080/admin. WithrecoveryAdminSecretRefset, the credential is the one in the vault item; without it, Stalwart generates a password and prints it to the pod log on every restart. -
Create the configuration objects in the WebUI. Blob store (S3), in-memory store (Valkey), coordinator (
Default— reuses the in-memory store connection), search store (Default— uses the data store), the TLS object from step 5, theNetworkListenerobjects,proxyTrustedNetworks, and oneClusterRoleper node group.The listener ports must match the release exactly, because the chart cannot create listener objects:
Listener bind published smtp25 node port 30025 submissions465 node port 30465 imaps993 node port 30993 http8080 not published (Ingress + probes) And the roles, named exactly
fullandmx. The example shape:{"name":"mx","tasks":{"@type":"DisableAll"},"listeners":{"@type":"EnableAll"}}Both fields accept
EnableAll,DisableAll,EnableSomeandDisableSome. The task types arestoreMaintenance,accountMaintenance,metricsCalculate,metricsPush,pushNotifications,searchIndexing,spamClassifierTraining,outboundMta,taskQueueProcessing,taskScheduler. OnlyspamClassifierTrainingmay run on at most one node. Note the object encoding: a set is a JSON object mapping each member totrue, not an array, and a duration is an integer count of milliseconds.Getting a role name wrong here does not fail — read Fail-open before continuing.
-
Configure HTTP forwarding, before the auto-ban notices the Ingress. Mandatory, not a tip — see The Ingress ban trap.
-
Point the TLS object at the mounted certificate.
certificate: /etc/stalwart-tls/tls.crt privateKey: /etc/stalwart-tls/tls.keycert-manager writes these into
tls-<release>-stalwart-mail. The volume is mountedoptional, so a pending challenge does not hold the pods inContainerCreating; until it is issued the mail ports serve a self-signed certificate. -
Set
send-proxy-v2on the HA proxy. All-or-nothing together with step 3. Without the header on a trusted network every connection hangs until timeout; with the header from an untrusted network Stalwart readsPROXY TCP4 …as an SMTP command. Test on a spare port with its own frontend and move 25 only once aReceived:line shows the real sender address. Background: WhyexternalTrafficPolicymust beCluster. -
Restart the pods — "a newly added listener binds only on the next start" — and set
bootstrap.enabled: falsein the same round. -
Raise
nodes.mx.replicas(only now that themxrole exists) and publish DNS.
DNS a mail server cannot run without
None of this is rendered by the chart; all of it is required.
| Record | Content |
|---|---|
| MX | the deployment's public name, pointing at the HA proxy |
| SPF | TXT "v=spf1 ..." naming the HA proxy's address as a permitted sender |
| DKIM | TXT at <selector>._domainkey — the key is generated in Stalwart (Domains → DKIM), so this record is published after step 3 |
| DMARC | TXT at _dmarc — start at p=none with rua= and tighten only once the reports are clean |
| Reverse DNS | PTR for the HA proxy's public address, matching its forward name |
The PTR is the one most often missed. It is not the node's and not the pod's: the HA proxy is what other mail servers see connecting. Many receivers reject outright when it is absent, and nothing in Kubernetes can set it — it belongs to whoever owns the IP range.
Configurable values
Global
| Value | Default | Meaning |
|---|---|---|
scaleDown |
false |
true renders every group with replicas: 0 and overrides every nodes.*.replicas. Services, Ingress, Secrets and the Certificate stay, so no certificate is re-issued |
limitFactor |
3 |
Chart-wide default limit factor; a group's resources.limitFactor wins |
secrets
| Value | Default | Meaning |
|---|---|---|
secrets.dataStoreSecretRef |
null |
op:// path; field password. Referenced from config.json by variable name, so the password never enters the ConfigMap |
secrets.recoveryAdminSecretRef |
null |
op:// path; field recovery-admin holding admin:<password>. null switches STALWART_RECOVERY_ADMIN off — the correct end state once a permanent admin exists |
secrets.s3SecretRef |
null |
op:// path; fields access-key, secret-key. Exposed as env vars for the BlobStore object to reference |
dataStore — the only part of Stalwart the chart configures
Rendered verbatim into config.json as the DataStore object, "@type": "PostgreSql".
| Value | Default | Meaning |
|---|---|---|
dataStore.postgres.host |
null |
Required, no default |
dataStore.postgres.port |
5432 |
|
dataStore.postgres.database |
stalwart |
|
dataStore.postgres.username |
stalwart |
Rendered as authUsername. Public by necessity — why |
dataStore.postgres.tls.enable |
true |
Rendered as useTls. The opposite of upstream's default, deliberately |
dataStore.postgres.tls.allowInvalidCerts |
false |
Only ever a temporary diagnostic |
dataStore.postgres.poolMaxConnections |
10 |
|
dataStore.postgres.timeoutMs |
15000 |
Integer milliseconds. Named for its unit — why |
dataStore.postgres.options |
null |
Extra libpq options, passed through; null omits the field |
bootstrap
| Value | Default | Meaning |
|---|---|---|
bootstrap.enabled |
false |
STALWART_RECOVERY_MODE=1. true for a first install, then false. Leaving it on keeps every mail port closed while all Kubernetes objects look healthy |
bootstrap.logLevel |
info |
STALWART_RECOVERY_MODE_LOG_LEVEL; no effect once disabled |
ports
A map, user-extensible: adding 587, 143, 110, 995 or 4190 is an additive change. Two numbers per entry and they are not interchangeable — see The port duplication.
| Entry | containerPort |
nodePort |
public |
|---|---|---|---|
smtp |
25 |
30025 |
true |
submissions |
465 |
30465 |
true |
imaps |
993 |
30993 |
true |
http |
8080 |
— | false |
service
| Value | Default | Meaning |
|---|---|---|
service.externalTrafficPolicy |
Cluster |
Local would drop every packet in this topology — why |
service.annotations |
{} |
Applied to every Service the chart renders |
nodes
A map, not a list, so that an override never depends on a position. The key is the cluster-role name and goes into STALWART_ROLE.
| Value | Default (full) |
Default (mx) |
Meaning |
|---|---|---|---|
nodes.<g>.replicas |
1 |
0 |
0 allowed; scaleDown wins |
nodes.<g>.exposedPorts |
[smtp, submissions, imaps, http] |
[smtp, http] |
Keys of ports; must include http (all probes target it) |
nodes.<g>.spreadAcrossNodes |
true |
true |
One pod of this group per node, enforced. Rendered only from two computed replicas upwards — what it costs |
nodes.<g>.podDisruptionBudget.enabled |
true |
true |
A PDB per group, rendered only from two computed replicas upwards |
nodes.<g>.podDisruptionBudget.minAvailable |
1 |
1 |
minAvailable, not maxUnavailable: the promise is about the service |
nodes.<g>.affinity |
{} |
{} |
Passed verbatim into this group's pod spec. Narrowing it below the replica count blocks the group — why that is the good outcome |
nodes.<g>.nodeSelector |
{} |
{} |
As above |
nodes.<g>.tolerations |
[] |
[] |
As above. Tolerating a taint does not raise the replica ceiling |
nodes.<g>.topologySpreadConstraints |
[] |
[] |
Appended after the chart's own and really AND-ed with it. A collision on kubernetes.io/hostname aborts the render — why |
nodes.<g>.resources.requests |
200m / 512Mi / 64Mi |
100m / 256Mi / 64Mi |
Limits computed by limitFactor |
nodes.<g>.env |
[] |
[] |
Additive; value, secretKeyRef, configMapKeyRef |
There is no nodes.<g>.tasks. Which tasks a role runs is a property of the ClusterRole object in the database and the chart cannot write it; a values key that looked like it set them and did not would be worse than its absence.
High availability, per group
spreadAcrossNodes: true renders one topologySpreadConstraints entry for the group — maxSkew: 1 on kubernetes.io/hostname, whenUnsatisfiable: DoNotSchedule, nodeAffinityPolicy: Ignore — with a labelSelector the chart fills from its own pod labels. podDisruptionBudget.enabled: true renders <release>-stalwart-<group>-pdb with the same selector. They are two halves of one answer: the constraint decides that the pods sit on different nodes, the budget decides that only one of those nodes may be emptied at a time.
The distribution is only ever within a group. The selector names the release and the node group, so full pods are spread against full pods and mx pods against mx pods. A full and an mx pod on the same node is deliberately fine — they are different roles, not redundant copies. There is no cross-group condition, and that was decided rather than overlooked.
Both objects are conditional on the computed replica count (so scaleDown: true removes both). With full: 2 and mx: 1 exactly one PDB and one spread constraint exist. NOTES.txt prints the outcome per group — enforced / not rendered / off, and for the PDB its minAvailable or the reason it is absent — so it never has to be inferred from kubectl get pdb.
The selector is the chart's, never yours. stalwart.groupLabels is the single definition behind the pod template's labels, the spread constraint's labelSelector and the PDB's selector, and the reason is that a selector matching no pod fails silently in both objects: the constraint becomes a condition over zero pods and is trivially satisfied, the budget never blocks an eviction while looking like protection. ci/run-selector-match-test.sh holds all three against each other on every pull request — see the selector-match job.
Scaling a group from 1 to 2 replaces the running pod. The constraint lives in the pod template, so the change is a rollout and not merely an addition. Expect a restart of the node that is already up, in the same round.
And one precondition that is not in values.yaml: this works because the StatefulSets have no volumeClaimTemplates. If the open question about /var/lib/stalwart ever turns into a PVC per group, a node-local or topology-bound storage class pins each pod to wherever its volume was first provisioned — distribution is then decided at provisioning time rather than by this rule, and DoNotSchedule can keep a returning pod Pending for good. The rule holds only while this chart stays PVC-free.
networkPolicy
| Value | Default | Meaning |
|---|---|---|
networkPolicy.enabled |
true |
Only a boundary where the CNI enforces one |
networkPolicy.probeSources |
[] |
CIDRs allowed to reach http. Empty means open — why |
networkPolicy.allowedNamespaces |
[] |
Extra namespaces for http. Not for the ingress controller — use ingressController below; this value is namespace-granular, so naming the controller's namespace here would open http to every pod in it |
networkPolicy.ingressController |
kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach http once probeSources or allowedNamespaces restricts it. The two keys render as one from entry, i.e. an AND — which is the whole reason it is separate from allowedNamespaces. Default measured on the cluster |
networkPolicy.egress.dns |
true |
UDP+TCP 53 to anywhere |
networkPolicy.egress.smtp |
true |
TCP 25 to anywhere — outbound delivery |
networkPolicy.egress.extraPorts |
[] |
Extra outbound TCP ports; add 443 for DNS-01 provider APIs or AI endpoints |
Egress is restricted by port, not by destination: a NetworkPolicy matches CIDRs and selectors, never hostnames, so no host value from this chart can appear in it. What it still buys is that a compromised pod cannot open arbitrary outbound ports. Unlike most charts in the repository these are the configured ports and not a guess at the standard ones, because this chart holds them as values (dataStore.postgres.port, externalServices.*.port).
Correction, 2026-10-14 (Gitea #5). This page and the chart's own
values.yamlused to advise putting the ingress controller's namespace intoallowedNamespaces, with["ingress-nginx"]as the example. Both halves of that are now wrong. Measured on the live cluster:ingress-nginxis gone — no DaemonSet, no pods — the onlyIngressClassistraefikand it is the default, and the controller is the DaemonSetrke2-traefikinkube-system. And even with the right name,allowedNamespaceswould have openedhttpto every pod in that namespace.networkPolicy.ingressControllerreplaces the advice and narrows the source to the controller's pods. A policy written against the old controller would have been accepted, would have looked strict, and would have silently blocked all web traffic.
externalServices — Kubernetes only, these do not configure Stalwart
| Value | Default | Used for |
|---|---|---|
externalServices.s3.host |
"" |
the NOTES.txt checklist only |
externalServices.s3.port |
443 |
the egress rule |
externalServices.valkey.host |
"" |
the render-abort check and the checklist; empty + valkeySubchart.enabled resolves to <release>-valkey-service |
externalServices.valkey.port |
6379 |
the egress rule |
Setting a host here does not point Stalwart at it. The blob store and in-memory store are database objects created in the WebUI.
certificate
| Value | Default | Meaning |
|---|---|---|
certificate.enabled |
true |
|
certificate.clusterIssuer |
null |
Falls back to ingress.clusterIssuer; usually a different, DNS-01 issuer |
certificate.extraDnsNames |
[] |
ingress.domain is always included |
certificate.mountPath |
/etc/stalwart-tls |
A top-level path so no mount nests inside the config emptyDir |
ingress
| Value | Default | Meaning |
|---|---|---|
ingress.enabled |
true |
Gates the Ingress only; ingress.domain stays required either way (the mail Certificate reads it). Mutually exclusive with httpRoute.enabled |
ingress.clusterIssuer |
null |
Required, no default — unless certificate.clusterIssuer is set, which it falls back to. Gateway mode does not free it for that reason: the mail Certificate still needs an issuer |
ingress.domain |
null |
Required, no default; read three times since the Gateway support — the Ingress host, the mail Certificate's dnsNames, and the HTTPRoute's hostname while httpRoute.hostnames is empty |
ingress.className |
nginx |
No counterpart in Gateway mode — the class sits on the Gateway |
ingress.nodeGroup |
full |
Which group the routing object points at, via that group's own http Service. Shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress. Must be a key of nodes: and must expose http; both are checked in both templates |
ingress.annotations |
{} |
Merged with the cert-manager annotation. No counterpart in Gateway mode, and for this chart that is the consequential one: this installation's CORS configuration lives here. See Routing |
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).
| Value | Default | Meaning |
|---|---|---|
httpRoute.enabled |
false |
Renders <release>-stalwart-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values. One route, not one per node group |
httpRoute.parentRefs |
[] |
The Gateway(s) the 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 |
[] |
Empty means [ingress.domain], so the HTTP hostname has a single source of truth. Note that the server's own hostname lives in the database, not in any chart value: after bootstrap the WebUI is reached at https://<the configured server hostname>/admin, so a second hostname can be reachable without the application agreeing that it is its name |
mtaSts — the MTA-STS policy routes
Added with Gitea #123. Stalwart serves the MTA-STS policy itself, per domain and out of its data store; this block is only about routing a sending MTA to it, and it renders in both routing modes.
| Value | Default | Meaning |
|---|---|---|
mtaSts.hosts |
[] |
One entry per policy hostname. The list is the switch — there is deliberately no mtaSts.enabled, because "enabled with an empty list" would be a state with no meaning that the chart would then have to catch. The schema rejects mtaSts.enabled outright, so the rule is enforced and not merely documented. Empty renders nothing in either mode and makes no value required |
mtaSts.hosts[].host |
— | Required. The policy hostname, i.e. mta-sts.<domain> |
mtaSts.hosts[].tlsSecretName |
— | Required in Ingress mode, inert in Gateway mode (TLS sits on the Gateway's listener there). No default on purpose: the plausible one, this release's own tls-<release>-<chart>-ingress, is the wrong certificate — it covers ingress.domain, not this host. Hosts sharing one secret are collected into a single tls: entry, so one certificate with several SANs is requested once instead of once per host |
mtaSts.hosts[].clusterIssuer |
"" |
Optional, and empty means no annotation — "the certificate comes from somewhere else, do not ask cert-manager for it". The same deliberate omission as the qBittorrent object in freesailarr and two of the four realm ingresses of Gitea #122. Entries sharing a tlsSecretName must agree on it; the render aborts naming both |
What is rendered is not the same shape on both sides, and that is forced rather than stylistic:
| Mode | Objects |
|---|---|
| Ingress | one Ingress per distinct clusterIssuer — <release>-stalwart-mta-sts-<issuer>-ingress, and <release>-stalwart-mta-sts-ingress for the issuer-less group — each with one rule per host and its tls: list grouped by secret |
| Gateway | one HTTPRoute, <release>-stalwart-mta-sts-httproute, carrying every hostname. Nothing differs per host (same path, same backend, no filter), and it attaches to the same Gateway(s) as the WebUI route |
"TLS belongs to the platform" is not the same as "there is no mechanism for it." In Gateway mode this chart renders no certificate for these hostnames and both TLS fields are inert — but cert-manager in this cluster runs with enableGatewayAPI: true (measured in its ControllerConfiguration), so it can issue the listener certificates itself, from an annotation on the Gateway rather than on anything here. So the work does not disappear with the Ingress, it moves to the Gateway object — and mind the depth trap below while doing it, because a wildcard listener for the parent domain will not cover mta-sts.sub.<domain>.
The path is not a value. RFC 8461 fixes it at /.well-known/mta-sts.txt, rendered with pathType: Exact (type: Exact in the route) because exactly one file lives there. A values key for a protocol-mandated path is one that can only ever be set wrong — and wrong is invisible from this side, see the traps below. Exact also matters in its own right: a prefix on these hostnames would publish the mail server's whole management surface under them.
The chart's own ingress.annotations are not copied onto these objects. They belong to the WebUI/JMAP host — on this installation they are its CORS configuration — and a policy file served to sending MTAs has no use for them.
Why not fold the names into the mail certificate. The alternative was real: the chart already has certificate.extraDnsNames. It was rejected because that would hang the mail certificate on this list — a bad entry here would then fail the issuance of the certificate STARTTLS on 25 and implicit TLS on 465/993 depend on. A policy route that is a nice-to-have would be able to take SMTP and IMAP with it. Separate secrets keep the blast radius at the entry.
Migration from jk-cluster-config. Where a route for one of these hosts already exists in another repository — and mta-sts.bekemeyer-klein.de does, from the Fleet release jk-cluster-config-fleet-cd-stalwart-mta-sts — the order is remove there first, then add the entry here. The other way round, ingress-nginx's admission webhook rejects the second object on the same host and path. With the default [] this block changes nothing about a running installation.
securityContext and probes
| Value | Default |
|---|---|
securityContext.pod |
runAsNonRoot: true, runAsUser/runAsGroup: 2000, seccompProfile: RuntimeDefault. No fsGroup — the chart mounts no PVC |
securityContext.container |
allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, drop: [ALL], add: [NET_BIND_SERVICE] |
livenessProbe |
GET /healthz/live, period 20s, timeout 5s, failures 3 |
readinessProbe |
GET /healthz/ready, period 20s, timeout 5s, failures 3 |
startupProbe |
GET /healthz/ready, period 10s, failures 30 (budget 300s) |
The intervals are calculated, not chosen — see The /healthz rate limit.
valkeySubchart and valkey (subchart)
The subchart is this repo's own valkey chart, resolved from the published index — not upstream's. So §14 point 2 ("we consume their chart as it comes") does not apply here: there is no third party's defaults to leave alone, because the subchart follows our conventions by being one of ours.
| Value | Default | Meaning |
|---|---|---|
valkeySubchart.enabled |
false |
true deploys our valkey chart alongside, reachable at <release>-valkey-service:6379 |
valkey.* |
see below | Passed through to the subchart — these are its value names |
The switch is valkeySubchart.enabled, not valkey.enabled, and that is forced. Everything under valkey: is handed to the subchart as its values, and our valkey chart ships additionalProperties: false with no enabled property — so valkey.enabled is rejected by the subchart:
Error: values don't meet the specifications of the schema(s) in the following chart(s):
valkey:
- at '': additional properties 'enabled' not allowed
And because Helm skips a disabled subchart's schema validation, that error would surface only for whoever first set it to true — the default path renders perfectly clean. The condition therefore lives on a key of this chart's own, which is also the honest shape: enabled was never a valkey value.
The values passed through, with the ones that matter:
| Value | Default here | Meaning |
|---|---|---|
valkey.secrets.authSecretRef |
null |
Required once the subchart is on (the subchart's own required() aborts the render). One field, auth-conf, holding the complete line requirepass <password> — not a bare password |
valkey.persistence.enabled |
false |
Cache, not queue — see below |
valkey.valkey.maxmemoryPolicy |
allkeys-lru |
The other half of the same pair |
valkey.valkey.resources.requests |
50m / 128Mi / 32Mi |
The subchart derives maxmemory from the computed limit |
valkey.networkPolicy.allowedNamespaces |
[] |
The subchart's own policy; needed only when Stalwart runs in a different namespace, which with the subchart it does not |
It is configured as a CACHE, and that is a decision rather than an inherited default. The valkey chart treats persistence and eviction as a pair and warns against mixing them: queue is persistence.enabled: true + noeviction, cache is persistence.enabled: false + allkeys-lru. Stalwart's in-memory store holds rate-limiter counters, auth tokens and sessions, auto-ban entries and the coordination pub/sub — every one of those is reconstructible. Nothing in it is the system of record: messages and metadata are in Postgres, blobs in S3, and the cluster node-id lease is in the data store too, not here. Paying for a PVC and for AOF replay to protect data that is worthless after a restart is the wrong trade.
The cost, named rather than discovered later: allkeys-lru really does discard keys under memory pressure, so the rate limiters and the auto-ban fail open — someone who can drive memory usage can age out their own ban entry. The answer is to size memory for the working set, not to switch to noeviction: with noeviction Stalwart's writes to the store start erroring instead, which takes out authentication rather than just the limits. Treat the limiters as a nuisance filter, not as a security boundary.
scaleDown and checksum/config stop at the subchart boundary, so scaling this release to zero does not stop the Valkey, and a new valkey chart release reaches a cluster only when stalwart is republished.
Never change valkey/ and stalwart/ in the same pull request
The dependency resolves from the published index, not from the working tree, and valkey/ is published only by a push to main that touches it. In one PR carrying both:
- every
helmcommand — lint, package, render and the install-test — pulls the old valkey.tgz, because the new one does not exist yet; - the PR goes green against a version of the subchart that is not the one in the diff, so the change to
valkey/is reviewed while nothing has exercised it; - after the merge both publish, and the first install of the new
stalwartis the first time the newvalkeyruns anywhere.
Two consecutive pull requests: change valkey/, merge, let it publish, then raise the version: in stalwart/Chart.yaml. The green check on a combined PR is a statement about the wrong artifact.
Traps
Fail-open: a node whose role is missing becomes a full node
The worst one, and the reason the chart is shaped as it is. From the source, not the documentation.
The bootstrap registry looks a ClusterRole up by name; when it finds none it records a non-fatal error and leaves the role unset. The listener check then reads
if bp.role.as_ref().is_none_or(|r| match &r.listeners { ... })
and is_none_or yields true for every listener when the role is None. The same applies to the tasks, through a different file whose pre-set default has all ten task flags at true.
So a node whose role is not in the database binds every listener and runs every task. The mx pod would serve IMAP and the admin interface, train the spam classifier, and run the outbound MTA a second time. The pod is Running, the log holds one line — Cluster role "mx" not found in registry — and the readiness probe is green. Kubernetes cannot detect it. Worse, the error is logged after the listeners are already bound.
This is not only a first-install concern: it is every typo in a role name and every rename in values.yaml without the matching rename in the database.
Three things contain it, and all three are why the chart looks the way it does:
mxships atreplicas: 0;- the Services publish only what was asked for, per port;
- the NetworkPolicy is a ceiling on reachability, independent of what the process actually bound.
Documented as a trap rather than explained away. If you add a node group, create its ClusterRole first.
NET_BIND_SERVICE is needed to exec, not to bind
Both published image variants run
setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
(Dockerfile.build, line 164 for the glibc target and 186 for musl/-alpine). The e in +ep sets the file's effective bit, which makes the binary a capability-dumb binary, and capabilities(7) then applies this at execve time:
When executing a capability-dumb binary, the kernel checks if the process obtained all permitted capabilities that were specified in the file permitted set… If the process did not obtain the full set of file permitted capabilities, then
execve(2)fails with the errorEPERM.
drop: [ALL] empties the bounding set, so the capability cannot be obtained, so the exec fails outright:
exec /usr/local/bin/stalwart: operation not permitted
The check runs before the program binds anything and does not ask whether the capability is needed. This is why the three obvious escapes all fail for the same reason: high application ports do not help, -alpine carries the identical setcap, and the net.ipv4.ip_unprivileged_port_start sysctl addresses binding rather than exec.
And the second half, which is the part that matters for whoever reads this next: the capability is never actually used. It is spent entirely on satisfying the exec check. Stalwart binds 25/465/993 here, but inside a pod that needs no capability for them anyway — the kubelet sets ip_unprivileged_port_start=0 in the pod network namespace. So a hardening pass grepping for a capability nobody uses will find exactly this one, and removing it stops the chart from starting at all. The day setcap disappears from upstream's Dockerfile.build is the day this deviation can go with it.
Why externalTrafficPolicy must be Cluster
The topology decides it. Three control-plane nodes, two workers; an HA proxy in front distributes 25/465/993 across the control-plane nodes, and no Stalwart pod is ever scheduled on a control-plane node.
externalTrafficPolicy: Local forwards only to pods on the node that received the packet. On a control-plane node there is never one — so under Local every packet is dropped, not some. It has to be Cluster.
The price of Cluster is that kube-proxy masquerades the source address to the receiving node's IP (it must: the reply would otherwise leave through a different node than the one the sender holds its connection with). On a mail server a lost source address is not cosmetic. In order of how fast it hurts:
- the auto-ban switches the service off — it bans the masqueraded node IP and with it every sender at once (documented behind Traefik, triggerable from outside);
- SPF checks the wrong address and takes DMARC with it;
- blocklists report everything clean;
- per-IP rate limits collapse onto one address;
- greylisting loses its key;
- the
Received:chain becomes worthless — the only item here that destroys evidence retroactively rather than merely failing.
The HA proxy solves all of it, port 25 included. The objection that foreign mail servers do not send a PROXY header applies only to direct DNAT. Here the HA proxy terminates the inbound connection and opens its own to the node, on which it can prepend a send-proxy-v2 header. The real client address then travels in the header and the masquerading stops mattering.
Which networks are trusted follows from the same topology — and only works because of it. A trusted network makes the PROXY header mandatory: a connection from there without one is misread and hangs until timeout. In Kubernetes that is dangerous, because kubelet probes come from a node IP.
| Traffic | Source IP Stalwart sees | Trusted? |
|---|---|---|
| Mail via the HA proxy | the control-plane node's IP (after masquerading) | yes |
| Kubelet probes | the worker node's IP (the one running the pod) | no |
| Ingress to WebUI/JMAP | the ingress controller's pod IP | no — different mechanism |
So proxyTrustedNetworks contains only the three control-plane node IPs. Because no Stalwart pod ever runs there, probe sources and trusted addresses are disjoint — that is not an operating preference, it is a load-bearing assumption of the design. Anyone who later lets a Stalwart pod onto a control-plane node breaks it.
Which address the masquerading actually inserts is to be measured, not assumed. It appears in the Received: header after the first connection.
The port duplication cannot be avoided
Every listener is a NetworkListener object in the database. The chart cannot create those objects, so ports.*.containerPort is a second copy of a decision really made in the WebUI. If the two disagree, the Service points at a port nothing listens on: connections are refused or time out, every pod stays Ready, and no object reports a problem.
The chart reduces the surprise rather than the duplication, in two ways. The application binds the ordinary 25/465/993, so the numbers an operator types into the WebUI are the ones from every page of Stalwart's documentation and from the image's own EXPOSE list. And nodes.<g>.exposedPorts is deliberately not a copy of the listener configuration but a statement of its own — "this group is reachable here and nowhere else" — which is why a disagreement in either direction is harmless: a role that binds more is unreachable on the extra ports, and a policy that permits more has nothing listening.
An earlier draft had the application bind 30025/30465/30993 so that nothing privileged was bound and the capability could be dropped. That reasoning collapsed against the setcap/execve rule above; since the capability must be granted anyway, high ports bought no hardening and only cost predictability.
And in the NetworkPolicy the pod ports apply, not the node ports — 25/465/993, never 30025/30465/30993. A policy is evaluated at the pod's interface, after kube-proxy has already translated the node port to the target port. Writing 30025 there matches nothing: the packet arrives on 25. The policy then looks strict, is accepted by the API server, and silently blocks everything inbound.
Routing: Ingress or Gateway API
Two mutually exclusive modes for the HTTP surface — WebUI and JMAP — and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.
One route, not one per node group. #114 describes this chart as rendering "per node group"; its Services and StatefulSets are per group, the routing object is not and must not be. The route targets exactly ingress.nodeGroup's own http Service and keeps both of the Ingress's guards — the group must be a key of nodes: and must expose http — because the WebUI's setup and management screens are not safe to spread over a group that may not run the management tasks, and a port-label selector would send each request to a random group.
| 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 Stalwart there is a second edge: the auto-ban sees the proxy's address, so two data paths mean two addresses to get banned and a WebUI that fails for half the requests. A migration runs over a second hostname — and note that the server hostname configured in the database decides where the WebUI sends administrators, which no chart value can change |
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 |
ingress.nodeGroup is shared by both modes |
it names where the management surface is published from, which is not a property of the Ingress |
SMTP, IMAP and ManageSieve are not routed in either mode, exactly as before: they keep arriving through the per-port NodePort Services behind the HA proxy. A Gateway could in principle carry them as TLSRoute/TCPRoute listeners; this chart renders neither, and doing so would be a separate decision.
The mail certificate stays in Gateway mode, and must not be cleaned up
This is the only chart in the whole sweep where anything of the TLS path stays with the chart. certificate.enabled keeps rendering <release>-stalwart-mail-certificate in Gateway mode, because that object has nothing to do with the Ingress or the Gateway: it covers STARTTLS on 25 and implicit TLS on 465/993, and those connections pass through no HTTP proxy at all (TLS).
So in Gateway mode:
ingress.domainis still required — the certificate'sdnsNamesread it;ingress.clusterIssueris still required unlesscertificate.clusterIssueris set, which it falls back to;- and the Certificate must not be removed as part of a migration. Deleting it drops SMTP and IMAP back to whatever self-signed certificate Stalwart generates for itself: clients see a name mismatch while every Kubernetes object in the release stays green.
What does move to the Gateway is the certificate for the HTTP hostname only, onto its listener.
Gateway mode drops the CORS configuration, and that breaks the webmail
Measured on the live Ingress on 2026-10-12 — operator-supplied through ingress.annotations; this chart sets none of it:
nginx.ingress.kubernetes.io/enable-cors: true
nginx.ingress.kubernetes.io/cors-allow-origin: https://<the webmail host>
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
That origin is bulwark, the webmail chart — so this is the one loss in the sweep that breaks a second chart. The CORS configuration is what allows the browser-side webmail to call this server's JMAP endpoint at all. Without it the webmail stops working, with a console error in the browser and nothing whatsoever in this server's logs.
And "no counterpart" would be the wrong thing to write here. Measured by grepping the CRDs this repository pins (ci/gateway-api-crds.sh, gateway-api v1.6.3 standard channel) rather than inferred:
- HTTPRoute's filters carry a full
corsfield (GEP-1767) withallowOrigins,allowMethods,allowHeaders,allowCredentialsandmaxAge. Its documented level, verbatim from that CRD:Support: Extended. responseHeaderModifieris in the same CRD and is alsoSupport: Extended, not Core.
There is a counterpart, then, and this chart still does not render it. Three reasons, in order:
- The chart has no CORS values. The live configuration is the operator's — a specific origin, a specific method list — and the chart sets no CORS annotation in Ingress mode either. Rendering a filter means inventing a values surface that would exist in Gateway mode only, and that asymmetry is its own defect and its own piece of work.
- Extended support is not support. An implementation that does not support an Extended value reports it in the route's status, and a rejected route is rejected whole — which here would take the WebUI and JMAP down together (the reasoning #119 reached for
statusCode). The observed failure is quieter still: Cilium acceptsfilters.type: CORSand forwards the browser's preflight upstream anyway (cilium/cilium#45272), i.e.Accepted: Truewith the behaviour absent. A chart cannot know which implementation it landed on. - Half a CORS is worse than none, and the translatable half is the useless one here. A
responseHeaderModifiercan add the threeAccess-Control-Allow-*headers to a response. The preflight is a different thing: the browser sendsOPTIONSfirst and the gateway, not the backend, has to answer it — the CORS filter's own contract says a path carrying it must not pass the preflight upstream, which a bare header modifier cannot do. And this installation's header list includesAuthorization, which makes every webmail request preflighted: the half that is expressible without the CORS filter is precisely the half that does not help.
So: configure CORS at the Gateway, with that implementation's own support, before you move this host. Then test it from the webmail in a browser — curl sends no preflight, so it will pass while the webmail fails.
MTA-STS: the chart registers a route, it does not create a domain
The limit the mtaSts block has to name rather than suggest away, and it is the same genus as the OnePasswordItem limit this chart already documents: a promise the chart cannot keep.
Read the first paragraph of values.yaml again — since v0.16 the domains live in Stalwart's data store, and the chart never sees them. So whether the server answers for mta-sts.example.com depends on example.com existing in that data store, which no value here can arrange.
An entry without its domain in the data store gives a 404 at a correctly routed endpoint: every Kubernetes object green, the Ingress or route resolving perfectly, nothing wrong on this side at all. And the other half is worse — MTA-STS failures are silent on the sender's side. A sending MTA that finds no policy reports nothing here; it simply delivers in the clear. So the thing the block exists to achieve can be absent with no symptom in this cluster whatsoever.
Two more things the chart does not do, both equally silent: the _mta-sts.<domain> TXT record (without it no sending MTA ever looks for the policy — that is DNS, not Kubernetes), and the policy's content (version/mode/max_age/mx come from the data store, per domain; the running installation serves mode: testing, so sending MTAs enforce nothing).
Verify every entry from outside after adding it — NOTES.txt prints the command per host:
curl https://mta-sts.<domain>/.well-known/mta-sts.txt
MTA-STS on a subdomain: the parent's wildcard does not cover it
The trap that will actually catch somebody. mta-sts.sub.example.com is one label deeper than *.example.com, and a wildcard certificate matches exactly one label (RFC 6125) — so a wildcard for the parent domain does not cover it, and the obvious assumption ("the wildcard already covers that") is false.
A secret per entry solves it in Ingress mode, which is why tlsSecretName is per entry and has no default. The trap is documented anyway because the symptom is a TLS error at the sending MTA: nothing in this cluster logs it, the endpoint looks healthy from here, and mail keeps flowing unencrypted.
In Gateway mode the trap moves to the Gateway's listener, where this chart cannot help at all: a wildcard listener for the parent domain will not terminate TLS for a deeper name, and the route carries no certificate to compensate with.
One Ingress per cluster issuer, and why it cannot be one object
Worth knowing before the next "list of hosts with its own TLS secret" lands in some other chart, because the cause is not specific to this one.
cert-manager reads the two fields at different scopes:
cert-manager.io/cluster-issueris an annotation, i.e. object-scoped. One Ingress carries exactly one, or none — and with it absent, ingress-shim creates nothing for that object, for everytlsentry on it rather than for some.secretNameistls-entry-scoped, and the shim creates one Certificate pertlsentry.
So an object cannot mix "issued by X" with "comes from somewhere else", and an entry-level optional issuer forces a split on the issuer. Measured in jk-cluster on 2026-10-13, which is where the second half of the rule comes from:
Certificate tls-mta-sts-bekemeyer-klein-de-ingress
secretName: tls-mta-sts-bekemeyer-klein-de-ingress
ownerReferences: Ingress/mta-sts-ingress
ingress-shim names the Certificate after the secret and owns it from the Ingress. Two Ingress objects whose tls entries name the same secret therefore drive the shim at one Certificate name from two owners — so a shared secret must sit in exactly one object (hence the grouping), and a secret may not span two issuer groups (hence the abort).
The objects are named by index all the same — …-mta-sts-ingress, …-mta-sts-ingress-1, … — in the repo's idiom (style guide §4), as homeassistant's routes and keycloak's realm Ingresses do it. The grouping above and the naming are independent decisions. An earlier draft of this chart named them by issuer, arguing that the index rule rested on "a route holds no state or certificate" and that these objects hold one. The reading was right and the rule's stated reason was wrong — it was the reason that got corrected, not the naming of two charts, because keycloak's realm Ingresses own certificates too and are already published. What actually carries the index rule is that the Secret survives a rename, so no certificate is re-issued; the three-step version, and the cluster flag it depends on, is in that style-guide section.
So the name does not say which issuer an object carries. NOTES.txt prints the index → issuer → hosts mapping, and it is the only place that association is visible — read it there rather than inferring it from object names.
And reordering mtaSts.hosts is sharper here than at homeassistant. There an index names a host. Here it names an issuer group derived from the list, so moving one entry can change how many objects exist and which index holds which issuer — renumbering objects that were not themselves moved. Kubernetes recreates them, which takes their Certificate objects along; nothing is re-issued, because the Secrets survive, and that holds only while the cluster leaves --enable-certificate-owner-ref unset. Measured on this cluster: it is unset. It is a property of the cluster, not of this chart.
The Ingress ban trap
The one the HA proxy does not solve. Stalwart sees the ingress controller's pod IP for every JMAP and WebUI request, not the client's, and the auto-ban eventually bans that address — at which point WebUI and JMAP are dead for everyone at once.
For HTTP the remedy is not the PROXY protocol but Stalwart's forwarded handling with X-Forwarded-For evaluation, while discarding forwarding headers the client brought itself. A mandatory step (step 4), because the outage arrives weeks later and looks like a server fault.
Gateway mode does not avoid this and does not reset it: it is one proxy address instead of another — the Gateway's data-plane pods rather than the ingress controller's — and whether the Gateway sends an X-Forwarded-For at all is that implementation's business, not something the route can state. The same applies to networkPolicy.allowedNamespaces: in Gateway mode the namespace to allow is the Gateway's data plane, which usually lives in a namespace of its own, not an ingress controller's.
Second half of the same area: after bootstrap, port 8080 is no longer the admin port. Upstream moves administration to https://<hostname>/admin once setup completes. The Ingress therefore only works while an HTTP NetworkListener on 8080 exists in the database — between finishing setup and creating that listener it resolves to a port that answers nothing.
A missing config.json opens an ephemeral store and looks healthy
If the file is absent — a failed ConfigMap mount, a renamed key — the server does not abort. It sets recovery mode, prints a random temporary admin password, and opens an in-memory EphemeralStore. Probes go green, mail is accepted, and everything is lost on the next restart.
An unparsable or unreadable file does abort. It is specifically the missing-file case that fails open, and the chart's defence is that config.json is always rendered and seeded by an init container, so its absence means something else broke.
The /healthz rate limit sets the probe intervals
Stalwart applies its anonymous HTTP rate limit to /healthz as to everything else, default 100 requests per 60 seconds keyed on the source IP. Kubelet probes arrive from the node's IP, so every Stalwart pod on one node draws from the same bucket. Exceeding it returns 429, a 429 fails the probe, and a failed liveness probe restarts the pod — a self-inflicted restart loop that worsens with pod density.
| Intervals | Per pod | Pods per node before the limit |
|---|---|---|
| 20/20/10 (shipped) | 6 req/min | ~16 |
| 10/10/5 (the obvious default) | 12 req/min | ~8 |
The startup probe gates liveness and readiness, so the three never run simultaneously and 6/min is the worst case in both phases. If a node ever needs more pods than that, raise the limit on the server (the allow-list bypasses the limiter outright for listed addresses) rather than blinding the probes.
This trap is not specific to Stalwart — any application that rate-limits by source IP meets it in Kubernetes, because probes are indistinguishable from each other at the node IP. See Lessons learned.
A tcpSocket readiness probe on the mail port would bypass the bucket, and was rejected: it would be green exactly in the fail-open case (a broken mx node binds 25 too), it proves strictly less than /healthz/ready, and it would have to differ per node group so the fallback default could no longer carry the probe's context.
Empty probeSources means open, and that is the safer default
With networkPolicy.probeSources empty the http rule renders without a from selector, so the port is reachable from anywhere the CNI routes to. Deliberate: the kubelet probes from the worker node's IP, which the chart cannot know, and a guessed CIDR that misses the real addresses blocks every probe — and a pod that never becomes Ready reads as a broken application rather than as a policy. http carries no node port, so "open" means open inside the cluster, not from the internet.
Once probeSources is non-empty the rule is restricted, which also cuts off the ingress controller unless its namespace is in networkPolicy.allowedNamespaces.
The data store username is public and cannot be otherwise
On the DataStore object, authSecret has the type SecretKeyOptional — a tagged sub-object with None, Value, EnvironmentVariable and File variants — while authUsername is a plain String with no secret form at all. So the password is referenced by variable name and never enters the ConfigMap:
"authSecret": {"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}
but the username is rendered into it. An upstream constraint, not a choice. The upside is the reason config.json can be a real ConfigMap with a meaningful checksum/config: Helm can hash the whole file because there is no secret in it.
The recovery admin is one field, not two
STALWART_RECOVERY_ADMIN takes username:password, and the chart reads it as one vault field rather than assembling it. The upstream parser splits on the first : and drops the entry when either half is empty, so a half-filled item would silently mean no recovery admin instead of failing — and this credential is a back door that logs in regardless of directory state.
Remove it when you are done: set secrets.recoveryAdminSecretRef: null once a permanent administrator exists.
Durations are integer milliseconds, not 15s
Stalwart's object encoding is explicit: "A duration is an integer count of milliseconds, and a size is an integer count of bytes. Neither accepts a string." The DataStore reference gives the field as Duration? default: 15000, and the deserializer goes through u64 into Duration::from_millis.
A config.json carrying "15s" is rejected at startup. The chart therefore names the value timeoutMs and types it integer in the schema, so timeoutMs: 15s fails at render with got string, want integer rather than in the server's log.
Two neighbours worth knowing when writing a stalwart-cli apply plan by hand: an ordered list is a JSON object keyed by stringified positions ({"0":…,"1":…}), and a set is a JSON object mapping each member to true — neither is an array.
What a green pod proves here, and what it does not
Liveness asks /healthz/live; readiness and startup ask /healthz/ready. Liveness is on the dependency-free endpoint on purpose, so a Postgres outage cannot restart every pod in the cluster at once (§12.3).
But read from the source: /healthz/ready does not query the database. It returns 200 as soon as the data store handle is not Store::None. So readiness does not hang on the dependency the way the guideline asks, and it cannot — the endpoint upstream offers is shallow. It also does not catch the ephemeral-store case above, since that server has a handle too.
Both endpoints are unauthenticated and carry no network restriction. They do pass the configurable allowedEndpoints expression (default 200, i.e. everything allowed) whose loopback escape hatch does not cover a node IP — so an operator who later writes a restrictive expression breaks every probe.
Distribution is a promise, and the price is a Pending pod
whenUnsatisfiable is DoNotSchedule, not ScheduleAnyway, because the requirement was stated as a guarantee: full pods run on different nodes, mx pods run on different nodes. ScheduleAnyway gives no guarantee at all — with one worker at 97 % requested CPU the scheduler may put both replicas of a group on the one remaining node, entirely within the rule and with the redundancy gone.
The price is a pod that stays Pending rather than one that lands somewhere useless, and a Pending pod with no scheduling event reads like a different problem than a distribution rule. Two cases:
- Two of three workers gone. The surviving replica keeps running; the second does not join the survivor — but only while the
Nodeobjects still exist. ANotReadynode still counts as an (empty) domain, so placing another pod would raise the skew to 2. Delete theNodeobjects and the domains disappear with them, and the pod schedules. Same mechanism, opposite outcome, one step apart — worth knowing before a node is "temporarily" left in the API. - A narrowing
nodeSelectororaffinity.DoNotScheduleplus a node restriction below the group's replica count is a combination that blocks itself. That is intended and loud — see the next trap for why it took an explicit field to make it loud.
Where the 97 %-CPU node actually bites: at the third replica, not the second. With two replicas on three workers the distribution is 1,1,0 → skew 1 → allowed, and the tight CPU produces no Pending yet. Only from three replicas per group upwards must every worker accept one.
The replica ceiling is a rule, not a number: as many replicas per group as there are schedulable workers. The chart leaves nodeTaintsPolicy at its API default of Ignore, so every node counts as a domain — control-plane nodes included, where no Stalwart pod ever runs. Those domains hold the minimum at 0 permanently, so with maxSkew: 1 no domain may hold more than one pod. Add a worker and the ceiling rises with it; remove one and it falls. Past that point a group needs spreadAcrossNodes: false, or an own entry with ScheduleAnyway. Do not write today's count down anywhere — it would be wrong at the next cluster rebuild and nobody would come back for it.
The two node-inclusion policies have opposite defaults
Straight from the API definition, and this asymmetry is the whole reason the chart sets one of them explicitly:
nodeAffinityPolicy— "If this value is nil, the behavior is equivalent to the Honor policy", and Honor means only nodes matching the pod'snodeAffinity/nodeSelectorare counted as domains.nodeTaintsPolicy— "If this value is nil, the behavior is equivalent to the Ignore policy", i.e. all nodes are counted.
Under the Honor default, a nodeSelector narrowing a 2-replica group onto one node leaves exactly one eligible domain, both pods land in it, and the skew is 0 — the constraint is satisfied. Both replicas co-located, nothing Pending, no event, and the selector in the object is perfectly correct, so the CI selector check cannot see it either. The chart therefore sets nodeAffinityPolicy: Ignore, under which every node counts: the same narrowing gives 2,0,0, skew 2, and the second pod is Pending. The silent half becomes the loud one.
A render abort was the alternative and was rejected, because it would also have forbidden a nodeSelector spanning five nodes, which is legal and works (1,1,0,0,0 → skew 1). The field forbids only the case that is actually broken.
And the two fields are not a package. The replica ceiling above comes from nodeTaintsPolicy ≙ Ignore and existed before any of this; nodeAffinityPolicy: Ignore does not add it, it only closes the silent gap. They are argued separately in values.yaml for exactly that reason.
nodeAffinityPolicy can be dropped without a word
The field is beta — on by default from Kubernetes 1.26, graduated to GA with the feature gate NodeInclusionPolicyInPodTopologySpread in 1.33. On an older API server, or one with the gate switched off, it is pruned silently, and the gap of the previous trap comes straight back: a narrowing nodeSelector again yields co-location with no Pending and no message. The object still looks correct in kubectl get sts -o yaml, minus one line.
So 1.26 is the minimum version on which this chart can make the promise at all. Check it against the live object rather than against the render:
kubectl -n <ns> get sts -l stalwart.jk-dev.de/release=<release>-stalwart \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.topologySpreadConstraints[*].nodeAffinityPolicy}{"\n"}{end}'
An empty column where Ignore should be is the whole symptom. Not measured against a pre-1.26 server — this is the documented pruning behaviour for a field behind a disabled gate, not something tried here.
A colliding spread constraint aborts the render
Kubernetes keys a spread constraint on (topologyKey, whenUnsatisfiable), and whenUnsatisfiable defaults to DoNotSchedule — so an entry in nodes.<g>.topologySpreadConstraints on kubernetes.io/hostname without an explicit whenUnsatisfiable collides with the chart's own. The chart aborts the render naming both nodes.<g>.spreadAcrossNodes and nodes.<g>.topologySpreadConstraints, because both ways the collision would otherwise resolve are worse:
kubectl createrejects the duplicate with a message naming a list index rather than a value;- an apply-based path merges the two entries over
patchMergeKey: topologyKey— silently — so onelabelSelectoris dropped and the result is green.
Entries on any other key (topology.kubernetes.io/zone, for instance) are appended and really do apply as an AND. To own the per-node distribution yourself, set spreadAcrossNodes: false and keep your entry — remembering that nodeAffinityPolicy: Ignore is then yours to repeat. The abort fires only while the chart's own entry is actually rendered, so it cannot trip a single-replica group.
Upgrades and backup
- A plain app-version bump needs no action here; Renovate opens it and
ci/renovate-bump-chart.shbumps the version — since Gitea #128 at the app's own level, so an app patch is a chart patch and an app minor a chart minor (see Versioning). Chart pages carry no version numbers —Chart.yamlis the source. - Read
UPGRADING/for an app major. The v0.15 → v0.16 migration was offline, multi-step and irreversible without a backup; nothing suggests the next major will be gentler. For app major jumps the repo rule applies: first to the last release of the old line, then to the newest major, each in its own PR. - The backup is Postgres plus the S3 bucket. No PVC is involved — there is nothing in the pods worth keeping. The configuration lives in Postgres too, which means a Postgres restore also rolls back every listener, domain and role.
stalwart-cli snapshotis the configuration backup, and the only one that is readable and diffable. It emits anapplyplan; keep one in version control after any WebUI change, or the eight steps above are the recovery procedure.- Scaling to zero (
scaleDown: true) stops the release without uninstalling it. Mail queued on other servers will be retried for days, so a short window is survivable — but inbound connections are refused meanwhile, and some senders downgrade a repeatedly refused host.
CI
ci/stalwart/ holds test-values.yaml and fixtures.yaml: a throwaway Postgres, the dummy data store Secret, and a stub CRD for cert-manager's Certificate. It runs with bootstrap.enabled: true, which is the only mode that can come up against an empty database.
A green run does not mean a working mail server, and no install-test here could. Every listener lives in the database and is created by a human, so against an empty database there is no SMTP service to test.
What it does prove is the chart's own responsibility, and the first item is why the chart is tested rather than excluded:
- the container execs at all — the
setcap/execveinteraction above would otherwise only be caught by reading a Dockerfile; readOnlyRootFilesystem: trueholds with the two image volumes plus/tmpas emptyDirs, and the init container can writeconfig.json;- the rendered
config.jsonparses as Stalwart reads it — the store opens, so@type, the integer timeout and theauthSecretindirection are all accepted, which is the one check that the password really arrives without ever being in the ConfigMap; - the Secret key names match, the node ports can be allocated, the quantities are valid, and the NetworkPolicies do not cut off the probes.
The install-test renders neither the PDB nor the spread constraint. ci/stalwart/test-values.yaml runs full: 1 / mx: 0, so both objects stay below their threshold — nothing about the HA objects is exercised in a cluster, and nothing claims otherwise. What covers them instead is a render-only overlay, ci/selector-match/stalwart.yaml, which raises both groups to two replicas and sets an externalServices.valkey.host (without it the chart's own "cluster with no in-memory store" abort fires). ci/run-selector-match-test.sh then checks that each group's PDB selector and spread labelSelector match that group's own pod labels — and fails if the overlay stops producing a selector, because an overlay that quietly reaches nothing would make the check vacuum-green. See CI/CD.
No Valkey in the fixtures, deliberately. The in-memory store is a database object, so a Valkey in the namespace would be reachable and unused — making the test look as though it covered clustering while covering none of it. The test runs a single node, which needs no in-memory store. The subchart path is covered by a local render only; a known gap.
The Gateway mode is held at render level, by ci/httproute/stalwart.yaml plus ci/run-httproute-match-test.sh, which runs over every chart on every PR (CI/CD). The overlay switches this chart into Gateway mode; 4 assertions since Gitea #123 — parentRefs not empty and the backendRefs entry resolving to that node group's http Service in the same render, for the WebUI route and for the MTA-STS route. The overlay's mtaSts.hosts enters all three shapes of the entry form, so the second route is not green over a single trivial case: an entry with a clusterIssuer, one without, and one host on a subdomain. The port is held by number (ports.http.containerPort), and for this chart that is the check worth having: the NodePort Services publish a different number (ports.<name>.nodePort) than the container binds, so naming the wrong one in a route would be a plausible mistake. The one-route-not-one-per-group property is the other thing the overlay pins down, since it is exactly what a copy would get wrong.
What that does not cover, named so the gaps are not mistaken for coverage:
- that the hostname answers. No Gateway controller runs in CI and no route reaches
Accepted: True. - the CORS configuration — the thing this chart actually loses in that mode, and the one that breaks the bulwark webmail. It lives in the operator's
ingress.annotations, the chart has no values for it, and the Gateway API's own CORS filter is Extended-support with observed implementation variance. All of that is documented invalues.yamland printed byNOTES.txt, and documentation is not something a test can assert. - that SMTP/IMAP still work. They never went through the Ingress and do not go through the route; the mail Certificate that serves them keeps being rendered in Gateway mode, which is this chart's distinguishing feature in the sweep, and no render test looks at whether it was left alone.
NOTES.txtitself.helm templatedoes not emit it and no script underci/reads it, so its text carries review-only assurance.
The MTA-STS Ingress half is covered by the install-test, not by the render test. The overlay's whole job is to switch the mode off, so it never renders those Ingress objects. ci/stalwart/test-values.yaml therefore carries two mtaSts.hosts entries — one with a clusterIssuer and one without, the second a subdomain — which is the only stage that puts them in front of a real API server: that two objects differing only by the issuer suffix are both accepted, that pathType: Exact on /.well-known/mta-sts.txt is valid, and that a tls: list grouping several hosts under one secret is taken as written. Nothing issues either certificate — cert-manager is not installed in the k3d cluster, so the annotation is inert and the named Secrets never exist, which is harmless because nothing mounts them.
See also
- Chart style guide — the conventions this chart follows and the two it deviates from
- Lessons learned — the source-IP mechanism behind NodePort, which recurs in every chart whose application evaluates the client address
- bulwark — a webmail client for this server: mail, calendar, contacts and files in a browser, over JMAP. A separate release pointed at this one's JMAP endpoint, and the two are only useful together. Two things worth carrying across before installing it: it is a pure client, so nothing in either chart notices the other being away — with this server gone, Bulwark is
Readyand green — and its own configuration can be overridden from its volume, silently, which is the first thing its page explains - valkey — the chart this one takes as a subchart, and the one to install separately when it should outlive this release
- 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