Run Stalwart as a 3-node StatefulSet coordinated through Valkey
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful

This commit is contained in:
2026-10-12 21:48:56 +11:00
parent 2b992f771c
commit eb7dbca947
8 changed files with 88 additions and 13 deletions
+3 -1
View File
@@ -12,8 +12,10 @@ resources:
- stalwart-apply-rbac.yaml
- stalwart-blobs-bucket.yaml
- stalwart-configmap.yaml
- stalwart-deployment.yaml
- stalwart-pdb.yaml
- stalwart-service.yaml
- stalwart-statefulset.yaml
- valkey.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
+4 -3
View File
@@ -1,7 +1,8 @@
---
# Converges Stalwart's database-held settings to plan.ndjson after every sync,
# then restarts the pod: listener and store changes only bind on startup.
# ponytail: restarts on every sync since neither image has a shell to diff apply output.
# then rolls the StatefulSet: listener, store and coordinator changes only bind
# on startup. It rolls on every sync because neither image has a shell to diff the
# apply output.
apiVersion: batch/v1
kind: Job
metadata:
@@ -68,7 +69,7 @@ spec:
args:
- rollout
- restart
- deployment/stalwart
- statefulset/stalwart
- --namespace=mail
securityContext:
runAsUser: 65532
+2 -2
View File
@@ -5,7 +5,7 @@ metadata:
name: stalwart-apply
namespace: mail
---
# Lets the stalwart-apply Job roll the Stalwart pod after applying the plan.
# Lets the stalwart-apply Job roll the Stalwart pods after applying the plan.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
@@ -15,7 +15,7 @@ rules:
- apiGroups:
- apps
resources:
- deployments
- statefulsets
resourceNames:
- stalwart
verbs:
+4 -1
View File
@@ -1,6 +1,8 @@
---
# config.json only names the datastore; everything else lives in Postgres and
# is converged from plan.ndjson by the stalwart-apply PostSync Job.
# The Coordinator uses the plain Redis client: its cluster client never
# resubscribes once the Valkey node it subscribed on is replaced.
apiVersion: v1
kind: ConfigMap
metadata:
@@ -19,7 +21,8 @@ data:
{"@type":"reconcile","object":"Tracer","matchOn":"*","value":{"stdout":{"@type":"Stdout","enable":true,"level":"info","ansi":false,"multiline":false,"buffered":false,"lossy":false,"eventsPolicy":"exclude","events":{}}}}
{"@type":"update","object":"BlobStore","value":{"@type":"S3","bucket":"stalwart-blobs","region":{"@type":"Custom","customEndpoint":"https://s3.ceph.unkin.net","customRegion":"us-east-1"},"accessKey":{"@type":"EnvironmentVariable","variableName":"AWS_ACCESS_KEY_ID"},"secretKey":{"@type":"EnvironmentVariable","variableName":"AWS_SECRET_ACCESS_KEY"},"securityToken":{"@type":"None"},"sessionToken":{"@type":"None"}}}
{"@type":"update","object":"SearchStore","value":{"@type":"Default"}}
{"@type":"update","object":"InMemoryStore","value":{"@type":"Default"}}
{"@type":"update","object":"InMemoryStore","value":{"@type":"RedisCluster","urls":{"redis://valkey-mail-valkey.mail.svc:6379":true},"authUsername":null,"authSecret":{"@type":"None"},"readFromReplicas":false,"protocolVersion":"resp3"}}
{"@type":"update","object":"Coordinator","value":{"@type":"Redis","url":"redis://valkey-mail-valkey.mail.svc:6379"}}
{"@type":"update","object":"Authentication","value":{"directoryId":"#authentik"}}
{"@type":"update","object":"MtaOutboundStrategy","value":{"route":{"match":{"0":{"if":"is_local_domain(rcpt_domain)","then":"'local'"}},"else":"'smarthost'"}}}
{"@type":"update","object":"MtaStageAuth","value":{"require":{"match":{},"else":"local_port != 25 && local_port != 24"}}}
+11
View File
@@ -0,0 +1,11 @@
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: stalwart
namespace: mail
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: stalwart
+16 -1
View File
@@ -16,6 +16,21 @@ spec:
port: 24
targetPort: lmtp
---
# Governing Service for the StatefulSet's per-pod DNS.
apiVersion: v1
kind: Service
metadata:
name: stalwart-headless
namespace: mail
spec:
clusterIP: None
selector:
app.kubernetes.io/name: stalwart
ports:
- name: http
port: 8080
targetPort: http
---
# Client-facing mail ports on the lab network. 443 is here too so the single
# internal mail.unkin.net record serves web as well as IMAP/submission.
apiVersion: v1
@@ -24,7 +39,7 @@ metadata:
name: stalwart-lb
namespace: mail
annotations:
purelb.io/addresses: 198.18.200.11
purelb.io/addresses: 198.18.200.18
purelb.io/service-group: common
spec:
type: LoadBalancer
@@ -1,16 +1,19 @@
---
apiVersion: apps/v1
kind: Deployment
kind: StatefulSet
metadata:
name: stalwart
namespace: mail
annotations:
secret.reloader.stakater.com/reload: "stalwart-admin,stalwart-db-app,stalwart-blobs-s3,wildcard-unkin-net-tls,vault-ca-cert,stalwart-db-ca"
spec:
# ponytail: single replica, no cluster coordinator; add one before scaling out.
replicas: 1
strategy:
type: Recreate
# Nodes coordinate through mail-valkey; each leases its cluster node id by
# hostname, so stable pod names keep a stable id across restarts.
replicas: 3
serviceName: stalwart-headless
podManagementPolicy: Parallel
updateStrategy:
type: RollingUpdate
selector:
matchLabels:
app.kubernetes.io/name: stalwart
@@ -26,6 +29,13 @@ spec:
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: stalwart
# rustls loads SSL_CERT_FILE, so public roots, the internal CA (RGW) and the CNPG CA go in one bundle.
initContainers:
- name: combine-certs
@@ -61,6 +71,10 @@ spec:
image: docker.io/stalwartlabs/stalwart:v0.16.25
imagePullPolicy: IfNotPresent
env:
- name: STALWART_HOSTNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: SSL_CERT_FILE
value: /etc/ssl/combined/ca-certificates.crt
- name: DB_PASSWORD
+29
View File
@@ -0,0 +1,29 @@
---
# Stalwart's in-memory store (locks, rate limits, queue leases) and cluster
# coordinator pub/sub. Ephemeral state, so no persistence; the operator runs it
# cluster-mode, passwordless and without TLS behind valkey-mail-valkey.
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata:
name: mail-valkey
namespace: mail
spec:
shards: 1
replicas: 2
image: docker.io/valkey/valkey:9.0.0
exporter:
enabled: false
scheduling:
node:
spread:
shard:
mode: Required
podDisruptionBudget:
mode: Cluster
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi