Files
helm-charts/stalwart/values.yaml
T
jklein 6ade74db80
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 7s
SonarQube Analysis / SonarQube Analysis (push) Successful in 27s
feat(stalwart): spread each node group over nodes and give each one a PDB
A two-replica node group was redundancy on paper: nothing kept both pods off
one node, and no PodDisruptionBudget kept a drain from taking both at once.
Both objects are now rendered per node group, gated on that group's COMPUTED
replica count being >= 2.

The distribution is a promise, not an effort: maxSkew 1 on
kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule, selecting only
this group's own pods, so an "mx" and a "full" pod may share a node. The
selector comes from the chart (stalwart.groupLabels), never from the operator -
a spread constraint whose selector matches no pod is trivially satisfied,
distributes nothing and reports nothing.

nodeAffinityPolicy: Ignore is set explicitly, and that is the load-bearing
detail. Its API default is Honor, under which a narrowing nodeSelector shrinks
the set of counted domains until both replicas fit into one: skew 0, both pods
co-located, no Pending, no event, and a selector that is correct - so no check
can see it. With Ignore the same narrowing gives skew 2 and the second pod is
Pending. A render abort was the alternative and would also have forbidden a
nodeSelector spanning five nodes, which is legal.

A user entry colliding with the chart's own on (topologyKey,
whenUnsatisfiable) aborts the render naming both values, because the API
rejects the duplicate with a message about a list index and an apply-based path
merges the two over patchMergeKey: topologyKey silently, dropping one selector.

Minor rather than major, proved with a diff rather than argued: with the
shipped defaults (full: 1, mx: 0) and with ci/stalwart/test-values.yaml,
helm template renders byte-for-byte what 1.0.1+upv0.16.24 rendered - both
diffs empty.

New in CI, over every chart on every PR: ci/run-selector-match-test.sh holds
every rendered PodDisruptionBudget selector and every topologySpreadConstraints
labelSelector against the labels of the pod templates in the same render. Helm
itself is the YAML parser (ci/yaml2json), because the runners have neither
pyyaml nor yq and a hand-written parser could read nothing and report nothing.
podAffinity/podAntiAffinity are out of scope; matchExpressions-only selectors
are reported as not checked rather than counted as passing. Two anti-vacuum
guards: a chart with an overlay under ci/selector-match/ that renders no
selector fails, and a run that checks no selector at all fails.

Fixes #96
2026-10-07 19:36:04 +00:00

883 lines
47 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.
annotations: {}
# ---------------------------------------------------------------------------
# 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: []