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> |
||
|---|---|---|
| .. | ||
| CERTIFICATE-RENEWAL.md | ||
| CONFIGMAP-HARDENING-TEST-2026-08-01.md | ||
| ISSUE-2026-07-13-smtp-imap-external-access.md | ||
| manual_config_steps.md | ||
| README.md | ||
| SETUP_COMPLETE.md | ||
| STABILITY-CHECK.md | ||
| STABLE-ROUTING-SOLUTION.md | ||
| stalwart-admin-credentials-sealed.yaml | ||
| stalwart-bootstrap-config.yaml | ||
| stalwart-config.yaml | ||
| stalwart-fresh-deployment.yaml | ||
| stalwart-monitoring.yaml | ||
| stalwart-postgres-backup.yaml | ||
| stalwart-postgres-credentials-sealed.yaml | ||
| stalwart-postgres.yaml | ||
| stalwart-s3-backup-sealed.yaml | ||
Stalwart Mail Server v0.16.18
Deployment of Stalwart mail server on a PostgreSQL backend (RocksDB was retired in DEV-467/DEV-473; teardown completed in DEV-474).
Architecture
- Version: v0.16.18
- Authentication: OIDC via Pocket-ID (backed by Postgres accounts store)
- Configuration + state: PostgreSQL (
stalwart-postgresStatefulSet in-namespace) - Storage: Encrypted hcloud-volumes attached to
stalwart-postgres-0(10Gi). Stalwart itself uses an emptyDir at/var/lib/stalwart— no persistent block volume onstalwart-0 - Backup: Daily Postgres logical dump to S3 via
stalwart-postgres-backupCronJob (restic tagstalwart-postgres) at 02:30 UTC - Web UI: https://mail.basicstack.de
Network Architecture
Stalwart uses a two-tier load balancing setup:
-
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
LoadBalanceris required because the Hetzner Cloud Controller Manager (CCM) automatically provisions and manages the Hetzner Load Balancer when it detects this service type. -
Hetzner Load Balancer: Automatically managed by the Hetzner CCM. The load balancer distributes traffic across:
- k3s-cp-1 (control plane node)
- k3s-worker-1 through k3s-worker-5 (worker nodes)
Traffic Flow
External Client → Hetzner Load Balancer → NodePort → K8s Service → Stalwart Pod
The Hetzner CCM watches for LoadBalancer-type services and automatically:
- Creates/updates the Hetzner LB configuration
- Configures health checks
- Maps service ports to node ports
- Manages target nodes
Important: Changing the service type from LoadBalancer to NodePort would break the automatic Hetzner LB management. The current setup is the correct configuration for our infrastructure.
Files
stalwart-fresh-deployment.yaml- Main deployment manifest (StatefulSet, services, ingress)stalwart-postgres.yaml- Postgres backend StatefulSet + service (state store)stalwart-postgres-backup.yaml- Dailypg_dump→ restic CronJob (tagstalwart-postgres)stalwart-config.yaml- Stalwart configmap (stalwart.toml)stalwart-bootstrap-config.yaml- Bootstrap configmap pointing Stalwart at Postgresstalwart-admin-credentials-sealed.yaml- Sealed secret for admin passwordstalwart-postgres-credentials-sealed.yaml- Sealed secret for Postgres rolestalwart-s3-backup-sealed.yaml- Sealed secret for S3 backup credentials
Deployment
# Apply sealed secrets first
kubectl apply -f stalwart-admin-credentials-sealed.yaml
kubectl apply -f stalwart-postgres-credentials-sealed.yaml
kubectl apply -f stalwart-s3-backup-sealed.yaml
# Configmaps: bootstrap points Stalwart at Postgres; stalwart-config carries stalwart.toml
kubectl apply -f stalwart-bootstrap-config.yaml
kubectl apply -f stalwart-config.yaml
# Postgres backend + backup
kubectl apply -f stalwart-postgres.yaml
kubectl apply -f stalwart-postgres-backup.yaml
# Deploy Stalwart
kubectl apply -f stalwart-fresh-deployment.yaml
Initial Admin Login
After deployment, log in at https://mail.basicstack.de with:
- Username:
admin - Password: (from stalwart-admin-credentials secret)
Configuration
IMPORTANT: Runtime configuration and account/queue state now live in PostgreSQL (
stalwart-postgres-0), not RocksDB.
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. The API access via /jmap seems to be too complex for the agent and the configruation has been done manually.
Refer to manual configuration steps
Network Architecture
Load Balancing Setup
Stalwart uses a two-tier load balancing architecture:
-
Hetzner Cloud Load Balancer (External Layer)
- Provides the public-facing IP for mail services
- Configured with k3s-cp-1 and all worker nodes as targets
- Forwards traffic to Kubernetes NodePorts on the cluster nodes
-
Kubernetes LoadBalancer Services (Internal Layer)
- Service type: LoadBalancer (managed by k3s ServiceLB)
- Automatically configured by Hetzner CCM (Cloud Controller Manager)
- Creates NodePorts that the Hetzner LB targets
Traffic Flow
External Mail Client
↓
Hetzner Load Balancer (public IP)
↓
k3s Node NodePort (automatically assigned)
↓
Kubernetes LoadBalancer Service (stalwart-smtp / stalwart-imap)
↓
Stalwart Pod
Why LoadBalancer Service Type is Required
The Kubernetes services MUST remain type LoadBalancer because:
- The Hetzner CCM automatically manages the Hetzner Load Balancer configuration
- When a service is type LoadBalancer, the CCM creates/updates the Hetzner LB targets
- Changing to NodePort would break this automatic management
- Manual Hetzner LB configuration would be required and error-prone
Services
stalwart-smtp(LoadBalancer): Ports 25, 587stalwart-imap(LoadBalancer): Port 993stalwart-http(ClusterIP): Port 8080 (web UI via Traefik Ingress)
Ports
- SMTP: 25, 587
- IMAPS: 993 (secure only, port 143 disabled per DEV-359)
- HTTP: 8080 (web UI)
Storage
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 (blob + metadata)
- User accounts
- Server 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
Refer to Automatic Renewal TLS Certificate