stack.basicstack.de/apps/stalwart
CTO Agent 0dfd6ada6e 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>
2026-08-28 15:12:25 +00:00
..
CERTIFICATE-RENEWAL.md Implement automatic TLS certificate renewal for Stalwart 2026-07-04 16:48:48 +00:00
CONFIGMAP-HARDENING-TEST-2026-08-01.md Document ConfigMap hardening test results (DEV-431) 2026-08-01 10:17:04 +00:00
ISSUE-2026-07-13-smtp-imap-external-access.md docs(stalwart): Clarify LoadBalancer was working all along via hostPort 2026-07-13 20:12:11 +00:00
manual_config_steps.md apps/stalwart/manual_config_steps.md aktualisiert 2026-07-04 16:39:11 +00:00
README.md chore(stalwart): retire RocksDB PVC + backup — Postgres backend is authoritative (DEV-474) 2026-08-28 15:12:25 +00:00
SETUP_COMPLETE.md Move Application manifests to apps root for proper self-management 2026-07-12 09:27:28 +00:00
STABILITY-CHECK.md Update Stalwart stability check: SMTP transient issue during LoadBalancer transition 2026-08-09 16:51:30 +00:00
STABLE-ROUTING-SOLUTION.md Document stable routing solution for Stalwart Mail 2026-07-22 18:07:38 +00:00
stalwart-admin-credentials-sealed.yaml Fix Stalwart ArgoCD degraded status by ignoring unsealed secrets 2026-07-25 15:15:42 +00:00
stalwart-bootstrap-config.yaml feat(stalwart): switch config/data store from RocksDB to PostgreSQL (DEV-476) 2026-08-15 14:46:29 +00:00
stalwart-config.yaml feat(stalwart): switch config/data store from RocksDB to PostgreSQL (DEV-476) 2026-08-15 14:46:29 +00:00
stalwart-fresh-deployment.yaml chore(stalwart): retire RocksDB PVC + backup — Postgres backend is authoritative (DEV-474) 2026-08-28 15:12:25 +00:00
stalwart-monitoring.yaml Stalwart reliability hardening: fix k3s service networking issues 2026-07-11 11:06:36 +00:00
stalwart-postgres-backup.yaml feat(stalwart): deploy stalwart-postgres StatefulSet + backup (DEV-469) 2026-08-15 12:05:25 +00:00
stalwart-postgres-credentials-sealed.yaml feat(stalwart): deploy stalwart-postgres StatefulSet + backup (DEV-469) 2026-08-15 12:05:25 +00:00
stalwart-postgres.yaml feat(stalwart): deploy stalwart-postgres StatefulSet + backup (DEV-469) 2026-08-15 12:05:25 +00:00
stalwart-s3-backup-sealed.yaml Fix Stalwart ArgoCD degraded status by ignoring unsealed secrets 2026-07-25 15:15:42 +00:00

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-postgres StatefulSet 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 on stalwart-0
  • 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

Network Architecture

Stalwart uses a two-tier load balancing setup:

  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:

    • 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 - 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-postgres-credentials-sealed.yaml - Sealed secret for Postgres role
  • stalwart-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:

  1. 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
  2. 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, 587
  • stalwart-imap (LoadBalancer): Port 993
  • stalwart-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