# 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.37.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] 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] 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, # "-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 # 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: []