--- 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: # Copy ConfigMap files to emptyDir to avoid subPath mounting race - name: copy-config image: busybox:latest command: - sh - -c - | set -e echo "Copying bootstrap config..." cp /tmp/bootstrap-config/* /etc/stalwart/ echo "Copying stalwart.toml..." cp /tmp/stalwart-config/stalwart.toml /etc/stalwart/stalwart.toml echo "Config files copied successfully:" ls -la /etc/stalwart/ volumeMounts: - name: bootstrap-config-source mountPath: /tmp/bootstrap-config readOnly: true - name: stalwart-config-source mountPath: /tmp/stalwart-config readOnly: true - name: config mountPath: /etc/stalwart - 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" # DEV-476: bootstrap config.json resolves authSecret via this env var. - name: PGPASSWORD valueFrom: secretKeyRef: name: stalwart-postgres-credentials key: POSTGRES_PASSWORD volumeMounts: - name: data mountPath: /var/lib/stalwart - name: config mountPath: /etc/stalwart - 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 # emptyDir for config files (populated by copy-config init-container) - name: config emptyDir: {} # ConfigMap sources for init-container (no longer mounted directly via subPath) - name: bootstrap-config-source configMap: name: stalwart-bootstrap-config - name: stalwart-config-source 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: 1800 # 30 minute timeout (was 600s; retries + affinity settling can push past 10 min) template: metadata: labels: app: stalwart-backup spec: serviceAccountName: stalwart-backup restartPolicy: OnFailure # Co-locate with stalwart-0 so both pods share the same hcloud block volume # attachment (RWO). Without this the backup pod can be scheduled on a # different node and hits FailedAttachVolume/Multi-Attach (DEV-468). affinity: podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: stalwart statefulset.kubernetes.io/pod-name: stalwart-0 topologyKey: kubernetes.io/hostname containers: - name: backup image: alpine:3.24 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