Skip to main content

Helm base chart

The base chart deploys Stalwart itself: a Deployment, a multi-port Service, two PVCs (config, data), a ServiceAccount, an HPA scaffold, an optional PodDisruptionBudget, and a Gateway API HTTPRoute for the HTTP-based protocols. It does not provision databases, secrets, or observability — see the platform chart.

Values reference: helm/base/values.yaml

Install

helm install stalwart ./helm/base \
--set gateway.hostnames[0]=mail.example.com

After install, port-forward the admin UI and complete the setup wizard:

kubectl port-forward svc/stalwart 8080:8080
# capture the generated admin password from logs first:
kubectl logs deploy/stalwart | grep -i password

Ports

Stalwart serves two distinct families of traffic. The chart exposes both on a single multi-port Service so the in-cluster surface stays uniform; the HTTPRoute only attaches to the HTTP port.

Port namePortProtocolWhat it carries
http8080HTTPAdmin UI, JMAP, CalDAV, CardDAV, WebDAV, /healthz, /metrics
smtp25SMTPInbound mail from other servers
submission587SMTP+STARTTLSAuthenticated client submission
submissions465SMTPSImplicit-TLS client submission
imap143IMAP+STARTTLSMail client retrieval
imaps993IMAPSImplicit-TLS IMAP
pop3110POP3+STARTTLSMail client retrieval (legacy)
pop3s995POP3SImplicit-TLS POP3
sieve4190ManageSieveSieve filter script management

To remove a protocol you don't run, delete its key under service.ports. The container exposes everything; the Service decides what's reachable.

Exposing mail to the internet

The HTTPRoute handles HTTP only. Mail TCP ports need a Layer-4 path off the cluster. Two common options:

LoadBalancer service

Set the Service to LoadBalancer and let your cluster's load balancer (MetalLB, cloud LB) front every port:

service:
type: LoadBalancer
externalTrafficPolicy: Local # preserves client source IPs for greylisting / abuse rules

NodePort + external proxy

For homelab setups without an LB, switch to NodePort and front the nodes with HAProxy or your edge router. Document the chosen NodePorts in your platform notes — Kubernetes assigns them dynamically by default.

The HTTP port is also reachable through the Gateway, so you don't need the LoadBalancer to expose 8080.

Persistence

Stalwart needs persistent disks for both config and data. The chart provisions two PVCs:

VolumeMountDefault sizeWhat's in it
config/etc/stalwart1 Giconfig.json, generated admin password, TLS material
data/var/lib/stalwart20 GiMailboxes, queue spool, embedded data/blob stores
persistence:
config:
size: 1Gi
storageClass: "" # use cluster default
data:
size: 50Gi
storageClass: fast-ssd
accessMode: ReadWriteOnce

Both PVCs default to ReadWriteOnce — Stalwart is single-instance in this chart. If you offload state to PostgreSQL + S3 (via the platform chart) the data PVC mostly holds the queue, so it can be sized smaller.

The config PVC contains the generated admin password and the active config — back it up. Losing it forces re-bootstrap.

Configuration

Stalwart is configured through /etc/stalwart/config.json, populated on first run by the in-cluster setup wizard. The chart does not pre-populate the file — point your browser at the admin UI after install and follow the prompts.

Environment variables

Use env for non-secret values:

env:
- name: STALWART_FQDN
value: mail.example.com

Injecting secrets

Don't put credentials in env. Use envFrom to pull from a Secret — typically one generated by the platform chart's ExternalSecrets integration:

envFrom:
- secretRef:
name: stalwart

See the platform chart docs for which keys to map.

Ingress (HTTP)

The base chart emits a Gateway API HTTPRoute for HTTP-based protocols (admin UI, JMAP, CalDAV, CardDAV, WebDAV). A parent Gateway with a TLS-terminating HTTPS listener is expected from the platform — typically a gateway chart such as hiroba-gateway.

gateway:
parentRefs:
- name: default-gateway
namespace: gateway-system
sectionName: https # pin to the HTTPS listener
hostnames:
- mail.example.com

Pin sectionName to an HTTPS listener so plaintext traffic doesn't silently match. HTTP→HTTPS redirect is a listener-level concern that lives on the parent Gateway.

Custom routing rules

The default catch-all sends every path to the HTTP port. Override gateway.rules if you need to split paths (e.g. route /.well-known/caldav differently). backendRefs are wired automatically to the chart's HTTP port.

Probes

Liveness and readiness probes hit Stalwart's /healthz/live and /healthz/ready endpoints on the HTTP port (8080). The defaults give Stalwart 30 seconds to come up before liveness fails, which matters when the data store is being opened the first time.

Scaling

autoscaling.enabled is off and replicaCount is 1 by design — Stalwart with the chart's default RWO PVCs is single-instance. Multi-replica deployments require:

  • An external data store (PostgreSQL via the platform chart, or FoundationDB)
  • An external blob store (S3 via the platform chart)
  • An RWX volume or no volume for /var/lib/stalwart

Once you've moved state out of the pod, raise replicaCount and consider enabling the HPA. The chart's HPA template is generic — replace its CPU metric with one Stalwart actually correlates with (e.g. stalwart_smtp_active_connections) before relying on it.

PodDisruptionBudget

Once you're running more than one replica, enable a PDB so node drains can't take the mail server fully offline:

podDisruptionBudget:
enabled: true
minAvailable: 1

The selector reuses the Deployment's pod labels — no cross-release coupling.

Security context

The pod runs as UID 2000 (matching the upstream image's stalwart user) with readOnlyRootFilesystem: true, all Linux capabilities dropped, and allowPrivilegeEscalation: false. The upstream image grants cap_net_bind_service directly on the binary via setcap, so port 25 etc. work without adding capabilities to the container.

If a future Stalwart feature needs to write outside the mounted volumes, prefer adding an emptyDir to extraVolumes over loosening the security context.