Files
helm-charts/stalwart/values.yaml
T
jklein b4c9823e01 feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes
The policy route for mta-sts.<domain> belonged to another repository
(jk-cluster-config) while pointing at this chart's own Service. It moves
here as mtaSts.hosts, a list, so several domains - subdomains included -
are maintainable in one place and in whichever routing mode is active.

THE LIST IS THE SWITCH. No mtaSts.enabled: "enabled with an empty list"
would be a state with no meaning that the chart would then have to catch.
The schema rejects the key outright, so the rule is enforced rather than
documented. Default [] renders nothing in either mode - the defaults and
the unchanged CI fixture both produce a byte-identical helm template
against origin/main.

Entry form {host, tlsSecretName, clusterIssuer?}. The two TLS fields are
read ONLY in Ingress mode; in Gateway mode TLS sits on the listener, so
requiring them there would demand a value that does nothing.

THE PATH IS HARDWIRED. RFC 8461 fixes /.well-known/mta-sts.txt, rendered
with pathType Exact (type: Exact in the route). A values key for a
protocol-mandated path can only ever be set wrong, and wrong is invisible
from this side. Exact also matters in itself: a prefix on these hostnames
would publish the mail server's whole management surface under them.

ONE INGRESS PER CLUSTER ISSUER, NOT ONE OBJECT - and the plan's own three
requirements forced that. cert-manager reads the two fields at different
scopes: cluster-issuer is an ANNOTATION (object-scoped, and absent means
nothing is issued for that object at all), while secretName is
tls-entry-scoped and yields one Certificate per entry. So an object cannot
both request a certificate and leave another host's secret alone, which is
what an empty per-entry issuer has to mean. Measured in jk-cluster:
ingress-shim names the Certificate AFTER THE SECRET and owns it from the
Ingress, so a shared secret must sit in exactly one object (hence grouping
the tls list by secret, reusing rss-bridge.tlsGroups' shape) and a secret
may not span two issuer groups (hence the abort). Objects are named by
issuer, not by index: an index is only free where the object holds no
certificate, which is the precondition homeassistant's route naming states
for itself.

Gateway mode renders ONE HTTPRoute with every hostname - nothing differs
per host there - attached to the same Gateway(s) as the WebUI route.

WHAT THE CHART CANNOT PROMISE, AND THEREFORE NAMES: it registers a route,
it does not create a domain. The domains live in Stalwart's data store, so
an entry whose domain is missing there answers 404 at a correctly routed
endpoint with every object green - and MTA-STS failures are silent on the
SENDER's side, which delivers in the clear and logs nothing here. Same for
the _mta-sts.<domain> TXT record and the policy's content. The subdomain
trap is documented too: mta-sts.sub.<domain> is one label below
*.<domain>, so a parent wildcard certificate does not cover it.

Not folded into certificate.extraDnsNames deliberately - that would hang
the MAIL certificate on this list, letting a bad policy entry take STARTTLS
and IMAPS with it.

Minor, additive, empty default: 1.2.0+upv0.16.25 -> 1.3.0+upv0.16.25.

CI: ci/httproute/stalwart.yaml enters all three shapes of the entry form
(with issuer, without, and a subdomain), taking the HTTPRoute render test
from 56 assertions over 21 charts to 58 over 21 - stalwart from 2 to 4.
The Ingress half cannot be covered there, since an overlay's whole job is
to switch the mode off, so ci/stalwart/test-values.yaml carries two
entries and the install-test has a real API server accept them.

Fixes #123
2026-10-13 10:41:10 +00:00

1167 lines
63 KiB
YAML

# Stalwart — a cluster-capable mail server (SMTP, IMAP, POP3, JMAP, ManageSieve).
#
# READ THIS FIRST: THIS CHART CONFIGURES ALMOST NOTHING.
#
# Since v0.16 Stalwart keeps its entire configuration — over 2400 settings in
# 117 object types, including every listener, the blob store, the in-memory
# store, TLS objects, domains and the cluster roles — INSIDE the data store,
# managed over the JMAP API (WebUI or stalwart-cli). The only thing left on
# disk is a small config.json that names the data store and nothing else:
# "It only needs to contain the DataStore object"
# (https://stalw.art/docs/configuration/declarative-deployments).
#
# So this chart renders config.json, the workloads with their STALWART_ROLE,
# the Services, the NetworkPolicies, the Ingress and a cert-manager
# Certificate. Everything else is operator work in the WebUI, and the values
# below say for each block whether it configures Stalwart or only Kubernetes.
# A value that only describes something to Kubernetes is marked as such rather
# than left to look like application configuration.
# Scale the whole chart down without uninstalling it: scaleDown: true renders
# every StatefulSet with replicas: 0 and takes precedence over every
# nodes.<group>.replicas. Services, Ingress, Secrets and the Certificate stay
# in place, so no certificate is re-issued when you scale back up.
scaleDown: false
# Chart-wide default for the limit factor of every node group. A group may
# override it as nodes.<group>.resources.limitFactor, which always wins. Memory
# and ephemeral-storage limits are computed as limitFactor x the request; a CPU
# limit is only set when configured explicitly.
limitFactor: 3
secrets:
# op:// path of the 1Password item carrying the data-store password in the
# field "password".
#
# ONLY the password. The username cannot live here, and that is an upstream
# constraint rather than a choice: on the DataStore object authSecret has the
# type SecretKeyOptional (which has an EnvironmentVariable variant, and that
# is what this chart uses) while authUsername is a plain String with no
# secret form at all. So the username is the public value
# dataStore.postgres.username below and ends up in the ConfigMap, while the
# password never does.
dataStoreSecretRef: null
# op:// path of the 1Password item carrying the field "recovery-admin" with
# the COMPLETE value of STALWART_RECOVERY_ADMIN, i.e. "admin:<password>" in
# one string — the same shape as valkey's auth-conf field. The chart does not
# assemble it from two fields, because a half-built credential ("admin:" with
# an empty password) is accepted by the server as a working back door.
#
# Why it is worth pinning at all: without it Stalwart generates a temporary
# administrator password on every bootstrap or recovery start and prints it to
# stdout — in Kubernetes that means a fresh password in the pod log on every
# restart, visible to anyone who can read logs, and lost as soon as it
# scrolls. Set to null to switch the variable off entirely once a permanent
# administrator exists in the database; that is the documented end state, see
# NOTES.txt.
recoveryAdminSecretRef: null
# op:// path of the 1Password item for the S3 blob store, carrying the fields
# "access-key" and "secret-key".
#
# This chart does NOT configure the blob store — that object lives in the
# database. What it does is expose both fields to the container as the
# environment variables STALWART_S3_ACCESS_KEY and STALWART_S3_SECRET_KEY, so
# that the BlobStore object you create in the WebUI can reference them
# instead of storing the credentials in the database:
# accessKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_ACCESS_KEY"}
# secretKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_SECRET_KEY"}
# Both fields accept that variant (accessKey is PublicStringOptional,
# secretKey is SecretKeyOptional; both offer Value, EnvironmentVariable and
# File). Leave null when the blob store is not S3 or when you prefer to keep
# the credentials in the database — the variables are then not set and
# nothing references them.
s3SecretRef: null
# ---------------------------------------------------------------------------
# config.json — the ONLY part of Stalwart this chart actually configures
# ---------------------------------------------------------------------------
dataStore:
postgres:
# REQUIRED, no default (enforced by required() in the template).
host: null
port: 5432
database: stalwart
# Public on purpose — see secrets.dataStoreSecretRef above for why this is
# not a secret reference. It is rendered into the ConfigMap.
username: stalwart
tls:
# Rendered as the object's "useTls" field. The names differ because this
# chart groups the two TLS switches that upstream keeps flat; the mapping
# is tls.enable -> useTls and tls.allowInvalidCerts -> allowInvalidCerts.
#
# Note the default is the OPPOSITE of upstream's (false). A mail server's
# data store carries every message's metadata, and this chart would
# rather fail to connect to a plaintext-only Postgres than reach it
# silently unencrypted.
enable: true
# Accepting an invalid certificate turns TLS into obfuscation: the
# connection is encrypted against a passive listener and wide open to an
# active one. Only ever a temporary diagnostic.
allowInvalidCerts: false
poolMaxConnections: 10
# MILLISECONDS, as an integer — not "15s".
#
# This is not a styling preference. Stalwart's object encoding states it
# outright: "A duration is an integer count of milliseconds, and a size is
# an integer count of bytes. Neither accepts a string."
# (https://stalw.art/docs/configuration/object-encoding). The DataStore
# reference gives this field as "Duration? default: 15000". A config.json
# carrying "15s" is rejected by the server at startup, so the key is named
# for its unit instead of accepting a suffix the file cannot hold.
timeoutMs: 15000
# Extra libpq connection options, passed through verbatim as the object's
# "options" field. null omits the field.
options: null
# ---------------------------------------------------------------------------
# First start: Stalwart needs recovery mode when config.json is provided
# ---------------------------------------------------------------------------
bootstrap:
# Sets STALWART_RECOVERY_MODE=1 on every node group.
#
# WHY THIS EXISTS, AND WHY IT IS NOT OPTIONAL ON A FIRST INSTALL. The setup
# wizard everyone reaches for runs in BOOTSTRAP mode, and bootstrap mode is
# entered only when there is no config.json: "The first time Stalwart is
# started without a config.json, it enters bootstrap mode"
# (https://stalw.art/docs/configuration/bootstrap-mode). This chart always
# renders one, so the wizard never appears. Against an empty database that
# leaves a server with no listeners, no domain and no administrator, and
# nothing to fix it with.
#
# The documented path for exactly this case is recovery mode: "template
# config.json onto the host, start Stalwart once with STALWART_RECOVERY_MODE=1
# and a STALWART_RECOVERY_ADMIN supplied from a secrets manager, run
# stalwart-cli apply against the recovery listener to load the initial
# configuration, and then restart Stalwart without the recovery variables"
# (https://stalw.art/docs/configuration/declarative-deployments). Recovery
# mode brings up the management HTTP endpoint on port 8080 — the WebUI
# included — and keeps every mail port closed.
#
# So: true for the first install, then false and upgrade again. Leaving it on
# is not a small untidiness — the mail ports stay shut while the Services and
# the Ingress look perfectly healthy, which is the loudest-looking silent
# failure this chart has. NOTES.txt prints a warning for as long as it is on.
enabled: false
# Log verbosity while recovery mode is on (STALWART_RECOVERY_MODE_LOG_LEVEL).
# Has no effect once bootstrap.enabled is false.
logLevel: info
# ---------------------------------------------------------------------------
# Ports. KUBERNETES ONLY — these numbers do not reach Stalwart.
# ---------------------------------------------------------------------------
# THE DUPLICATION BELOW CANNOT BE AVOIDED, SO IT IS WRITTEN DOWN INSTEAD.
#
# Every listener Stalwart binds is a NetworkListener object in the database.
# This chart cannot create those objects, so containerPort here is a second
# copy of a decision that is 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. Check both sides
# whenever you touch one.
#
# TWO NUMBERS PER ENTRY, AND THEY ARE NOT INTERCHANGEABLE:
# containerPort — what the application binds inside the pod, and what the
# NetworkListener object in the database must say.
# nodePort — what the cluster publishes on every node, and what the HA
# proxy in front of the cluster connects to.
# The Service maps one onto the other: nodePort 30025 -> targetPort 25.
#
# WHY THE APPLICATION BINDS THE ORDINARY 25/465/993. An earlier draft had it
# bind 30025/30465/30993 so that nothing privileged was ever bound and the
# NET_BIND_SERVICE capability could be dropped. That reasoning collapsed: the
# image ships "setcap cap_net_bind_service=+ep" on the binary, which makes it a
# capability-dumb binary, and the kernel then fails the execve ITSELF when the
# bounding set does not carry the capability — before the program binds
# anything. The capability has to be granted either way (see securityContext
# below), so high ports bought no hardening at all. What the ordinary numbers
# buy instead is predictability: the listener objects an operator creates in
# the WebUI carry the numbers from every page of Stalwart's documentation and
# from the image's own EXPOSE list, which takes most of the surprise out of the
# duplication above.
#
# The nodePorts must be inside the API server's --service-node-port-range
# (default 30000-32767). 30025/30465/30993 are, with the default — UNVERIFIED
# against this cluster's actual setting.
#
# Only these three plus HTTP are published. 587, 143, 110, 995 and 4190 are
# deliberately absent and can be added later without a breaking change: add an
# entry here and list it in the groups that should answer on it.
ports:
smtp:
containerPort: 25
nodePort: 30025
protocol: TCP
public: true
submissions:
containerPort: 465
nodePort: 30465
protocol: TCP
public: true
imaps:
containerPort: 993
nodePort: 30993
protocol: TCP
public: true
http:
# The management/JMAP/WebUI port, and the port the probes use. Never
# published as a NodePort: it is reached through the Ingress (ClusterIP
# Service) and by the kubelet.
containerPort: 8080
protocol: TCP
public: false
# The init container that seeds /etc/stalwart/config.json out of the ConfigMap.
# Pinned here rather than hardcoded in the template, and reviewed on every
# app-update round, because it is an auxiliary image and its tag does not come
# from appVersion (README, guideline 3).
initImage:
repository: docker.io/library/busybox
tag: "1.38.0-uclibc"
service:
# Cluster, and Local is not an option here — this is a property of the
# deployment's topology, not a preference.
#
# externalTrafficPolicy: Local only forwards to pods on the node that
# received the packet. Mail enters through an HA proxy that distributes
# 25/465/993 across the three control-plane nodes, and no Stalwart pod is
# ever scheduled on a control-plane node — so under Local EVERY packet would
# be dropped, not some.
#
# The price of Cluster is that kube-proxy masquerades the source address to
# the receiving node's IP (it has to: the reply would otherwise leave through
# a different node than the one the sender holds its connection with). For a
# mail server a lost source address is not cosmetic; it switches off SPF,
# takes DMARC with it, makes every blocklist report "clean", collapses
# per-IP rate limits onto one address, removes greylisting's key, and makes
# the auto-ban lock out every sender at once by banning the node IP. The
# Received: chain becomes worthless, which is the one item on that list that
# destroys evidence retroactively instead of just failing.
#
# What pays that price back is the PROXY protocol, and only because of this
# topology: the HA proxy terminates the inbound connection and opens its OWN
# to the node, so it can prepend a PROXY v2 header on that connection — for
# port 25 as well, where no remote mail server would ever send one. The real
# client address then travels in the header and the masquerading stops
# mattering. This is a MANDATORY companion step, not an optimisation; the
# wiki page has the all-or-nothing warning that goes with it.
externalTrafficPolicy: Cluster
# Extra annotations for every Service this chart renders (ClusterIP and
# NodePort alike). Free-form by nature.
annotations: {}
# ---------------------------------------------------------------------------
# Node groups. One StatefulSet, one headless Service and one NetworkPolicy each.
# ---------------------------------------------------------------------------
# A MAP, NOT A LIST, and on purpose: with a list every override would be
# index-based (--set nodes[1].replicas=2), so inserting a group later would
# silently repoint every existing override at the wrong group.
#
# The key is the cluster role name and goes into STALWART_ROLE. A ClusterRole
# object of exactly that name has to exist in the database — see the fail-open
# warning below, which is the most important paragraph in this file.
#
# THERE IS NO nodes.<group>.tasks KEY. Which tasks and listeners a role runs is
# a property of the ClusterRole object in the database, and this chart cannot
# write it. A values key that looked like it set them and did not would be
# worse than its absence.
#
# FAIL-OPEN: A NODE WHOSE ROLE IS MISSING FROM THE DATABASE BECOMES A FULL NODE.
#
# From the source, not the documentation. The bootstrap registry looks a role
# up by name; when it finds none it records a non-fatal error and leaves the
# role unset, and the listener check then reads
# bp.role.as_ref().is_none_or(|r| ...)
# which yields true for EVERY listener when the role is None. The same holds
# for the task set. So a typo in a group name here, or a rename here without
# the matching rename in the database, turns that group into a node that binds
# everything — the mx group would serve IMAP and the admin interface. 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.
#
# Three things in this chart contain that failure, and they are the reason the
# layout looks the way it does: mx ships with replicas: 0, the per-port
# Services publish only what was asked for, and the NetworkPolicy is a ceiling
# on reachability that holds no matter which ports the process actually bound.
# Documented as a trap rather than explained away.
nodes:
# Everything: all tasks, all listeners. The group the Ingress points at.
full:
# 0 is allowed and scales this group down; the chart-global scaleDown wins.
replicas: 1
# Which ports this group may be reached on. NOT a copy of the listener
# configuration in the database but a statement of its own: "this group is
# reachable here and nowhere else". That 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 — and
# it is exactly what takes the edge off the fail-open above.
#
# Every group needs http, including mx: it is the port all three probes
# use. It is not published for mx.
exposedPorts: [smtp, submissions, imaps, http]
# -----------------------------------------------------------------------
# Distribute THIS GROUP's pods over distinct nodes (Gitea #96)
# -----------------------------------------------------------------------
# Renders a topologySpreadConstraints entry on topologyKey
# kubernetes.io/hostname with maxSkew: 1 and a labelSelector the CHART
# fills in — the two labels it puts on its own pods anyway. You never
# write that selector, and that is the point: a spread constraint whose
# selector matches no pod is TRIVIALLY SATISFIED. It distributes nothing,
# the rollout is green, and nothing anywhere says so.
#
# ONLY WITHIN THIS 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.
#
# RENDERED ONLY FROM TWO EFFECTIVE REPLICAS UPWARDS. With one pod any
# placement satisfies it, so it would describe nothing; the count used is
# the computed one, so scaleDown: true removes it too.
#
# whenUnsatisfiable IS DoNotSchedule, i.e. THIS IS A PROMISE AND NOT AN
# EFFORT — and the price is a pod that stays Pending rather than one that
# lands somewhere useless. ScheduleAnyway was the original proposal and was
# rejected: with one worker at 97 % requested CPU the scheduler may put both
# replicas of a group on the same remaining node, entirely within the rule
# and with the redundancy gone. The cases where the promise costs something:
#
# - TWO OF THREE WORKERS GONE: the second replica stays Pending instead
# of joining the survivor — but only WHILE THE Node OBJECTS STILL
# EXIST. A NotReady node still counts as an (empty) domain, so the
# third replica would raise the skew to 2. Delete the Node objects and
# the domains disappear with them and the pod schedules. Same
# mechanism, opposite outcome, one step apart.
# - A NARROWING nodeSelector/affinity BELOW: see nodeSelector further
# down. That combination blocks itself, deliberately and loudly.
#
# A Pending pod with no scheduling event reads like a different problem
# than a distribution rule, so NOTES.txt names this rule whenever it is
# rendered.
#
# THE FIELD THAT CAN BE DROPPED WITHOUT A WORD: the constraint carries
# nodeAffinityPolicy: Ignore, and that field is BETA — on by default since
# Kubernetes 1.26, graduated to GA with the feature gate
# NodeInclusionPolicyInPodTopologySpread in 1.33. On an API server older
# than 1.26, or one with the gate switched off, the field is pruned
# SILENTLY, and the gap it was set against comes straight back (the
# nodeSelector case below). Nothing reports it: the object still looks
# correct in `kubectl get sts -o yaml`, minus one line. So 1.26 is the
# minimum version this chart can make the promise on at all. NOT MEASURED
# against an older server — this is the documented pruning behaviour for a
# field behind a disabled gate, not something tried here. Check the
# rendered StatefulSet against the live one if the server is near that
# boundary.
#
# WHAT IS *NOT* A CONSEQUENCE OF THAT FIELD, because the two
# node-inclusion policies are not a package: the ceiling on replicas
# described at nodeSelector below comes from nodeTaintsPolicy, which this
# chart leaves at its own default. It was there before nodeAffinityPolicy
# was set and is not its price.
#
# ONE PRECONDITION, AND IT IS NOT IN THIS FILE: this works because the
# StatefulSet has NO volumeClaimTemplates. Open measurement 1 on #65 asks
# whether anything under /var/lib/stalwart has to survive a restart; if
# that answer ever becomes yes, a PVC per group appears, and with a
# node-local or topology-bound storage class the pod is pinned 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. This rule holds only while the chart
# stays PVC-free.
#
# AND THE COST OF TURNING IT ON BY SCALING UP: going from 1 to 2 replicas
# changes the POD TEMPLATE (the constraint appears in it), so the existing
# pod is replaced in the same rollout round rather than merely joined by a
# second one. Expect a restart of the running node, not just an addition.
spreadAcrossNodes: true
# A PodDisruptionBudget for this group, rendered only while the group's
# COMPUTED replica count is >= 2 — the spread constraint's companion: that
# one decides the two pods sit on different nodes, this one decides only
# one of those nodes may be emptied at a time.
#
# The condition is not tidiness. minAvailable: 1 with exactly one replica
# forbids every voluntary eviction, so `kubectl drain` blocks forever on a
# pod nothing may move — and the message names the PDB rather than the
# replica count, which sends the reader to the wrong file. Precedent and
# full argument: keycloak (README, guideline 11).
podDisruptionBudget:
enabled: true
# minAvailable, not maxUnavailable: the promise is about the service —
# at least one pod of this group keeps answering through a drain.
minAvailable: 1
# Generic pass-through into this group's pod spec. Rendered verbatim with
# toYaml; the chart reads none of their contents, which is why they are
# loosely typed in values.schema.json — Kubernetes owns their schema. An
# empty value renders no field at all.
#
# THE ONE COMBINATION THAT BLOCKS ITSELF, and it does so on purpose:
# narrowing the eligible nodes below this group's replica count while
# spreadAcrossNodes is on leaves a replica permanently Pending. With
# nodeAffinityPolicy: Ignore on the chart's constraint, a nodeSelector
# down to one node gives 2,0,0 — skew 2 — rather than the one eligible
# domain and skew 0 it would give under the API default of Honor. The
# second shape is what we do NOT want: both pods co-located, nothing
# Pending, no event, and a selector that is perfectly correct, so no check
# in CI can see it either. Loud beats silent; that is the whole trade.
#
# AND THE CEILING, WHICH IS A RULE AND NOT A NUMBER. The chart leaves
# nodeTaintsPolicy at its API default of Ignore, so EVERY node counts as a
# domain — control-plane nodes included, and no Stalwart pod ever runs
# there. Those domains hold the minimum at 0 permanently, so with
# maxSkew: 1 a group can never place more replicas than there are
# SCHEDULABLE WORKERS. Add a worker and the ceiling rises with it; remove
# one and it falls. Do not write today's count down here — it would be
# wrong at the next cluster rebuild and nobody would come back for it.
# Raising a group past that ceiling needs spreadAcrossNodes: false, or an
# own topologySpreadConstraints entry with ScheduleAnyway.
affinity: {}
nodeSelector: {}
tolerations: []
# Appended AFTER the chart's own constraint and really AND-ed with it, so
# a second key — topology.kubernetes.io/zone, say — works as expected.
#
# An entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule
# (or omitted, which IS DoNotSchedule) ABORTS THE RENDER naming both this
# value and spreadAcrossNodes. Kubernetes keys a constraint on exactly
# that pair: `kubectl create` rejects the duplicate with a message about a
# list index instead of a value, and an apply-based path merges the two
# over patchMergeKey: topologyKey SILENTLY — one labelSelector is dropped
# and the result is green. A named abort is better than either.
topologySpreadConstraints: []
resources:
requests:
cpu: 200m
memory: 512Mi
ephemeral-storage: 64Mi
# Additive environment entries for this group's container, appended to the
# ones the chart sets (STALWART_ROLE, STALWART_HOSTNAME and the secret-fed
# ones). Supports the value, secretKeyRef and configMapKeyRef forms and is
# rendered through tpl.
#
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
# does not replace it. At install both reach the pod and the later one wins;
# on an EXISTING release Kubernetes merges env by name, the pair collapses
# to the chart's own value, and helm upgrade still reports success. Do not
# use this to override STALWART_ROLE.
env: []
# SMTP intake only. Add the matching ClusterRole in the database first:
# {"name":"mx","tasks":{"@type":"DisableAll"},"listeners":{"@type":"EnableSome", ...}}
mx:
# Zero until the role "mx" exists in the database. Raising this before the
# role is there does not fail — it produces a second full node (see
# fail-open above), which is the opposite of what this group is for.
replicas: 0
exposedPorts: [smtp, http]
# Same six keys as nodes.full, same defaults, and argued in full there —
# including the three things that are easy to get wrong: that
# nodeAffinityPolicy can be pruned silently below Kubernetes 1.26, that the
# replica ceiling is "as many as there are schedulable workers" rather than
# a number, and that the ceiling comes from nodeTaintsPolicy and not from
# the field this chart sets.
#
# Neither object is rendered for this group while replicas is 0, and the
# condition is on the computed count: raising it to 2 (after the "mx"
# ClusterRole exists — see above) brings both the spread constraint and the
# PodDisruptionBudget with it, independently of what "full" does.
spreadAcrossNodes: true
podDisruptionBudget:
enabled: true
minAvailable: 1
affinity: {}
nodeSelector: {}
tolerations: []
topologySpreadConstraints: []
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 64Mi
env: []
# ---------------------------------------------------------------------------
# NetworkPolicy — the ceiling on reachability, independent of what bound what
# ---------------------------------------------------------------------------
networkPolicy:
# On by default. Only a real boundary where the cluster's CNI enforces
# NetworkPolicies; an unenforced policy is accepted by the API server and
# filters nothing.
enabled: true
# Source CIDRs allowed to reach the http port, in addition to
# allowedNamespaces. This is where the kubelet's probe traffic has to be
# permitted: probes come from the IP of the WORKER node running the pod, an
# address this chart cannot know.
#
# EMPTY MEANS OPEN, not blocked. With no entry the http rule is rendered
# without a "from" selector, so the port is reachable from anywhere the CNI
# routes to. That is the deliberate choice between two bad defaults: a
# guessed CIDR that excludes the real node addresses blocks every probe, and
# a pod that never becomes Ready reads as a broken application rather than as
# a policy. http is not a NodePort, so "open" here means open inside the
# cluster, not from the internet. Narrow it by listing the worker node IPs
# (e.g. ["10.0.1.11/32", "10.0.1.12/32"]).
#
# And mind the companion trap: once this list is non-empty the http rule is
# restricted, which cuts off the ingress controller too unless its namespace
# is in allowedNamespaces below.
probeSources: []
# Namespaces allowed to reach the http port, matched on the
# kubernetes.io/metadata.name label Kubernetes sets on every namespace
# itself. The release's own namespace is always allowed. Put the ingress
# controller's namespace here (e.g. ["ingress-nginx"]) as soon as
# probeSources is set. A name that does not exist matches nothing and is
# silently ineffective.
allowedNamespaces: []
# Egress is restricted BY PORT, not by destination, and the reason is a limit
# of the object rather than laziness: a NetworkPolicy matches ipBlock CIDRs
# and pod/namespace selectors, never hostnames — so dataStore.postgres.host
# and externalServices.*.host cannot appear in it. Each rule below therefore
# permits its port to any destination. What it still buys is real: a
# compromised pod cannot open arbitrary ports outbound.
#
# Outbound SMTP is the one that must stay wide: delivering mail means
# connecting to port 25 on any host on the internet.
egress:
# DNS (UDP and TCP 53) to any destination. Switching this off breaks
# everything, including MX lookups and SPF.
dns: true
# Outbound SMTP on port 25 to any destination — required for delivery.
# Deliberately NOT derived per group from the outboundMta task: this chart
# does not know which tasks a role runs, the value would drift away from
# the database, and the failure mode would be mail silently not leaving.
# So it is uniform for every group.
smtp: true
# Additional outbound TCP ports, beyond the data store, S3 and Valkey ports
# the chart already derives from the values above. Add 443 here if the
# deployment needs outbound HTTPS (ACME over DNS-01 through a provider API,
# an AI model endpoint, a remote lookup list).
extraPorts: []
# ---------------------------------------------------------------------------
# External services — KUBERNETES ONLY. These do not configure Stalwart.
# ---------------------------------------------------------------------------
# The blob store and the in-memory store are objects in the database and are
# created in the WebUI. What the chart uses these values for is exactly three
# things, and nothing else:
# - the port goes into the egress rule of every NetworkPolicy,
# - the valkey host decides whether the in-memory store has an address at all
# (see the fail below), and
# - both hosts are printed in NOTES.txt as the addresses to enter in the
# WebUI, so the installation checklist is not a separate document.
# Setting a host here does not point Stalwart at it.
externalServices:
s3:
host: ""
port: 443
valkey:
# Empty plus valkey.enabled: true resolves to the subchart's Service,
# "<release>-valkey". Empty with the subchart off and more than one
# Stalwart pod ABORTS THE RENDER with a message naming both values, because
# that combination is a cluster without an in-memory store: from v0.16 the
# coordinator's Default variant "reuses the Redis connection already
# configured on the in-memory store", so a multi-node install has no
# coordination path at all. Failing the render is the loud version of a
# cluster that comes up and then does not agree with itself.
host: ""
port: 6379
# ---------------------------------------------------------------------------
# TLS for the mail ports — cert-manager, not Stalwart's own ACME
# ---------------------------------------------------------------------------
# Why not Stalwart's built-in ACME: the HA proxy forwards 25, 465 and 993, and
# NOT 80, so an HTTP-01 challenge answered by Stalwart is unreachable from
# outside. DNS-01 inside Stalwart would work but would duplicate what
# cert-manager already does in this cluster.
#
# Why a certificate is needed at all when there is an Ingress: the Ingress
# covers only the WebUI and JMAP. The HA proxy terminates no TLS, so Stalwart
# itself has to present the certificate for STARTTLS on 25 and implicit TLS on
# 465 and 993.
certificate:
enabled: true
# Cluster issuer for the MAIL certificate. null falls back to
# ingress.clusterIssuer.
#
# MIND THE CHALLENGE TYPE. The same reasoning that rules out Stalwart's ACME
# rules out an HTTP-01 issuer here: port 80 does not reach this cluster for
# the mail hostname. A DNS-01 issuer is usually the right answer, and it is
# usually a DIFFERENT issuer from the one the Ingress uses — which is why
# this is its own value instead of reusing ingress.clusterIssuer silently.
clusterIssuer: null
# Additional DNS names for the mail certificate. ingress.domain is always
# included, so this is for the hostnames clients and other MTAs actually
# connect to when they differ from the WebUI host — typically the MX name and
# the autoconfig names (mail., smtp., imap.).
extraDnsNames: []
# Mount path of the issued certificate inside the container. A separate
# top-level path rather than something under /etc/stalwart, so there is no
# mount nested inside the config emptyDir.
#
# These two files are what the TLS object in the DATABASE has to point at:
# certificate: /etc/stalwart-tls/tls.crt
# privateKey: /etc/stalwart-tls/tls.key
mountPath: /etc/stalwart-tls
ingress:
# Gates the whole Ingress template. ingress.domain stays REQUIRED either way:
# it is read by the Certificate's dnsNames as well, so switching the Ingress
# off would otherwise render a certificate with no name in it. Only
# ingress.clusterIssuer becomes unnecessary — and only if
# certificate.clusterIssuer is set, since that falls back to it.
enabled: true
# REQUIRED, no default.
clusterIssuer: null
# REQUIRED, no default.
domain: null
className: nginx
# Which node group the Ingress routes to. Must be a key of nodes: above, and
# that group must list http in its exposedPorts — both are checked, and the
# render aborts naming the value. It matters that this is a single group: the
# WebUI's setup and management screens are not safe to spread over a group
# that may not run the management tasks, and a Service selecting several
# groups would send every request to a random one of them.
nodeGroup: full
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
# controller behaviour. For THIS chart that is the most consequential line in
# the whole block: read the httpRoute section below before switching modes,
# because what lives in here on the live installation is the CORS
# configuration the webmail depends on.
annotations: {}
# ---------------------------------------------------------------------------
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114)
# ---------------------------------------------------------------------------
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY an
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP
# hostname, and no implementation-specific policy object.
#
# ONE ROUTE, NOT ONE PER NODE GROUP. #114 describes this chart as rendering
# "per node group"; its SERVICES and StatefulSets are, the routing object is
# not and must not be. The route targets exactly the ingress.nodeGroup group,
# through that group's own http Service, and aborts the render if that group
# does not expose "http" - the same two guards the Ingress has, for the same
# reason: the WebUI's setup and management screens are not safe to spread over
# a group that may not run the management tasks.
#
# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
# anything does. certificate.enabled renders a Certificate for STARTTLS on 25
# and implicit TLS on 465/993; those connections pass through neither an
# ingress controller nor a Gateway. So in Gateway mode:
# - ingress.domain is still REQUIRED (the certificate's dnsNames read it),
# - ingress.clusterIssuer is still required unless certificate.clusterIssuer
# is set,
# - and the Certificate must NOT be "cleaned up" as part of the migration.
# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed
# certificate: clients see a name mismatch while every Kubernetes object in the
# release stays green.
#
# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not
# routed by the Ingress. They keep reaching the pods through the per-port
# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners;
# this chart renders neither, and doing so would be a separate decision.
#
# WHAT THIS MODE COSTS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
# Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
# all of it 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://webmail.bekemeyer-klein.de
# 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 in this same group. The CORS
# configuration is what allows the browser-side webmail to call this server's
# JMAP endpoint at all. Lose it and the webmail stops working, with a console
# error in the browser and NO server-side symptom whatsoever.
#
# AND "no counterpart" would be the wrong thing to claim here. Measured against
# the CRDs this repository pins (gateway-api v1.6.3 standard channel,
# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by
# inference:
# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with
# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge.
# Its documented support level, verbatim from that CRD: "Support:
# Extended".
# - `responseHeaderModifier` is in the same CRD and is ALSO "Support:
# Extended", not Core.
#
# There IS a counterpart, then, and this chart still does not render it. Three
# reasons, in order:
#
# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's
# - a specific origin, a specific method list. Rendering a filter means
# inventing a values surface in a sweep PR, and one that would exist in
# Gateway mode only, since the chart sets no CORS annotations in Ingress
# mode either. That asymmetry is its own defect and its own piece of work.
# 2. 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 arrived at for statusCode). The observed
# failure is quieter still: Cilium accepts filters.type: CORS and
# forwards the browser's preflight upstream anyway (cilium/cilium#45272),
# i.e. Accepted: True with the behaviour absent. A chart cannot know which
# implementation it landed on.
# 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
# ONE HERE. A responseHeaderModifier can add the three
# Access-Control-Allow-* headers to a response. The PREFLIGHT is a
# different thing: the browser sends OPTIONS first 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. A bare header
# modifier cannot do that. And this installation's header list includes
# Authorization, 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, using that implementation's own CORS
# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with
# curl: curl sends no preflight, so it passes while a browser fails.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the HTTP hostname has one source of truth.
# Set explicitly only to publish under a different or an additional name -
# which is how a migration from the Ingress runs: the HTTPRoute takes a
# second hostname while the Ingress keeps ingress.domain.
#
# NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After
# bootstrap the WebUI is reached at https://<the configured server
# hostname>/admin, and no value of this chart changes that - so a second
# hostname is reachable without the application agreeing that it is its name.
hostnames: []
# ---------------------------------------------------------------------------
# MTA-STS — the policy route, in BOTH routing modes (Gitea #123)
# ---------------------------------------------------------------------------
# MTA-STS lets a sending mail server find out, over HTTPS, that this domain
# expects TLS. Stalwart serves the policy file itself — this block is only
# about ROUTING a sending MTA to it, and the route has to exist in whichever
# mode this chart is publishing in.
#
# WHAT IS RENDERED, AND IT IS NOT THE SAME SHAPE ON BOTH SIDES:
#
# Ingress mode — ONE Ingress object PER DISTINCT clusterIssuer, each with one
# rule per host and its tls: list grouped by secret name.
# Several objects because cert-manager's cluster-issuer is an
# object-level ANNOTATION while secretName is a per-tls-entry
# field, so one object cannot both request a certificate from
# issuer X and leave another host's secret alone. The full
# argument, with the cert-manager behaviour it was measured
# against, is in stalwart.mtaSts.ingressGroups in
# _helpers.tpl.
# Gateway mode — ONE HTTPRoute with all the hostnames. Nothing differs per
# host here (same path, same backend, no filter) and
# spec.hostnames is a list, so one object says exactly what
# is meant. TLS sits on the Gateway's listener and this chart
# renders no certificate for it.
#
# Both point at the same per-group http Service the WebUI route points at
# (ingress.nodeGroup), on the one path RFC 8461 allows.
#
# THE LIST IS THE SWITCH. There is deliberately no mtaSts.enabled: "enabled
# with an empty list" would be a state with no meaning that the chart would
# then have to catch. Empty list — the default — renders nothing in either
# mode, so this block changes nothing about a running installation until it is
# filled.
#
# THE PATH IS HARDWIRED and is not a value. RFC 8461 fixes it: the policy is
# fetched from /.well-known/mta-sts.txt on mta-sts.<domain>, with pathType
# Exact (type: Exact in the HTTPRoute) because exactly one file lives there. A
# values key for a protocol-mandated path is a key that can only ever be set
# wrong — and "wrong" here is invisible from this side, see the limit below.
# Exact rather than a prefix also matters in its own right: a prefix on these
# hostnames would publish the mail server's whole management surface under
# them.
#
# ------------------------------------------------------------------------
# THE LIMIT, AND IT HAS TO BE SAID RATHER THAN SUGGESTED AWAY
# ------------------------------------------------------------------------
# THIS CHART REGISTERS A ROUTE. IT DOES NOT CREATE A DOMAIN. Read the first
# paragraph of this file again: since v0.16 the domains live in Stalwart's DATA
# STORE, and this 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, and nothing wrong on this side at all. And the other
# half of it is worse: MTA-STS failures are silent ON THE SENDER'S SIDE. A
# sending MTA that finds no policy does not report anything here; it simply
# falls back to delivering in the clear. So the thing this block exists to
# achieve can be absent with no symptom in this cluster whatsoever.
#
# That is the same genus as the OnePasswordItem limit this chart already
# documents: a promise the chart cannot keep, and therefore one it has to name.
# Check an entry from outside after adding it:
# curl https://mta-sts.<domain>/.well-known/mta-sts.txt
#
# ------------------------------------------------------------------------
# THE SUBDOMAIN TRAP, which is the one 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.
#
# With a secret per entry that is solved, which is why the field exists. The
# trap is written down 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 same 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.
#
# ------------------------------------------------------------------------
# WHY A SECRET PER ENTRY AND NOT certificate.extraDnsNames
# ------------------------------------------------------------------------
# The alternative was real and was rejected. This chart already has
# "Additional DNS names for the mail certificate" (certificate.extraDnsNames,
# further up this file), so the mta-sts.* names could have been folded into the
# mail certificate instead of carrying their own secrets.
#
# They are not, because that would hang the MAIL certificate on this list. The
# mail certificate is what serves STARTTLS on 25 and implicit TLS on 465/993;
# a bad entry here — a typo, a name whose DNS-01 challenge cannot be answered —
# would then fail the issuance of the certificate the mail ports depend on.
# A policy route that is only a nice-to-have would be able to take SMTP and
# IMAP with it. Separate secrets keep the blast radius at the entry.
#
# ------------------------------------------------------------------------
# NOT DONE BY THIS CHART, so it is not mistaken for done
# ------------------------------------------------------------------------
# - THE DNS RECORD. MTA-STS also needs a TXT record at _mta-sts.<domain>;
# without it no sending MTA ever looks for the policy. That is DNS, not
# Kubernetes, and nothing here creates it.
# - THE POLICY'S CONTENT. version/mode/max_age/mx come from Stalwart's data
# store, per domain. The running installation serves mode: testing, which
# means sending MTAs enforce nothing — also a data-store setting and not a
# chart question.
# - REMOVING AN EXISTING OBJECT FROM ANOTHER REPOSITORY. Where a route for
# one of these hosts already exists elsewhere (jk-cluster-config does serve
# mta-sts.bekemeyer-klein.de from a Fleet release of its own), 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 — which is the loud failure, but it is a failure.
mtaSts:
# Empty by default: no object in either mode, and no value becomes required.
#
# Entry form, and the two TLS fields apply ONLY IN INGRESS MODE:
#
# host REQUIRED. The policy hostname, i.e. mta-sts.<domain>.
# tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose:
# the plausible default (this release's own
# tls-<release>-<chart>-ingress) is the wrong certificate —
# it covers ingress.domain, not this host. Hosts that share
# one secret are collected into one tls: entry, so one
# certificate with several SANs is asked for once rather
# than once per host. Inert in Gateway mode.
# clusterIssuer OPTIONAL, and EMPTY MEANS NO ANNOTATION — i.e. "the
# certificate for this host comes from somewhere else, do
# not ask cert-manager for it". That is what keeps a secret
# owned by another release from being fought over, the same
# deliberate omission as the qBittorrent object in
# freesailarr and two of the four realm ingresses in Gitea
# #122. Entries sharing a secret must agree on it (the
# render aborts naming both), because cert-manager names the
# Certificate after the secret and the issuer is an
# object-level annotation. Inert in Gateway mode.
#
# 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.
#
# hosts:
# - host: mta-sts.example.com
# tlsSecretName: tls-mta-sts-example-com-ingress
# clusterIssuer: letsencrypt-issuer
# # A deeper name needs its own certificate — the parent's wildcard does
# # not reach it. See the subdomain trap above.
# - host: mta-sts.sub.example.com
# tlsSecretName: tls-mta-sts-sub-example-com-ingress
# clusterIssuer: letsencrypt-issuer
# # Certificate managed elsewhere: no issuer, so no annotation and no
# # Certificate requested by this release.
# - host: mta-sts.other.example
# tlsSecretName: tls-some-existing-wildcard-ingress
hosts: []
# ---------------------------------------------------------------------------
# Hardening — chart-wide, applied to every node group
# ---------------------------------------------------------------------------
securityContext:
pod:
# uid/gid 2000 is the image's own "stalwart" user: the image already runs
# as non-root, so nothing has to be bent here.
runAsNonRoot: true
runAsUser: 2000
runAsGroup: 2000
# In the pod context so it also covers the init container that places
# config.json; a container-level profile would have to be repeated for
# every container and would be missed on the next one added.
seccompProfile:
type: RuntimeDefault
# No fsGroup and no fsGroupChangePolicy: this chart mounts no PVC. Both
# volumes are emptyDirs (state lives in Postgres, S3 and Valkey) and the
# kubelet creates those owned by the pod's user already.
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
# DOCUMENTED DEVIATION from the repo's hardening baseline (README,
# guideline 5), forced by the stalwartlabs/stalwart image. Precedent for a
# justified capability: samba.
#
# NET_BIND_SERVICE IS REQUIRED TO *START* THE CONTAINER, NOT TO BIND A PORT.
# Both published image variants run
# setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
# (Dockerfile.build at v0.16.24, line 164 for the glibc target and line 186
# for the musl/-alpine one). The "e" in "+ep" sets the file's effective
# bit, which makes the binary a "capability-dumb binary" in the kernel's
# terms, and capabilities(7) then applies this check 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 error
# EPERM."
#
# 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 it does not ask
# whether the capability is needed. This is why the three escape routes
# that look obvious all fail for the same reason: high ports do not help,
# the -alpine image carries the identical setcap, and the
# net.ipv4.ip_unprivileged_port_start sysctl addresses binding rather than
# exec.
#
# AND THE SECOND HALF, WHICH IS WHY THIS LINE MUST NOT BE "CLEANED UP":
# THE CAPABILITY IS NEVER ACTUALLY USED. It is spent entirely on satisfying
# the execve check. Stalwart does bind 25/465/993 here, which are
# privileged — but inside a pod that needs no capability either way: the
# kubelet sets net.ipv4.ip_unprivileged_port_start=0 in the pod network
# namespace, so a non-root container binds low ports regardless (measured
# in k3d for template-chart, see its ports comment). So a hardening pass
# that greps for "a capability nobody uses" will find exactly this one and
# removing it stops the chart from starting at all. Do not remove it
# without first checking whether upstream still sets the file capability;
# the day that setcap disappears from Dockerfile.build is the day this
# deviation can go with it.
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
# Probes for every node group, on the two separate endpoints the image
# provides. Values-configurable; an erased block falls back to these defaults
# rather than removing the probe.
#
# THE INTERVALS ARE CALCULATED, NOT TASTE. DO NOT TIGHTEN THEM WITHOUT REDOING
# THE ARITHMETIC BELOW.
#
# Stalwart applies an anonymous HTTP rate limit to /healthz as to everything
# else, and its default is 100 requests per 60 seconds KEYED ON THE SOURCE IP
# (registry default rate_limit_anonymous: count 100, period 60000ms). Kubelet
# probes arrive from the IP of the NODE running the pod, not from the pod, so
# every Stalwart pod on one node draws from the SAME bucket. Exceeding it
# returns 429, a 429 fails the probe, and failing liveness restarts the pod —
# a self-inflicted restart loop that gets worse the more pods a node holds.
#
# The budget per pod, with the values below:
# steady state liveness 60/20 = 3/min + readiness 60/20 = 3/min = 6/min
# while starting startup 60/10 = 6/min = 6/min
# The startup probe GATES liveness and readiness, so the three never run at
# once and 6 requests/min/pod is the worst case in both phases. Against a
# 100/60s bucket that leaves room for about 16 Stalwart pods per node before
# the limit is in sight.
#
# For comparison, the obvious 10/10/5 defaults would cost 12/min/pod in steady
# state and allow only about 8 pods per node — which is not far from a
# plausible deployment. That is the whole reason these numbers are 20/20/10.
#
# What the wider intervals cost, stated so the trade is visible: up to 20s
# before unreadiness removes a pod from its Services, and up to 60s
# (3 x 20s) before a hung pod is restarted. For a mail server both are
# acceptable — SMTP senders retry for days.
#
# WHY HTTP AND NOT A tcpSocket READINESS PROBE. A tcpSocket probe on the mail
# port would bypass the bucket entirely, and it was considered for exactly that
# reason. It is rejected on three grounds, none of them about convenience:
# 1. It would be GREEN in precisely the failure this chart is built to
# contain. A node that fail-opens (its ClusterRole missing from the
# database) binds every listener, so a TCP connect to 25 succeeds on a
# broken "mx" node just as it does on a healthy "full" one.
# 2. It proves strictly less. A TCP accept says a socket is bound;
# /healthz/ready additionally says the server reached the point where a
# data store handle exists at all.
# 3. It would have to differ per node group (the "mx" group has no 465 or
# 993), so the fallback default in _helpers.tpl could no longer carry the
# probe's context — the failure mode guideline 12.5 exists for.
# If a node ever does need to carry more than ~16 pods, the right move is to
# raise the limit on the server (the allow-list bypasses the limiter outright
# for listed addresses) rather than to blind the probes.
#
# WHAT THESE PROVE, AND WHAT THEY DO NOT — three honest limits:
# - Liveness is deliberately on /healthz/live so a Postgres outage cannot
# restart every pod in the cluster at once; the restart would fix nothing
# and would drop every connection in flight (guideline 12.3).
# - /healthz/ready DOES NOT QUERY THE DATABASE. Read from the source at
# v0.16.24: it returns 200 iff the data store handle is not Store::None.
# So readiness here does not hang on the dependency the way guideline 12.3
# asks, and it cannot — the endpoint upstream offers is shallow. Named
# rather than papered over, because a green pod proves less than it looks.
# - It also does not catch the missing-config.json trap (a server that fell
# back to an ephemeral in-memory store still has a data store handle).
#
# NOT VERIFIED AGAINST A RUNNING SERVER: whether /healthz answers a plain
# kubelet probe at all. The source shows no auth, no loopback check and no
# trusted-network restriction, but the request still passes the configurable
# Http.allowedEndpoints expression (default "200", i.e. everything allowed) —
# and its loopback escape hatch does NOT cover a node IP, so an operator who
# later writes a restrictive expression breaks every probe. The image's own
# healthcheck sends "X-Forwarded-For: 127.0.0.1", which is how it puts itself
# in the loopback rate-limit bucket; that only has an effect when
# Http.useXForwarded is on, and it is off by default.
livenessProbe:
httpGet:
path: /healthz/live
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness and readiness, so neither needs an
# initialDelaySeconds. Budget = periodSeconds x failureThreshold = 300s, which
# is generous on purpose: the first start of a node has to reach Postgres,
# acquire its cluster node-id lease and read the whole configuration registry
# out of the database.
startupProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
# ---------------------------------------------------------------------------
# The Valkey subchart — THIS REPO'S OWN valkey CHART, off by default
# ---------------------------------------------------------------------------
# Stalwart needs an in-memory store for a cluster, and the coordinator rides on
# the same connection. The subchart is the path for a deployment that has no
# Valkey yet; an installation that already runs one points
# externalServices.valkey.host at it and leaves this off.
#
# THE SWITCH IS HERE AND NOT UNDER "valkey:", and it has to be. Everything under
# "valkey:" is handed to the subchart as its values, and our valkey chart ships
# a strict schema (additionalProperties: false) with no "enabled" property — so
# "valkey.enabled" is rejected BY THE SUBCHART. Measured; and because Helm skips
# a disabled subchart's schema validation, the error would only ever appear for
# whoever first switched it on. Chart.yaml has the error text.
valkeySubchart:
enabled: false
# ---------------------------------------------------------------------------
# Values passed THROUGH to the valkey subchart
# ---------------------------------------------------------------------------
# Read as the subchart's own values, so the key names are ITS schema, not
# upstream's and not this chart's. `valkey/values.yaml` in the repo is the
# reference; its header comment explains the pairs. Nothing here has any effect
# while valkeySubchart.enabled is false.
#
# WHY THIS IS NOT "upstream's chart as it comes": it is our chart, so there is
# no third party's defaults to leave unpatched and no shortfall against this
# repo's conventions to document — it follows them because it IS one of them.
# What still applies in full is the subchart being invisible downstream (a
# Valkey fix reaches a cluster only when THIS chart is republished — Chart.yaml
# has the detail and the PR-ordering warning), that scaleDown and
# checksum/config stop at the subchart boundary, and that every helm command
# needs ci/helm-dependencies.sh to have run first.
valkey:
secrets:
# REQUIRED once the subchart is on — the subchart's own required() aborts
# the render without it, which is the correct loud failure.
#
# MIND THE FIELD, it is not a password. The 1Password item carries ONE
# field, `auth-conf`, whose value is the COMPLETE configuration line
# requirepass <password>
# The valkey chart mounts that file and `include`s it, and an init container
# refuses to start the pod unless the line is really there — because the
# alternative failure is an open server that looks perfectly healthy. A bare
# password in that field does not work.
authSecretRef: null
# CACHE, not queue — a decision, with its cost named.
#
# 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 cluster coordination pub/sub. Every one
# of those is reconstructible: counters and bans restart from zero, tokens and
# sessions force a re-authentication, and pub/sub messages are not stored keys
# at all. 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, so: cache.
#
# THE COST, because `allkeys-lru` means keys really are discarded under memory
# pressure: the rate limiters and the auto-ban FAIL OPEN. An attacker who can
# drive memory usage can age out their own ban entry. The answer is to size
# memory for the working set rather than to switch to `noeviction` — with
# `noeviction` Stalwart's writes to the store start erroring instead, which
# takes out authentication rather than just the limits. Size it, and treat the
# limiters as a nuisance filter, not as a security boundary.
persistence:
enabled: false
valkey:
maxmemoryPolicy: allkeys-lru
# The subchart derives maxmemory from the computed memory limit and aborts
# its render if an explicit maxmemory exceeds it. Defaults left alone.
resources:
requests:
cpu: 50m
memory: 128Mi
ephemeral-storage: 32Mi
# The subchart's own NetworkPolicy, with its own semantics — this chart's
# networkPolicy block does not reach it. On by default there, allowing the
# release's own namespace. It needs an entry here only when Stalwart runs in a
# DIFFERENT namespace from the Valkey, which with the subchart it does not.
networkPolicy:
allowedNamespaces: []