# 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..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..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:" 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..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 everything else that needs the port. The chart # adds the release's own namespace, ingressController below and # allowedNamespaces to the same rule for exactly that reason. 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. A name that does not # exist matches nothing and is silently ineffective. # # NOT FOR THE INGRESS CONTROLLER - use ingressController below. This value is # namespace-granular, so naming the controller's namespace here would open # the http port to EVERY pod in it (kube-system, in this cluster). The # earlier version of this comment advised exactly that, with # ["ingress-nginx"] as the example, and both halves of that advice are now # wrong: see ingressController. allowedNamespaces: [] # The ingress controller allowed to reach the http port once probeSources or # allowedNamespaces restricts it. With neither set the http rule carries no # source selector at all and the controller reaches the port anyway, so this # value only becomes load-bearing at the moment one of them is filled - which # is also the moment the Ingress would otherwise go dark. # # The two keys are rendered as ONE "from" entry, which makes them an AND: # pods carrying podLabels IN namespace. That is the whole point of having # them separate from allowedNamespaces. # # MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is GONE - # no DaemonSet, no pods - the only IngressClass is "traefik" and it is the # default, and the controller is the DaemonSet rke2-traefik in kube-system. # The same Traefik serves the Gateway API here as well. A policy written # against the wrong controller is accepted, looks strict, and silently blocks # all web traffic; ingress.className says nothing about it, because a class # names an implementation and not the pods the traffic comes from. ingressController: namespace: kube-system podLabels: app.kubernetes.io/name: rke2-traefik # 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, # "-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:///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. # Named BY INDEX over those groups — # -stalwart-mta-sts-ingress, then -1, -2 — in this # repo's idiom (README/style guide section 4, as # homeassistant's routes and keycloak's realm Ingresses do # it). So the NAME DOES NOT SAY WHICH ISSUER it carries: # NOTES.txt prints that mapping, and it is the only place the # association is visible. # REORDERING THIS LIST IS NOT FREE. The index runs over the # issuer groups, which are derived from the list, so moving # one entry can change how many objects exist and which index # holds which issuer — renumbering objects that were not # themselves moved. Kubernetes recreates them, which takes # their Certificate objects along; nothing is RE-ISSUED, # because the Secrets survive (cert-manager only # owner-references a Secret with # --enable-certificate-owner-ref, which this cluster does not # set). That last part is a property of the CLUSTER and not # of this chart: with that flag on, a reorder costs a fresh # ACME request per renumbered object. # 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., 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./.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.; # 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.. # tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose: # the plausible default (this release's own # tls---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 # 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: []