chore(stalwart): retire RocksDB PVC + backup — Postgres backend is authoritative (DEV-474)

Post-cutover 14-day observation window on DEV-474 closed today with 14/14
healthy days. This removes every RocksDB-era artefact from the manifest and
updates the README to reflect the PostgreSQL backend as the default:

- Delete PVC `stalwart-data` (was 20Gi hcloud-volumes-encrypted, RWO).
- Drop the `fix-permissions` init-container (existed only to chown the
  RocksDB PVC mount).
- Replace the StatefulSet's `data` volume with an `emptyDir` and keep the
  `/var/lib/stalwart` mount as a plain scratch path (Stalwart's state is
  now in the `stalwart-postgres` StatefulSet).
- Delete the `stalwart-backup` CronJob and its `stalwart-backup`
  ServiceAccount / Role / RoleBinding — RocksDB snapshotting is obsolete
  now that state lives in Postgres. Daily backup is via
  `stalwart-postgres-backup` (restic tag `stalwart-postgres`).
- README: update version, backend, storage, backup, files, and deployment
  sections; correct exposed-ports language to match SMTP 25/587 + IMAPS 993.

Executed alongside the merge:

- `restic forget --prune` removed all 11 snapshots on tag=stalwart (Job
  `stalwart-restic-forget-rocksdb` on 2026-08-28 15:09 UTC, log preserved
  on DEV-474). 16.165 MiB pruned. `restic snapshots --tag stalwart` now
  returns empty; only the 14 tag=`stalwart-postgres` snapshots remain.
- `stalwart-backup` CronJob was pre-suspended in-cluster to prevent
  tonight's 03:00 UTC fire from re-creating a stalwart-tagged snapshot
  before this PR merges.

Rollback (if needed before merge): revert this commit; the old CronJob
would then re-create RocksDB snapshots from live PVC contents. After
merge the rollback path is the Postgres `pg_dump` restic chain only.

Refs: DEV-467, DEV-473 (cutover), DEV-474 (this observation + teardown),
DEV-548 (day-14 execution issue).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO Agent 2026-08-28 15:12:25 +00:00
parent c97a2d218e
commit 0dfd6ada6e
2 changed files with 37 additions and 224 deletions

View file

@ -1,21 +1,21 @@
# Stalwart Mail Server v0.16.11 # Stalwart Mail Server v0.16.18
Clean deployment of Stalwart mail server with username/password authentication only. Deployment of Stalwart mail server on a PostgreSQL backend (RocksDB was retired in DEV-467/DEV-473; teardown completed in DEV-474).
## Architecture ## Architecture
- **Version**: v0.16.11 - **Version**: v0.16.18
- **Authentication**: Username/password only (NO OAuth/OIDC) - **Authentication**: OIDC via Pocket-ID (backed by Postgres accounts store)
- **Configuration**: API-based (stored in RocksDB) - **Configuration + state**: PostgreSQL (`stalwart-postgres` StatefulSet in-namespace)
- **Storage**: Encrypted hcloud-volumes (20Gi) - **Storage**: Encrypted hcloud-volumes attached to `stalwart-postgres-0` (10Gi). Stalwart itself uses an emptyDir at `/var/lib/stalwart` — no persistent block volume on `stalwart-0`
- **Backup**: Daily restic backup to S3 at 3 AM - **Backup**: Daily Postgres logical dump to S3 via `stalwart-postgres-backup` CronJob (restic tag `stalwart-postgres`) at 02:30 UTC
- **Web UI**: https://mail.basicstack.de - **Web UI**: https://mail.basicstack.de
## Network Architecture ## Network Architecture
Stalwart uses a two-tier load balancing setup: Stalwart uses a two-tier load balancing setup:
1. **Kubernetes LoadBalancer Services**: Four separate LoadBalancer services expose SMTP (25, 587, 465) and IMAP (143, 993) ports. The service type `LoadBalancer` is required because the Hetzner Cloud Controller Manager (CCM) automatically provisions and manages the Hetzner Load Balancer when it detects this service type. 1. **Kubernetes LoadBalancer Services**: Two LoadBalancer services expose SMTP (25, 587) and IMAPS (993) ports (SMTPS/465 removed per DEV-359 approval, plaintext IMAP/143 disabled). The service type `LoadBalancer` is required because the Hetzner Cloud Controller Manager (CCM) automatically provisions and manages the Hetzner Load Balancer when it detects this service type.
2. **Hetzner Load Balancer**: Automatically managed by the Hetzner CCM. The load balancer distributes traffic across: 2. **Hetzner Load Balancer**: Automatically managed by the Hetzner CCM. The load balancer distributes traffic across:
- k3s-cp-1 (control plane node) - k3s-cp-1 (control plane node)
@ -37,8 +37,13 @@ The Hetzner CCM watches for LoadBalancer-type services and automatically:
## Files ## Files
- `stalwart-fresh-deployment.yaml` - Main deployment manifest - `stalwart-fresh-deployment.yaml` - Main deployment manifest (StatefulSet, services, ingress)
- `stalwart-postgres.yaml` - Postgres backend StatefulSet + service (state store)
- `stalwart-postgres-backup.yaml` - Daily `pg_dump` → restic CronJob (tag `stalwart-postgres`)
- `stalwart-config.yaml` - Stalwart configmap (`stalwart.toml`)
- `stalwart-bootstrap-config.yaml` - Bootstrap configmap pointing Stalwart at Postgres
- `stalwart-admin-credentials-sealed.yaml` - Sealed secret for admin password - `stalwart-admin-credentials-sealed.yaml` - Sealed secret for admin password
- `stalwart-postgres-credentials-sealed.yaml` - Sealed secret for Postgres role
- `stalwart-s3-backup-sealed.yaml` - Sealed secret for S3 backup credentials - `stalwart-s3-backup-sealed.yaml` - Sealed secret for S3 backup credentials
## Deployment ## Deployment
@ -46,12 +51,16 @@ The Hetzner CCM watches for LoadBalancer-type services and automatically:
```bash ```bash
# Apply sealed secrets first # Apply sealed secrets first
kubectl apply -f stalwart-admin-credentials-sealed.yaml kubectl apply -f stalwart-admin-credentials-sealed.yaml
kubectl apply -f stalwart-postgres-credentials-sealed.yaml
kubectl apply -f stalwart-s3-backup-sealed.yaml kubectl apply -f stalwart-s3-backup-sealed.yaml
# Create bootstrap config # Configmaps: bootstrap points Stalwart at Postgres; stalwart-config carries stalwart.toml
kubectl create configmap stalwart-bootstrap-config \ kubectl apply -f stalwart-bootstrap-config.yaml
--from-literal=config.json='{"@type":"RocksDb","path":"/var/lib/stalwart"}' \ kubectl apply -f stalwart-config.yaml
-n stalwart
# Postgres backend + backup
kubectl apply -f stalwart-postgres.yaml
kubectl apply -f stalwart-postgres-backup.yaml
# Deploy Stalwart # Deploy Stalwart
kubectl apply -f stalwart-fresh-deployment.yaml kubectl apply -f stalwart-fresh-deployment.yaml
@ -65,9 +74,11 @@ After deployment, log in at https://mail.basicstack.de with:
## Configuration ## Configuration
> **IMPORTANT:** The configuration is stored in the RocksDB and cannot be overwritten by a configuration file! > **IMPORTANT:** Runtime configuration and account/queue state now live in **PostgreSQL** (`stalwart-postgres-0`), not RocksDB.
All configuration is done via the web UI or API. The bootstrap config only points to the RocksDB database location. NO config.toml files are used. The `stalwart-bootstrap-config` configmap points Stalwart at the Postgres store; runtime edits made in the web UI or API are persisted to Postgres. The `stalwart-config` configmap carries the boot-time `stalwart.toml`.
The historical RocksDB backend (PVC `stalwart-data`, restic tag `stalwart`, `stalwart-backup` CronJob, and the `fix-permissions` init-container) was retired on 2026-08-28 after 14 healthy post-cutover days (DEV-474). No RocksDB restic chain remains; rollback is now via the Postgres `pg_dump` restic chain (`tag=stalwart-postgres`).
Check https://stalw.art/docs/ref/ for configuration possibilities. Check https://stalw.art/docs/ref/ for configuration possibilities.
The API access via /jmap seems to be too complex for the agent and the configruation has been done manually. The API access via /jmap seems to be too complex for the agent and the configruation has been done manually.
@ -126,11 +137,13 @@ The Kubernetes services MUST remain type `LoadBalancer` because:
## Storage ## Storage
Data is stored in `/var/lib/stalwart` using the RocksDB database format. This includes: All persistent state lives in **PostgreSQL** on `stalwart-postgres-0` (PVC `postgres-data-stalwart-postgres-0`, 10Gi, `hcloud-volumes-encrypted`). Stalwart connects to Postgres via the bootstrap config and stores:
- Email messages - Email messages (blob + metadata)
- User accounts - User accounts
- Server configuration - Server configuration
- TLS certificates configuration - Queue / spool state
`/var/lib/stalwart` on `stalwart-0` is an emptyDir mount kept only as a writable path if Stalwart needs one at runtime; no data of value lives there.
## Certificate Renewal ## Certificate Renewal

View file

@ -5,19 +5,6 @@ metadata:
name: stalwart name: stalwart
--- ---
apiVersion: v1 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 kind: Service
metadata: metadata:
name: stalwart-smtp name: stalwart-smtp
@ -114,12 +101,6 @@ spec:
readOnly: true readOnly: true
- name: config - name: config
mountPath: /etc/stalwart 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: containers:
- name: stalwart - name: stalwart
image: stalwartlabs/stalwart:v0.16.18 image: stalwartlabs/stalwart:v0.16.18
@ -159,6 +140,9 @@ spec:
- name: tls-certs - name: tls-certs
mountPath: /etc/stalwart/certs mountPath: /etc/stalwart/certs
readOnly: true readOnly: true
# DEV-474: /var/lib/stalwart is no longer RocksDB storage (state now lives in
# the stalwart-postgres StatefulSet). Kept as emptyDir so stalwart still has
# a writable path if it needs one at runtime.
resources: resources:
requests: requests:
memory: "512Mi" memory: "512Mi"
@ -203,9 +187,10 @@ spec:
timeoutSeconds: 3 timeoutSeconds: 3
failureThreshold: 3 failureThreshold: 3
volumes: volumes:
# DEV-474: was persistentVolumeClaim.claimName=stalwart-data (RocksDB).
# Postgres-backed now; replaced with emptyDir.
- name: data - name: data
persistentVolumeClaim: emptyDir: {}
claimName: stalwart-data
# emptyDir for config files (populated by copy-config init-container) # emptyDir for config files (populated by copy-config init-container)
- name: config - name: config
emptyDir: {} emptyDir: {}
@ -246,188 +231,3 @@ spec:
name: stalwart-http name: stalwart-http
port: port:
number: 8080 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