stack.basicstack.de/apps/stalwart/stalwart-fresh-deployment.yaml
CTO Agent 693fcd394c Fix Stalwart accessibility by adding stalwart.toml ConfigMap
Issue: Stalwart pod was crashing with "expected value at line 1 column 1"
when trying to parse /etc/stalwart/stalwart.toml because the file was not
being mounted.

Root cause: The StatefulSet was only mounting bootstrap-config (config.json)
but Stalwart v0.16.11 expects stalwart.toml for runtime configuration.

Changes:
- Add stalwart-config.yaml ConfigMap with complete stalwart.toml
- Update StatefulSet to mount stalwart-config at /etc/stalwart/stalwart.toml
- Keep bootstrap-config mount for backward compatibility

This resolves DEV-422 (Stalwart not accessible).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-01 08:55:07 +00:00

393 lines
10 KiB
YAML

---
apiVersion: v1
kind: Namespace
metadata:
name: stalwart
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: stalwart-data
namespace: stalwart
spec:
accessModes:
- ReadWriteOnce
storageClassName: hcloud-volumes-encrypted
resources:
requests:
storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
name: stalwart-smtp
namespace: stalwart
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
selector:
app: stalwart
ports:
- name: smtp
port: 25
targetPort: 25
protocol: TCP
- name: submission
port: 587
targetPort: 587
protocol: TCP
# Port 465 (SMTPS) removed per DEV-359 approval - enforce secure protocols
---
apiVersion: v1
kind: Service
metadata:
name: stalwart-imap
namespace: stalwart
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
selector:
app: stalwart
ports:
# Port 143 (IMAP) removed per DEV-359 approval - enforce secure protocols
- name: imaps
port: 993
targetPort: 993
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: stalwart-http
namespace: stalwart
spec:
type: ClusterIP
selector:
app: stalwart
ports:
- name: http
port: 8080
targetPort: 8080
protocol: TCP
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: stalwart
namespace: stalwart
annotations:
# Automatically restart this StatefulSet when the TLS certificate secret is updated
# This ensures the pod reloads new certificates after cert-manager renews them
secret.reloader.stakater.com/reload: "stalwart-tls"
spec:
serviceName: stalwart-http
replicas: 1
selector:
matchLabels:
app: stalwart
template:
metadata:
labels:
app: stalwart
spec:
initContainers:
- name: fix-permissions
image: busybox:latest
command: ["sh", "-c", "chown -R 2000:2000 /var/lib/stalwart && chmod -R 755 /var/lib/stalwart"]
volumeMounts:
- name: data
mountPath: /var/lib/stalwart
containers:
- name: stalwart
image: stalwartlabs/stalwart:v0.16.11
ports:
- containerPort: 25
name: smtp
- containerPort: 587
name: submission
# Port 465 (SMTPS) removed - enforce STARTTLS on 587
# Port 143 (IMAP) removed - enforce TLS on 993
- containerPort: 993
name: imaps
- containerPort: 8080
name: http
env:
#- name: STALWART_RECOVERY_ADMIN
# valueFrom:
# secretKeyRef:
# name: stalwart-admin-credentials
# key: admin-password
# optional: false
- name: TLS_CERTIFICATE
value: "/etc/stalwart/certs/tls.crt"
- name: TLS_PRIVATE_KEY
value: "/etc/stalwart/certs/tls.key"
volumeMounts:
- name: data
mountPath: /var/lib/stalwart
- name: bootstrap-config
mountPath: /etc/stalwart/config.json
subPath: config.json
- name: stalwart-config
mountPath: /etc/stalwart/stalwart.toml
subPath: stalwart.toml
- name: tls-certs
mountPath: /etc/stalwart/certs
readOnly: true
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
startupProbe:
exec:
command:
- curl
- -f
- --max-time
- "3"
- http://localhost:8080/healthz/live
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
exec:
command:
- curl
- -f
- --max-time
- "3"
- http://localhost:8080/healthz/live
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- curl
- -f
- --max-time
- "3"
- http://localhost:8080/healthz/ready
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: stalwart-data
- name: bootstrap-config
configMap:
name: stalwart-bootstrap-config
- name: stalwart-config
configMap:
name: stalwart-config
- name: tls-certs
secret:
secretName: stalwart-tls
defaultMode: 0444
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: stalwart-web
namespace: stalwart
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
tls:
- hosts:
- mail.basicstack.de
secretName: stalwart-tls
rules:
- host: mail.basicstack.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: stalwart-http
port:
number: 8080
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: stalwart-backup
namespace: stalwart
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: stalwart-backup
namespace: stalwart
rules:
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["get", "list", "patch"]
- apiGroups: ["apps"]
resources: ["statefulsets/scale"]
verbs: ["get", "update", "patch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: stalwart-backup
namespace: stalwart
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: stalwart-backup
subjects:
- kind: ServiceAccount
name: stalwart-backup
namespace: stalwart
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: stalwart-backup
namespace: stalwart
spec:
schedule: "0 3 * * *" # 3 AM daily
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 600 # 10 minute timeout
template:
metadata:
labels:
app: stalwart-backup
spec:
serviceAccountName: stalwart-backup
restartPolicy: OnFailure
containers:
- name: backup
image: alpine:3.19
command:
- /bin/sh
- -c
- |
set -e
BACKUP_START=$(date +%s)
echo "=== Starting Stalwart backup at $(date) ==="
# Install required tools
echo "Installing kubectl and restic..."
apk add --no-cache kubectl restic curl bash
# Build restic repository URL from env vars
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/stalwart"
echo "Using restic repository: $RESTIC_REPOSITORY"
# Function to scale StatefulSet
scale_statefulset() {
local replicas=$1
echo "Scaling stalwart StatefulSet to $replicas replicas..."
kubectl scale statefulset stalwart -n stalwart --replicas=$replicas
if [ "$replicas" -eq 0 ]; then
echo "Waiting for pod to terminate..."
kubectl wait --for=delete pod/stalwart-0 -n stalwart --timeout=120s || true
sleep 5
else
echo "Waiting for pod to be ready..."
kubectl wait --for=condition=ready pod/stalwart-0 -n stalwart --timeout=120s || echo "Warning: Pod not ready after 120s"
fi
}
# Trap to ensure we scale back up on any exit
cleanup() {
EXIT_CODE=$?
echo "Cleanup: scaling stalwart back to 1 replica..."
scale_statefulset 1 || echo "ERROR: Failed to scale back up!"
BACKUP_END=$(date +%s)
DURATION=$((BACKUP_END - BACKUP_START))
if [ $EXIT_CODE -eq 0 ]; then
echo "=== Backup completed successfully in ${DURATION}s at $(date) ==="
else
echo "=== Backup FAILED after ${DURATION}s at $(date) ==="
fi
exit $EXIT_CODE
}
trap cleanup EXIT INT TERM
# Scale down Stalwart
scale_statefulset 0
# Build restic repository URL from env vars (K8s doesn't expand $(VAR) in value fields)
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/stalwart"
echo "Using repository: $RESTIC_REPOSITORY"
# Initialize restic repo if needed
echo "Initializing restic repository..."
restic snapshots || restic init
# Backup the data directory
echo "Running backup..."
restic backup /var/lib/stalwart \
--tag stalwart \
--tag daily \
--host stalwart-k8s \
--verbose
# Prune old backups (keep 7 daily, 4 weekly, 6 monthly)
echo "Pruning old backups..."
restic forget \
--tag stalwart \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6 \
--prune
echo "Backup operations completed"
env:
- name: RESTIC_PASSWORD
valueFrom:
secretKeyRef:
name: stalwart-s3-backup
key: restic-password
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: stalwart-s3-backup
key: access-key
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: stalwart-s3-backup
key: secret-key
- name: S3_ENDPOINT
valueFrom:
secretKeyRef:
name: stalwart-s3-backup
key: endpoint
- name: S3_BUCKET
valueFrom:
secretKeyRef:
name: stalwart-s3-backup
key: bucket
volumeMounts:
- name: data
mountPath: /var/lib/stalwart
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: stalwart-data