Deploys Stalwart with optional Bulwark webmail. Defaults to one Stalwart replica, external PostgreSQL and S3, and no persistent volume. Database, bucket, Secrets, DNS, and public routing must be supplied separately.
This is an initial 0.1 release. Template, schema, policy, and container startup checks have been performed; a complete Kubernetes deployment, mail delivery, and OAuth login remain unverified. See operating notes before production use.
The Common dependency is vendored in charts/; a fresh checkout needs no dependency download. Copy examples/postgresql-s3.yaml to a private values file and set your database host, bucket, and Secret references. PostgreSQL TLS and certificate validation are enabled by default.
Create the namespace and these Secrets there before installing:
| Example Secret | Required keys |
|---|---|
stalwart-postgresql |
password: database password |
stalwart-s3 |
accessKey, secretKey: bucket credentials |
stalwart-admin |
password: provisioning administrator password; STALWART_RECOVERY_ADMIN: admin:<same password> for initial setup |
The example references stalwart-admin both for server environment variables and for CLI provisioning. A bare install without configured storage and credentials fails validation.
helm upgrade --install stalwart . --namespace stalwart \
-f my-values.yaml --set recoveryMode.enabled=true --timeout 15m
kubectl --namespace stalwart port-forward svc/stalwart-management 8080:8080Open http://localhost:8080/admin with the recovery administrator credential. Configure domains, permanent accounts, listeners, certificates, and any search backend. Recovery mode suspends mail services. Once setup is complete:
helm upgrade stalwart . --namespace stalwart \
-f my-values.yaml --set recoveryMode.enabled=false --timeout 15mReplace the provisioning credential with a permanent administrator's password, remove STALWART_RECOVERY_ADMIN, and restart server pods to remove it from their environment. Keep provisioning credentials valid for future upgrades.
Server, CLI, and Bulwark images are pinned by digest in values.yaml. Changing a tag alone does not change the image: update the corresponding image.digest, or explicitly clear it to use the tag. This applies to image, provisioning.image, and webmail.image. A nonempty global.imageRegistry overrides their registries; mirrors must contain all enabled images.
Renovate checks the pinned images, Bitnami Common, the CI tools and GitHub Actions weekly and opens PRs that change tag and digest together; for the main image the PR also moves appVersion in Chart.yaml. Image blocks in values.yaml must keep the order registry, repository, tag, digest for Renovate to match them. Install the Renovate GitHub App on the repository to enable it.
Prefer existing Secrets. Plain credential values are stored in Helm release history even when rendered into Kubernetes Secrets.
| Credential | Existing Secret settings |
|---|---|
| Recovery administrator | recoveryAdmin.enabled, .existingSecret, .secretKey (value is username:password) |
| PostgreSQL | dataStore.postgresql.existingSecret, .passwordSecretKey |
| S3 | blobStore.s3.existingSecret, .accessKeySecretKey, .secretKeySecretKey |
| Provisioning administrator | provisioning.existingSecret, .passwordSecretKey (password only; username is separate) |
| Metrics | metrics.auth.existingSecret, .passwordSecretKey |
| NATS | coordinator.nats.existingSecret, .passwordSecretKey |
| Bulwark session/admin/OAuth | webmail.sessionSecret, webmail.adminPassword, webmail.oauth |
| Image pulls | global.imagePullSecrets or each image's pullSecrets |
Top-level existingSecret supplies server environment variables through envFrom. Use it for recovery credentials as in the example, or use recoveryAdmin explicitly. extraEnvVars supports individual secretKeyRef entries; extraEnv contains plain values.
Existing Secret changes require a pod restart for environment variables to refresh. Helm cannot verify Secret contents or credentials at render time.
dataStore.backend selects postgresql or rocksdb. A nonempty config object overrides that selection with raw startup configuration. Local durable stores, including local search storage, need persistence.enabled: true; RocksDB/SQLite cannot run with multiple replicas. With persistence disabled, local writes are lost on pod replacement.
blobStore.backend: s3 applies a BlobStore configuration through a post-install/post-upgrade CLI Job. Use Stalwart region names such as EuCentral1; for an S3-compatible provider, set endpoint and customRegion. The chart exposes permanent access keys, not session tokens or workload identity. Set blobStore.backend: existing to leave an externally configured BlobStore untouched.
The same Job handles optional server settings:
| Values | Action |
|---|---|
metrics.enabled: true, metrics.provision: true |
Configure the Prometheus exporter |
email.provision: true |
Apply encryptAtRest and encryptOnAppend |
oidc.enabled: true |
Declare an OIDC Directory; oidc.activate separately enables it |
Nonempty coordinator.type |
Configure cluster coordination |
The ServiceMonitor and its authentication Secrets always live in the Helm release namespace. Configure Prometheus's serviceMonitorNamespaceSelector to discover that namespace and its serviceMonitorSelector to match the monitor's labels; Prometheus itself can stay in a separate monitoring namespace. The former metrics.serviceMonitor.namespace option has been removed; remove it from existing values files before upgrading.
All provisioning requires an administrator already accepted by Stalwart. The Job uses the internal management Service over HTTP. Successful Jobs are deleted; failed Jobs remain until the next attempt:
kubectl --namespace stalwart logs job/stalwart-provisioningUse a Helm timeout longer than provisioning.activeDeadlineSeconds. Upgrades reapply enabled settings; rollback and uninstall do not undo changes in Stalwart's database. Changing a BlobStore does not migrate mail. Disabling a provisioning option does not revert its previous configuration.
Set service.type: LoadBalancer for external mail traffic. Ingress handles HTTP/HTTPS only; SMTP, IMAP, POP3, and ManageSieve need TCP exposure. Management stays on a separate <fullname>-management ClusterIP Service.
service.ports.<name>.port is the Service port and .containerPort is the application port. Configure matching listeners in Stalwart: declaring Kubernetes ports does not make the application listen. Ingress hosts, TLS, and upstream protocol settings must match your controller and listener configuration.
externalTrafficPolicy: Local is the default for external Services; use Cluster if cross-node forwarding is required. service.ipFamilyPolicy and service.ipFamilies configure mail Service IP families; equivalent fields exist under webmail.service.
Routes attach to existing Gateways; install compatible CRDs and a controller separately.
gatewayAPI:
httpRoute:
enabled: true
parentRefs:
- name: web-gateway
namespace: infrastructure
sectionName: https
hostnames: [mail.example.org]
tcpRoutes:
smtp:
parentRefs:
- name: mail-gateway
namespace: infrastructure
sectionName: smtpHTTPRoute defaults to plain HTTP upstream with client TLS terminated at the Gateway. TCPRoutes require Gateway TCP listeners and preserve the mail protocol's TLS stream. Configure mail certificates in Stalwart. Cross-namespace Gateway listeners must allow routes from the release namespace.
TCPRoute defaults to gateway.networking.k8s.io/v1; set gatewayAPI.tcpRouteApiVersion to the version installed in your cluster. Check route Accepted and ResolvedRefs status after installation. Management is not exposed by these routes.
metrics.enabled: true configures the exporter; metrics.serviceMonitor.enabled: true additionally creates a ServiceMonitor and requires Prometheus Operator CRDs. Set metrics.provision: false when managing exporter configuration outside Helm. Scraping targets the internal management Service at /metrics/prometheus. Configure metrics.auth for Basic authentication; internal reachability alone is not access control.
email.provision: true applies message encryption settings. Encryption still requires each account's OpenPGP key or S/MIME certificate; these settings do not configure S3 bucket encryption. Configure bucket encryption separately.
oidc.enabled declares a Stalwart token-validation Directory. oidc.activate: true switches the active authentication directory; treat this as a server-wide authentication change, not a webmail-only toggle. Matching accounts must already exist. Test administrator access and mail-client authentication before activation. The chart does not provision identity-provider clients or user accounts.
Bulwark is disabled by default. Enable it with a browser-reachable JMAP URL and a shared session secret:
webmail:
enabled: true
jmapServerUrl: https://mail.example.org
sessionSecret:
existingSecret: bulwark-session # key: SESSION_SECRET, at least 32 characters
adminPassword:
existingSecret: bulwark-admin # key: ADMIN_PASSWORD
ingress:
enabled: true
host: webmail.example.orgConfigure TLS on the Ingress for deployment. webmail.gatewayAPI.httpRoute is available instead. Without an explicit JMAP URL, the chart uses the internal mail Service, which is unsuitable for browser access outside the cluster. Serve the published Bulwark image on a dedicated host; its base path is a build-time setting.
adminPassword bootstraps Bulwark's local dashboard, separate from Stalwart accounts. Set it explicitly when using that dashboard. sessionSecret is required for dashboard sessions and other encrypted session features; generate one with openssl rand -base64 32.
For multiple replicas, including webmail.autoscaling.maxReplicas > 1:
- Provide both a pinned admin password and the same session secret on every replica. Inline session secrets must be at least 32 characters; ensure existing Secret contents meet that requirement too.
- Use
ReadWriteManypersistence, or disable persistence and settings sync. With ephemeral storage, dashboard edits and audit data remain local to each replica and disappear on pod replacement. Shared-volume application behavior still needs deployment testing. - Leave
adminConfigReadonly: falseunless you supply a pre-seededadmin.json; read-only configuration prevents password bootstrap.
webmail.oauth controls Bulwark's browser login flow and needs its own identity-provider client ID and secret. It is separate from Stalwart's oidc token validation. The issuer defaults to oidc.issuerUrl when that integration is enabled. OAuth login has not been verified end to end.
webmail.telemetry.enabled: false disables Bulwark telemetry, not all outbound traffic. Additional application settings can be supplied through webmail.extraEnv and webmail.extraEnvVars.
Multiple Stalwart replicas need shared external stores and cluster coordination. The chart rejects local DataStores with multiple replicas but does not enforce coordinator setup, since it may be configured outside Helm.
replicaCount: 3
podAntiAffinityPreset: soft
coordinator:
type: nats
nats:
addresses: [nats.messaging.svc.cluster.local:4222]
existingSecret: stalwart-nats # key: passwordProvision the coordinator separately. Supported chart options are nats, redis, kafka, and zenoh; see values.yaml for their fields. Check backend support and discovery in the chosen Stalwart build. Avoid embedding Redis passwords in URLs stored as configuration.
autoscaling.enabled creates an HPA and omits the workload's static replica count. Configure CPU/memory requests for utilization targets, or supply autoscaling.metrics. role and pushShard apply to every replica in a release; use separate releases for different roles. Bulwark has independent webmail.autoscaling settings.
podAntiAffinityPreset accepts soft or hard; explicit affinity takes precedence. Hard anti-affinity needs enough nodes. podManagementPolicy defaults to OrderedReady; changing it on an existing StatefulSet requires recreation. The chart does not install a PodDisruptionBudget.
Stalwart runs as UID/GID 2000 with a read-only root filesystem, RuntimeDefault seccomp, no privilege escalation, and all capabilities dropped except NET_BIND_SERVICE. Additional writable paths need explicit volumes. ServiceAccount token automounting is disabled; the chart grants no Kubernetes RBAC permissions.
Restrict management access with deployment-specific NetworkPolicies: a ClusterIP Service alone does not isolate callers. Configure external storage access controls, TLS, backups, and restore procedures separately. Check PVC retention before uninstalling or recreating workloads, and keep release names and fullnameOverride stable to preserve resource naming.
Stalwart's requests of 100m CPU and 512Mi memory are starting points. Tune resources, probes, placement, and disruption budgets from measured workloads; startup probes allow up to ten minutes. The chart does not install NetworkPolicies or PodDisruptionBudgets.
Before production use, test a fresh installation and upgrade with your actual infrastructure. Verify provisioning, mail send/receive, TLS, administrator access, and backup restoration. Test OAuth, cross-replica sessions, and failover when enabled; rendering and container health checks do not establish these behaviors.
The provisioning Job has Helm post-install/post-upgrade and Argo CD PostSync hooks. Ensure your controller executes hooks; plain helm template only renders resources. Install Gateway API, ServiceMonitor, and other required CRDs before enabling their resources.
Run local checks with Helm, Python, and Docker:
pip install --require-hashes -r ci/requirements.txt
scripts/render-matrix.sh
scripts/validate-manifests.sh
python3 tests/render.py
yamllint -c .yamllint.yaml Chart.yaml values.yaml ci examples policies .githubCI also checks workflow syntax and the values schema. Optional local hooks are defined in .pre-commit-config.yaml. These checks do not establish runtime correctness or production readiness.
policies/kyverno/ contains optional ValidatingPolicy resources for pod hardening, image digests, and resource requests. They are not installed by the chart. They default to Deny and have no namespace restriction; scope them or choose audit actions before applying to a shared cluster. Use a Kyverno version supporting this policy API.
Common is vendored and included in the packaged chart. Renovate opens PRs that update it together with its archive. For a manual dependency upgrade, update Chart.yaml, run helm dependency update ., and commit Chart.lock and the replacement archive together. Review extracted dependency changes. helm dependency build . restores dependencies from the existing lock file.
Push a vX.Y.Z tag matching Chart.yaml to run CI, package the chart, publish it to oci://ghcr.io/<owner>/charts/stalwart, and sign its digest with cosign. GitHub publishing and signing require a successful hosted run. Consumers can install a published version with their configured values:
helm upgrade --install stalwart oci://ghcr.io/<owner>/charts/stalwart \
--version <X.Y.Z> --namespace stalwart -f my-values.yaml --timeout 15mFor a fresh deployment, follow the recovery-mode setup above. Verify published signatures against the expected repository's release workflow identity and GitHub's OIDC issuer (https://token.actions.githubusercontent.com).
The table below is generated from values.yaml by helm-docs. Edit the # -- comments in values.yaml, or README.md.gotmpl, then run scripts/generate-docs.sh. Do not edit README.md directly; CI fails if it is out of date.
Configuration values and defaults
| Key | Type | Default | Description |
|---|---|---|---|
| additionalAnnotations | object | {} |
|
| additionalLabels | object | {} |
Metadata is merged without changing immutable selector labels. |
| affinity | object | {} |
Explicit affinity always wins over podAntiAffinityPreset below. |
| autoscaling.behavior | object | {} |
|
| autoscaling.enabled | bool | false |
|
| autoscaling.maxReplicas | int | 5 |
|
| autoscaling.metrics | list | [] |
Advanced: raw autoscaling/v2 metric entries, used instead of the two convenience targets above when non-empty. |
| autoscaling.minReplicas | int | 1 |
|
| autoscaling.targetCPUUtilizationPercentage | int | 80 |
|
| autoscaling.targetMemoryUtilizationPercentage | string | "" |
|
| blobStore.backend | string | "s3" |
|
| blobStore.s3.accessKey | string | "" |
Fallback only, stored in a chart-managed Secret and visible in Helm's release history. Prefer existingSecret. Both are required together when existingSecret is not set. |
| blobStore.s3.accessKeySecretKey | string | "accessKey" |
|
| blobStore.s3.allowInvalidCerts | bool | false |
|
| blobStore.s3.bucket | string | "" |
|
| blobStore.s3.customRegion | string | "us-east-1" |
|
| blobStore.s3.endpoint | string | "" |
|
| blobStore.s3.existingSecret | string | "" |
|
| blobStore.s3.keyPrefix | string | "" |
|
| blobStore.s3.region | string | "EuCentral1" |
Stalwart region variant, e.g. EuCentral1. An endpoint selects Custom instead. |
| blobStore.s3.secretKey | string | "" |
|
| blobStore.s3.secretKeySecretKey | string | "secretKey" |
|
| blobStore.s3.verifyAfterWrite | bool | true |
|
| config | object | {} |
Advanced raw DataStore object. A nonempty object overrides dataStore selection. |
| containerSecurityContext.allowPrivilegeEscalation | bool | false |
|
| containerSecurityContext.capabilities.add[0] | string | "NET_BIND_SERVICE" |
|
| containerSecurityContext.capabilities.drop[0] | string | "ALL" |
|
| containerSecurityContext.enabled | bool | true |
|
| containerSecurityContext.privileged | bool | false |
|
| containerSecurityContext.readOnlyRootFilesystem | bool | true |
|
| containerSecurityContext.runAsNonRoot | bool | true |
|
| coordinator.kafka.brokers | list | [] |
|
| coordinator.kafka.groupId | string | "stalwart" |
|
| coordinator.nats.addresses | list | [] |
|
| coordinator.nats.authUsername | string | "stalwart" |
|
| coordinator.nats.existingSecret | string | "" |
|
| coordinator.nats.password | string | "" |
Fallback only, stored in a chart-managed Secret and visible in Helm's release history. Prefer existingSecret. Leave both empty for no auth. |
| coordinator.nats.passwordSecretKey | string | "password" |
|
| coordinator.nats.useTls | bool | false |
|
| coordinator.redis.url | string | "" |
|
| coordinator.type | string | "" |
|
| coordinator.zenoh.config | string | "" |
|
| dataStore.backend | string | "postgresql" |
|
| dataStore.postgresql.allowInvalidCerts | bool | false |
|
| dataStore.postgresql.database | string | "stalwart" |
|
| dataStore.postgresql.existingSecret | string | "" |
Existing Secret containing the database password. Falls back to top-level existingSecret, then to the plain password below. |
| dataStore.postgresql.host | string | "" |
|
| dataStore.postgresql.password | string | "" |
Fallback only, stored in a chart-managed Secret and visible in Helm's release history. Prefer existingSecret. |
| dataStore.postgresql.passwordSecretKey | string | "STALWART_DB_PASSWORD" |
|
| dataStore.postgresql.poolMaxConnections | int | 10 |
|
| dataStore.postgresql.port | int | 5432 |
|
| dataStore.postgresql.useTls | bool | true |
|
| dataStore.postgresql.username | string | "stalwart" |
|
| dataStore.rocksdb.path | string | "/var/lib/stalwart" |
|
| domain | string | "" |
Convenience base domain: when set and ingress.hosts / webmail.ingress.host are left at their defaults, they derive mail. and webmail. respectively. Set either explicitly to override this, or leave domain empty and set both explicitly yourself. |
| email.encryptAtRest | bool | true |
Encrypts messages delivered via SMTP/LMTP before they're written to disk, for any recipient with a registered key. |
| email.encryptOnAppend | bool | false |
Also encrypt client-appended (IMAP/JMAP) messages server-wide, overriding any per-account preference. Off by default so clients keep control, matching Stalwart's own default. |
| email.provision | bool | false |
Off by default: flipping it on reapplies the Email singleton via the same CLI apply Job used for blobStore/metrics, which requires provisioning credentials even if nothing else needs the Job. |
| existingSecret | string | "" |
Existing Secret containing environment variables; also accepts STALWART_RECOVERY_ADMIN. |
| extraEnv | object | {} |
Extra environment variables injected into the container, for store credentials that live in plain text. |
| extraEnvVars | list | [] |
Extra environment variables for the mail container, as full Kubernetes env entries (for example valueFrom.secretKeyRef). |
| extraSecretEnv | object | {} |
Extra environment variables injected from the managed Secret. Use this for anything sensitive (database passwords, S3 keys, etc.). |
| extraVolumeMounts | list | [] |
Mounts for extraVolumes in the mail container. |
| extraVolumes | list | [] |
Extra volumes for the mail pod, for example a mounted certificate or a writable path for custom logging. |
| fullnameOverride | string | "" |
Overrides the full resource name. Use it to keep existing resource names when upgrading from another chart. |
| gatewayAPI.httpRoute.annotations | object | {} |
|
| gatewayAPI.httpRoute.backendPortName | string | "http" |
Plain HTTP upstream; terminate client TLS on the Gateway's HTTPS listener. |
| gatewayAPI.httpRoute.enabled | bool | false |
|
| gatewayAPI.httpRoute.hostnames | list | [] |
|
| gatewayAPI.httpRoute.parentRefs | list | [] |
Each reference names a Gateway; namespace and sectionName are optional. |
| gatewayAPI.httpRoute.paths[0].type | string | "PathPrefix" |
|
| gatewayAPI.httpRoute.paths[0].value | string | "/" |
|
| gatewayAPI.tcpRouteApiVersion | string | "gateway.networking.k8s.io/v1" |
Use v1alpha2 for older experimental-channel TCPRoute CRDs. |
| gatewayAPI.tcpRoutes | object | {} |
Keys refer to service.ports entries, with a Gateway TCP listener per mail port. tcpRoutes: smtp: parentRefs: - name: mail-gateway sectionName: smtp annotations: {} |
| global.defaultStorageClass | string | "" |
Default storage class when persistence.storageClass is unset. |
| global.imagePullSecrets | list | [] |
|
| global.imageRegistry | string | "" |
|
| image.digest | string | pinned, see values.yaml | Multi-arch index digest of the tag. Takes precedence over the tag; clear it to float on the tag instead. |
| image.pullPolicy | string | "Always" |
|
| image.pullSecrets | list | [] |
|
| image.registry | string | "docker.io" |
|
| image.repository | string | "stalwartlabs/stalwart" |
|
| image.tag | string | pinned, see values.yaml | Image tag, kept in sync with the digest by Renovate. |
| ingress.annotations | object | {} |
|
| ingress.className | string | "" |
|
| ingress.enabled | bool | false |
|
| ingress.hosts | list | [] |
Defaults to a single "mail." host (HTTPS, path /) when empty and top-level domain is set. Set this explicitly to override that default or to declare more than one host. |
| ingress.tls | list | [] |
|
| lifecycleHooks | object | {} |
Lifecycle hooks for the mail container. |
| livenessProbe.enabled | bool | true |
|
| livenessProbe.failureThreshold | int | 3 |
|
| livenessProbe.initialDelaySeconds | int | 0 |
|
| livenessProbe.periodSeconds | int | 10 |
|
| livenessProbe.successThreshold | int | 1 |
|
| livenessProbe.timeoutSeconds | int | 5 |
|
| management.annotations | object | {} |
|
| management.port | int | 8080 |
|
| management.servicePort | int | 8080 |
|
| metrics.auth.existingSecret | string | "" |
|
| metrics.auth.password | string | "" |
Fallback only, stored in a chart-managed Secret and visible in Helm's release history. Prefer existingSecret. |
| metrics.auth.passwordSecretKey | string | "password" |
|
| metrics.auth.username | string | "" |
|
| metrics.enabled | bool | false |
|
| metrics.path | string | "/metrics/prometheus" |
|
| metrics.provision | bool | true |
Reapplies the Metrics singleton on every install/upgrade via the same CLI apply Job used for S3 provisioning. Disable if the Prometheus exporter is already configured through the WebUI/CLI. |
| metrics.serviceMonitor.additionalLabels | object | {} |
|
| metrics.serviceMonitor.annotations | object | {} |
|
| metrics.serviceMonitor.enabled | bool | false |
|
| metrics.serviceMonitor.honorLabels | bool | false |
|
| metrics.serviceMonitor.interval | string | "30s" |
|
| metrics.serviceMonitor.metricRelabelings | list | [] |
|
| metrics.serviceMonitor.relabelings | list | [] |
|
| metrics.serviceMonitor.scrapeTimeout | string | "" |
|
| nameOverride | string | "" |
Overrides the chart name used in resource names. |
| nodeSelector | object | {} |
Node selector for the mail StatefulSet. |
| oidc.activate | bool | false |
Activates this directory server-wide via the Authentication singleton's directoryId, replacing the internal directory for every protocol (IMAP, POP3, SMTP, JMAP) -- not only WebUI/JMAP -- since Stalwart has no per-protocol override. Stalwart's documentation does not confirm whether local/admin login keeps a fallback once this is set. Verify WebUI/admin access still works in a non-production environment before enabling this; leaving it false only declares the Directory object without switching any authentication over to it. |
| oidc.claimGroups | string | "" |
|
| oidc.claimName | string | "name" |
|
| oidc.claimUsername | string | "preferred_username" |
|
| oidc.enabled | bool | false |
|
| oidc.issuerUrl | string | "" |
|
| oidc.name | string | "keycloak" |
Short label. Also used as the Directory object's natural key (its "description" field), so re-applying doesn't create duplicates, and as the CLI-apply cross-reference for activate. |
| oidc.requireAudience | string | "stalwart" |
|
| oidc.requireScopes | list | ["openid","email"] |
A list here; rendered into Stalwart's object-of-true Set encoding. |
| oidc.usernameDomain | string | "" |
Domain appended to claimUsername values that don't already contain "@". |
| persistence.accessMode | string | "ReadWriteOnce" |
|
| persistence.enabled | bool | false |
Disabled for external PostgreSQL/S3; enable for local durable stores. |
| persistence.size | string | "20Gi" |
|
| persistence.storageClass | string | "" |
Empty uses the cluster default; "-" explicitly requests no storage class. |
| podAnnotations | object | {} |
Extra annotations on the mail pods. checksum/config and checksum/secret are reserved. |
| podAntiAffinityPreset | string | "" |
Spreads replicas across nodes when affinity is left empty: "", "soft" (preferred, default topologyKey kubernetes.io/hostname), or "hard" (required -- extra unschedulable replicas rather than co-located ones). |
| podLabels | object | {} |
Extra labels on the mail pods. Selector labels are reserved and cannot be overridden. |
| podManagementPolicy | string | "OrderedReady" |
OrderedReady starts/stops pods one at a time, each waiting for readiness (the startup probe allows up to ten minutes), so an HPA scale-up is serial. Parallel starts them together; with an external DataStore and no per-replica local state that is safe and much faster to scale. Immutable on an existing StatefulSet -- changing it on a live release means deleting the StatefulSet (e.g. --cascade=orphan) and upgrading. |
| podSecurityContext.enabled | bool | true |
|
| podSecurityContext.fsGroup | int | 2000 |
|
| podSecurityContext.fsGroupChangePolicy | string | "OnRootMismatch" |
|
| podSecurityContext.runAsGroup | int | 2000 |
|
| podSecurityContext.runAsNonRoot | bool | true |
|
| podSecurityContext.runAsUser | int | 2000 |
|
| podSecurityContext.seccompProfile.type | string | "RuntimeDefault" |
|
| provisioning.activeDeadlineSeconds | int | 600 |
|
| provisioning.backoffLimit | int | 6 |
|
| provisioning.existingSecret | string | "" |
Password for an administrator already recognized by Stalwart. |
| provisioning.image.digest | string | pinned, see values.yaml | Multi-arch index digest of the tag. Takes precedence over the tag; clear it to float on the tag instead. |
| provisioning.image.pullPolicy | string | "Always" |
|
| provisioning.image.pullSecrets | list | [] |
|
| provisioning.image.registry | string | "ghcr.io" |
|
| provisioning.image.repository | string | "stalwartlabs/cli" |
|
| provisioning.image.tag | string | pinned, see values.yaml | Image tag, kept in sync with the digest by Renovate. |
| provisioning.password | string | "" |
Fallback only, stored in a chart-managed Secret and visible in Helm's release history. Prefer existingSecret. |
| provisioning.passwordSecretKey | string | "password" |
|
| provisioning.resources.requests.cpu | string | "50m" |
|
| provisioning.resources.requests.memory | string | "64Mi" |
|
| provisioning.username | string | "admin" |
|
| pushShard | string | "" |
Maps to STALWART_PUSH_SHARD. Only set on nodes whose role delivers push notifications. Leave empty on single-node installs. |
| readinessProbe.enabled | bool | true |
|
| readinessProbe.failureThreshold | int | 3 |
|
| readinessProbe.initialDelaySeconds | int | 0 |
|
| readinessProbe.periodSeconds | int | 10 |
|
| readinessProbe.successThreshold | int | 1 |
|
| readinessProbe.timeoutSeconds | int | 5 |
|
| recoveryAdmin.enabled | bool | false |
|
| recoveryAdmin.existingSecret | string | "" |
Preferred: key containing the complete username:password credential. |
| recoveryAdmin.password | string | "" |
|
| recoveryAdmin.secretKey | string | "STALWART_RECOVERY_ADMIN" |
|
| recoveryAdmin.username | string | "admin" |
|
| recoveryMode.enabled | bool | false |
|
| recoveryMode.logLevel | string | "info" |
|
| recoveryMode.port | int | 8080 |
|
| replicaCount | int | 1 |
Number of pods in the StatefulSet. Single-node deployments keep this at 1. For a clustered deployment, raise this value and set role (and pushShard where applicable) to a value that makes sense for the whole StatefulSet, or install multiple releases of this chart with different role values. |
| resources.requests.cpu | string | "100m" |
|
| resources.requests.memory | string | "512Mi" |
|
| role | string | "" |
Maps to STALWART_ROLE. Names a ClusterRole defined in the database. Leave empty on single-node installs to run every task and listener. See /docs/cluster/configuration/roles for the role model. |
| service.annotations | object | {} |
|
| service.enabled | bool | true |
|
| service.externalTrafficPolicy | string | "Local" |
Preserve source IPs when supported by the load balancer. |
| service.ipFamilies | list | [] |
|
| service.ipFamilyPolicy | string | "" |
Dual-stack: SingleStack, PreferDualStack or RequireDualStack. Empty leaves the cluster default. ipFamilies (IPv4/IPv6, in order) with two entries requires a *DualStack policy. |
| service.loadBalancerSourceRanges | list | [] |
|
| service.ports.http.containerPort | int | 80 |
|
| service.ports.http.port | int | 80 |
|
| service.ports.http.protocol | string | "TCP" |
|
| service.ports.https.containerPort | int | 443 |
|
| service.ports.https.port | int | 443 |
|
| service.ports.https.protocol | string | "TCP" |
|
| service.ports.imap.containerPort | int | 143 |
|
| service.ports.imap.port | int | 143 |
|
| service.ports.imap.protocol | string | "TCP" |
|
| service.ports.imaps.containerPort | int | 993 |
|
| service.ports.imaps.port | int | 993 |
|
| service.ports.imaps.protocol | string | "TCP" |
|
| service.ports.pop3.containerPort | int | 110 |
|
| service.ports.pop3.port | int | 110 |
|
| service.ports.pop3.protocol | string | "TCP" |
|
| service.ports.pop3s.containerPort | int | 995 |
|
| service.ports.pop3s.port | int | 995 |
|
| service.ports.pop3s.protocol | string | "TCP" |
|
| service.ports.sieve.containerPort | int | 4190 |
|
| service.ports.sieve.port | int | 4190 |
|
| service.ports.sieve.protocol | string | "TCP" |
|
| service.ports.smtp.containerPort | int | 25 |
|
| service.ports.smtp.port | int | 25 |
|
| service.ports.smtp.protocol | string | "TCP" |
|
| service.ports.smtps.containerPort | int | 465 |
|
| service.ports.smtps.port | int | 465 |
|
| service.ports.smtps.protocol | string | "TCP" |
|
| service.ports.submission.containerPort | int | 587 |
|
| service.ports.submission.port | int | 587 |
|
| service.ports.submission.protocol | string | "TCP" |
|
| service.type | string | "ClusterIP" |
|
| serviceAccount.annotations | object | {} |
|
| serviceAccount.automountServiceAccountToken | bool | false |
|
| serviceAccount.create | bool | true |
|
| serviceAccount.labels | object | {} |
|
| serviceAccount.name | string | "" |
|
| startupProbe.enabled | bool | true |
|
| startupProbe.failureThreshold | int | 60 |
|
| startupProbe.initialDelaySeconds | int | 0 |
|
| startupProbe.periodSeconds | int | 10 |
|
| startupProbe.successThreshold | int | 1 |
|
| startupProbe.timeoutSeconds | int | 5 |
|
| terminationGracePeriodSeconds | int | 60 |
Seconds the pod gets to shut down cleanly. Stalwart needs time to drain connections. |
| tolerations | list | [] |
Tolerations for the mail StatefulSet. |
| topologySpreadConstraints | list | [] |
Topology spread constraints for the mail StatefulSet. Use for zone-level spreading; podAntiAffinityPreset only covers nodes. |
| updateStrategy.type | string | "RollingUpdate" |
|
| webmail.additionalAnnotations | object | {} |
|
| webmail.additionalLabels | object | {} |
|
| webmail.adminConfigReadonly | bool | false |
Prevent admin config writes. Requires a pre-seeded admin.json; otherwise Bulwark cannot bootstrap the dashboard password. |
| webmail.adminPassword.existingSecret | string | "" |
|
| webmail.adminPassword.secretKey | string | "ADMIN_PASSWORD" |
|
| webmail.adminPassword.value | string | "" |
|
| webmail.affinity | object | {} |
Explicit affinity always wins over podAntiAffinityPreset below. |
| webmail.appName | string | "" |
|
| webmail.autoscaling.behavior | object | {} |
|
| webmail.autoscaling.enabled | bool | false |
|
| webmail.autoscaling.maxReplicas | int | 5 |
|
| webmail.autoscaling.metrics | list | [] |
|
| webmail.autoscaling.minReplicas | int | 1 |
|
| webmail.autoscaling.targetCPUUtilizationPercentage | int | 80 |
|
| webmail.autoscaling.targetMemoryUtilizationPercentage | string | "" |
|
| webmail.containerSecurityContext.allowPrivilegeEscalation | bool | false |
|
| webmail.containerSecurityContext.capabilities.drop[0] | string | "ALL" |
|
| webmail.containerSecurityContext.enabled | bool | true |
|
| webmail.containerSecurityContext.privileged | bool | false |
|
| webmail.containerSecurityContext.readOnlyRootFilesystem | bool | true |
|
| webmail.containerSecurityContext.runAsNonRoot | bool | true |
|
| webmail.enabled | bool | false |
|
| webmail.extraEnv | object | {} |
Extra environment variables for anything not exposed as its own value above (branding, PWA icons, cookie/CSP settings, logging, locale, etc.) -- see bulwarkmail.org's environment reference. |
| webmail.extraEnvVars | list | [] |
|
| webmail.extraVolumeMounts | list | [] |
|
| webmail.extraVolumes | list | [] |
|
| webmail.gatewayAPI.httpRoute.annotations | object | {} |
|
| webmail.gatewayAPI.httpRoute.enabled | bool | false |
|
| webmail.gatewayAPI.httpRoute.hostnames | list | [] |
Defaults to a single "webmail." hostname when empty and top-level domain is set; required explicitly otherwise. |
| webmail.gatewayAPI.httpRoute.parentRefs | list | [] |
Each reference names a Gateway; namespace and sectionName are optional. |
| webmail.image.digest | string | pinned, see values.yaml | Multi-arch index digest of the tag. Takes precedence over the tag; clear it to float on the tag instead. |
| webmail.image.pullPolicy | string | "IfNotPresent" |
|
| webmail.image.pullSecrets | list | [] |
|
| webmail.image.registry | string | "ghcr.io" |
|
| webmail.image.repository | string | "bulwarkmail/webmail" |
|
| webmail.image.tag | string | pinned, see values.yaml | Image tag, kept in sync with the digest by Renovate. |
| webmail.ingress.annotations | object | {} |
|
| webmail.ingress.className | string | "" |
|
| webmail.ingress.enabled | bool | false |
|
| webmail.ingress.host | string | "" |
Defaults to "webmail." when empty and top-level domain is set; required explicitly otherwise. |
| webmail.ingress.tls | list | [] |
|
| webmail.jmapServerUrl | string | "" |
Browser-reachable JMAP URL. Empty falls back to the internal mail Service; set a public URL for access from outside the cluster. |
| webmail.livenessProbe.enabled | bool | true |
|
| webmail.livenessProbe.failureThreshold | int | 3 |
|
| webmail.livenessProbe.initialDelaySeconds | int | 0 |
|
| webmail.livenessProbe.periodSeconds | int | 15 |
|
| webmail.livenessProbe.successThreshold | int | 1 |
|
| webmail.livenessProbe.timeoutSeconds | int | 5 |
|
| webmail.nodeSelector | object | {} |
|
| webmail.oauth.autoSso | bool | false |
Starts the OAuth redirect automatically with no user interaction. Requires only: true. |
| webmail.oauth.clientId | string | "" |
|
| webmail.oauth.clientSecret | string | "" |
Fallback only, stored in a chart-managed Secret. Prefer existingSecret. |
| webmail.oauth.clientSecretKey | string | "OAUTH_CLIENT_SECRET" |
|
| webmail.oauth.enabled | bool | false |
|
| webmail.oauth.existingSecret | string | "" |
|
| webmail.oauth.extraScopes | string | "" |
|
| webmail.oauth.issuerUrl | string | "" |
Defaults to the top-level oidc.issuerUrl when unset and oidc.enabled. |
| webmail.oauth.only | bool | false |
Hides the username/password form, making OAuth the only login method. |
| webmail.oauth.scopes | string | "" |
|
| webmail.persistence.accessMode | string | "ReadWriteOnce" |
|
| webmail.persistence.enabled | bool | false |
|
| webmail.persistence.size | string | "2Gi" |
|
| webmail.persistence.storageClass | string | "" |
|
| webmail.podAnnotations | object | {} |
|
| webmail.podAntiAffinityPreset | string | "" |
Spreads replicas across nodes when affinity is left empty: "", "soft", or "hard" -- see the top-level podAntiAffinityPreset for details. |
| webmail.podLabels | object | {} |
|
| webmail.podSecurityContext.enabled | bool | true |
|
| webmail.podSecurityContext.fsGroup | int | 1000 |
The published image's non-root user; adjust if a custom image uses a different UID/GID. |
| webmail.podSecurityContext.fsGroupChangePolicy | string | "OnRootMismatch" |
|
| webmail.podSecurityContext.runAsGroup | int | 1000 |
|
| webmail.podSecurityContext.runAsNonRoot | bool | true |
|
| webmail.podSecurityContext.runAsUser | int | 1000 |
|
| webmail.podSecurityContext.seccompProfile.type | string | "RuntimeDefault" |
|
| webmail.readinessProbe.enabled | bool | true |
|
| webmail.readinessProbe.failureThreshold | int | 3 |
|
| webmail.readinessProbe.initialDelaySeconds | int | 0 |
|
| webmail.readinessProbe.periodSeconds | int | 10 |
|
| webmail.readinessProbe.successThreshold | int | 1 |
|
| webmail.readinessProbe.timeoutSeconds | int | 5 |
|
| webmail.replicaCount | int | 1 |
Multiple replicas require a pinned adminPassword and shared sessionSecret. Use ReadWriteMany persistence, or disable persistence and settingsSync. Without persistence, config and audit data are local to each replica. |
| webmail.resources.requests.cpu | string | "100m" |
|
| webmail.resources.requests.memory | string | "256Mi" |
|
| webmail.service.annotations | object | {} |
|
| webmail.service.ipFamilies | list | [] |
|
| webmail.service.ipFamilyPolicy | string | "" |
See the top-level service.ipFamilyPolicy. |
| webmail.service.port | int | 3000 |
|
| webmail.service.type | string | "ClusterIP" |
|
| webmail.serviceAccount.annotations | object | {} |
|
| webmail.serviceAccount.automountServiceAccountToken | bool | false |
|
| webmail.serviceAccount.create | bool | true |
|
| webmail.serviceAccount.name | string | "" |
|
| webmail.sessionSecret.existingSecret | string | "" |
|
| webmail.sessionSecret.secretKey | string | "SESSION_SECRET" |
|
| webmail.sessionSecret.value | string | "" |
Fallback only, stored in a chart-managed Secret and visible in Helm's release history. Prefer existingSecret. |
| webmail.settingsSync.enabled | bool | false |
|
| webmail.stalwartFeatures | bool | true |
Enables Stalwart-specific WebUI features (password change, Sieve management, vacation responder, account security, API keys, admin dashboard). Matches Bulwark's own default. |
| webmail.startupProbe.enabled | bool | true |
|
| webmail.startupProbe.failureThreshold | int | 30 |
|
| webmail.startupProbe.initialDelaySeconds | int | 0 |
|
| webmail.startupProbe.periodSeconds | int | 5 |
|
| webmail.startupProbe.successThreshold | int | 1 |
|
| webmail.startupProbe.timeoutSeconds | int | 5 |
|
| webmail.telemetry.enabled | bool | true |
|
| webmail.tolerations | list | [] |
Copyright 2026 Thomas Kaltenstein (Sovereign Systems). Licensed under the Apache License, Version 2.0. Bundled third-party components are listed in NOTICE. Stalwart and Bulwark images have their own licences.