Skip to main content

Stalwart GKE Module — Configuration Guide

This guide describes the configuration variables available in the Stalwart_GKE module. Stalwart_GKE is a wrapper module that combines the generic App_GKE infrastructure module with the Stalwart_Common shared application configuration to deploy Stalwart Mail Server — an open-source mail server that serves SMTP, IMAP, POP3, JMAP and ManageSieve from a single Rust binary — on Google Kubernetes Engine (GKE) Autopilot.

Most configuration options in Stalwart GKE map directly to the same options in App GKE. Where a variable is identical in behaviour, this guide references the App GKE guide rather than repeating the same documentation. Only the variables and defaults that are specific to Stalwart are described in full here.

Note: Variables marked as platform-managed are set and maintained by the platform. You do not normally need to change them.

GKE only: There is no Cloud Run variant. Mail protocols are line protocols that Cloud Run cannot forward, and Stalwart serves normally on 443 while its port 8080 opens only when it is unconfigured or has failed to start — so Cloud Run's $PORT contract would be satisfied exactly when the server is broken.


Standard Configuration Reference​

The following configuration areas are provided by the underlying App_GKE module. Consult the linked sections of the App_GKE Configuration Guide for full documentation.

Configuration AreaApp GKE.md SectionStalwart-Specific Notes
Project & Identity§2 IAM & Access ControlIdentical.
Application Identity§3.A Compute (GKE Autopilot)Change the leftover Wiki.js display name; see Group 3.
Runtime & Scaling§3.A Compute (GKE Autopilot)container_port = 443; keep one replica; see Group 4.
Environment Variables & Secrets§3 Core Service ConfigurationSTALWART_RECOVERY_ADMIN belongs in secret_environment_variables; see Group 5.
Kubernetes Service & Ports§5 Traffic & IngressL4 LoadBalancer carrying 443 plus six mail ports; see Group 6 and the port map.
Stateful Workloads§3.A Compute (GKE Autopilot)StatefulSet with a PVC at /var/lib/stalwart; see Group 7.
Pod Disruption Budgets§7.A Pod Disruption BudgetsOnly created while max_instance_count > 1.
Observability & Health Checks§3.A Compute (GKE Autopilot)TCP probes on 443; see Group 10.
Initialization Jobs & CronJobs§3.E Initialization Jobs & CronJobsNo init job — Stalwart creates its own schema.
CI/CD, Binary Authorization§6 CI/CD & Delivery, §4.CIdentical.
Storage — NFS / GCS§3.C Storage (NFS / GCS / GCS Fuse)Not used by Stalwart; see Groups 13–14.
Redis§8.A Redis / MemorystoreLeave off; see Group 15.
Database Configuration§3.B Database (Cloud SQL)MySQL 8.0, fixed by Stalwart_Common; see Group 16.
Backup Schedule, Import & Custom SQL§3.B, §8.B, §3.EIdentical.
Custom Domain & Static IP§5.C Static IP ReservationStatic IP on the LoadBalancer; no Gateway; see Group 19.
IAP, Cloud Armor, CDN§4.A, §4.B, §5.BGateway (HTTP) features — they do not cover mail ports.
VPC Service Controls§4.D VPC Service ControlsIdentical.

How Stalwart GKE Relates to App GKE​

Stalwart GKE passes its variables through to App GKE and adds a Stalwart Common sub-module that supplies the Stalwart-specific application configuration. The main effects are:

  1. A wrapper image writes config.json. Stalwart reads a single --config /etc/stalwart/config.json file that holds only its DataStore definition, and the file does not ship in the image. App_GKE cannot mount a ConfigMap into the app container, so Stalwart_Common builds FROM stalwartlabs/stalwart:<version> with an entrypoint that renders the file from the foundation's DB_IP, DB_PORT, DB_NAME and DB_USER on every start. The password is not written into the file — its authSecret is { "@type": "EnvironmentVariable", "variableName": "DB_PASSWORD" }.
  2. The database is MySQL 8.0, reached over the private IP. Stalwart_Common sets database_type = "MYSQL_8_0" and enable_cloudsql_volume = false; the DataStore host is the raw Cloud SQL private IP. These values come from Stalwart_Common and override the wrapper's own database_type and enable_cloudsql_volume inputs, as does the container port (443).
  3. There is no initialization job. The foundation's db-create step provisions the database and user, and Stalwart creates its own tables (27 on a fresh install) on first connect.
  4. The container serves on 443, never 8080. Stalwart's normal HTTPS listener (admin UI, JMAP, /healthz/live) is 443. Port 8080 opens only in its bootstrap ("no configuration file was found") and recovery ("startup failed") states.
  5. Probes are TCP on 443. The image's own HEALTHCHECK is curl https://127.0.0.1:443/healthz/live || curl http://127.0.0.1:8080/healthz/live, which reports a broken server as healthy via the 8080 fallback. An HTTP probe is not possible either: App_GKE's HTTP probe has no scheme field, so it cannot check an HTTPS port.
  6. The Service is an L4 TCP LoadBalancer with the mail ports added. service_port = 443 plus extra_service_ports (25, 465, 587, 993, 995, 4190).
  7. Single replica by design. A second replica needs Stalwart's coordinator, which needs Redis; the module avoids Redis. Note that the wrapper's max_instance_count default is 3 — set it to 1.
  8. Domains, listeners and TLS are not configured. Every Stalwart setting other than the DataStore lives in the database and is applied with stalwart-cli (a separate image). The module does not wire that step.

Group 1: Project & Identity​

Identical to App_GKE. See App_GKE. Inputs: project_id (required), tenant_id ("demo"), region ("us-central1").


Group 2: Deployment Environment​

Identical to App_GKE: support_users ([]) and resource_labels ({}).


Group 3: Application Identity​

VariableStalwart GKE DefaultNotes
application_name"stalwart"Base name for all GCP and Kubernetes resources. Do not change after deployment.
application_display_name"Wiki.js"A leftover from the module the wrapper was copied from. It is what the GCP Console, the static IP description and dashboards show — set it to "Stalwart Mail Server" or similar.
application_description"Wiki.js - The most powerful and extensible open source Wiki software"Also a leftover, but not used — the workload description comes from Stalwart_Common ("Stalwart — open-source mail and collaboration server…").
application_version"v0.16.22"stalwartlabs/stalwart tag, passed to the build as STALWART_VERSION. Pin an exact release — this value overrides the _Common default. A rebuild under an unchanged tag produces no Terraform diff.

Group 4: Runtime & Scaling​

VariableStalwart GKE DefaultNotes
container_port443No effect — App_GKE takes the container port from Stalwart_Common, which fixes it at 443, Stalwart's normal HTTPS listener (never 8080, the bootstrap/recovery port). The TCP probes check this port.
container_resources{ cpu_limit = "1000m", memory_limit = "2Gi" }Also passed to Stalwart_Common as the container's limits.
min_instance_count1Keep at least one replica — mail must be received continuously.
max_instance_count3Set to 1. Any value above 1 creates a HorizontalPodAutoscaler (min min_instance_count, max this value, scaling on CPU and memory utilisation) and a PodDisruptionBudget. A second replica requires Stalwart's coordinator, which requires Redis, which this module does not configure.
container_image_source"custom"Builds the wrapper image. "prebuilt" would deploy an image without the config.json generator; Stalwart would then find no configuration and open port 8080.
container_image"stalwartlabs/stalwart"Base image reference.
enable_image_mirroringtrueMirror the image into Artifact Registry.
enable_cloudsql_volumetrueNo effect — Stalwart_Common always sets it to false (no Auth Proxy sidecar).
service_annotations / service_labels{}Applied to the Kubernetes Service at creation only; App_GKE ignores later changes to them.

Group 5: Environment Variables & Secrets​

These variables behave identically to App_GKE. See App_GKE.

Stalwart-specific behaviour:

Stalwart_Common sets two environment variables, and environment_variables is merged over them:

VariableDefaultPurpose
STALWART_PUBLIC_URL""Published base URL for OAuth, OIDC and JMAP discovery. Left empty on purpose — a wrong value is handed to clients in discovery documents. The wrapper has no dedicated input for it; set it through environment_variables once you have a domain (e.g. https://mail.example.com).
STALWART_DATASTORE_TYPE"MySql"The DataStore @type the entrypoint renders, derived from the engine Stalwart_Common provisions. Do not override it.

The entrypoint also reads STALWART_DB_USE_TLS and STALWART_DB_ALLOW_INVALID_CERTS (both default false) for the DataStore connection; the defaults are correct for Cloud SQL over the private IP.

Bootstrap administrator — set it:

Stalwart's administrator is supplied as STALWART_RECOVERY_ADMIN, with the value username:password. Store it in Secret Manager and map it in secret_environment_variables:

printf 'admin:%s' "$(openssl rand -base64 24)" | \
gcloud secrets create stalwart-recovery-admin --data-file=- --project "$PROJECT"
secret_environment_variables = {
STALWART_RECOVERY_ADMIN = "stalwart-recovery-admin"
}

Without it, Stalwart generates a random administrator password and prints it to the container log once; the entrypoint logs recovery_admin=<unset - a random password will be printed below> just before.

The remaining secrets variables (secret_rotation_period, secret_propagation_delay, protect_sensitive_environment_variables) behave as described in App_GKE.


Group 6: Kubernetes Service, Workload & Ports​

VariableDefaultNotes
service_type"LoadBalancer"An external passthrough Network Load Balancer (networking.gke.io/load-balancer-type: External). Mail protocols need this — a Gateway/Ingress carries HTTP only.
service_port443Primary Service port (named http), targeting container port 443.
session_affinity"ClientIP"Requests from one client IP go to the same pod.
workload_type"StatefulSet"Stable identity plus the per-pod PVC in Group 7.
namespace_name""Derived from the service name when empty.
enable_network_segmentationfalseKubernetes NetworkPolicies.
termination_grace_period_seconds30Seconds between SIGTERM and SIGKILL.

Port map. extra_service_ports (a Group 0, platform-managed input) adds the mail ports to the same Service. Each targets the same port on the container and uses TCP:

PortNameProtocol
443httpHTTPS — admin UI, JMAP, /healthz/live. TLS is terminated by Stalwart, not by Google.
25smtpSMTP. Inbound only in practice — Google Cloud blocks outbound 25.
465submissionsSMTP submission, implicit TLS.
587submissionSMTP submission (STARTTLS); also the port for relaying outbound mail through a smart host.
993imapsIMAP, implicit TLS.
995pop3sPOP3, implicit TLS.
4190managesieveManageSieve (verified to return Stalwart's own banner).

Plaintext IMAP (143) and POP3 (110) are omitted on purpose — the implicit-TLS ports do the same job without a cleartext credential path. Port 8080 is not published. GKE creates the firewall rules for a LoadBalancer Service automatically. The variable's own description still says it is "declared but NOT forwarded"; that text is out of date — main.tf forwards it to App_GKE.


Group 7: Stateful Workloads​

VariableDefaultNotes
stateful_pvc_enabledtrueOne PVC per pod.
stateful_pvc_mount_path"/var/lib/stalwart"Stalwart's working directory and local state. Mail itself is stored in the Cloud SQL DataStore.
stateful_pvc_size"10Gi"Size of each PVC.
stateful_pvc_storage_class"standard-rwo"Balanced PD. StatefulSet PVC templates are immutable — choose the class before the first deploy.
stateful_fs_group0Pod fsGroup.
stateful_headless_service, stateful_pod_management_policy, stateful_update_strategynullApp_GKE defaults (OrderedReady, RollingUpdate).

/etc/stalwart is deliberately not persisted — config.json is regenerated from the platform's variables on every start.


Group 9: Reliability Policies​

enable_pod_disruption_budget (true) and pdb_min_available ("1"). App_GKE creates the PDB only when max_instance_count > 1; with the recommended max_instance_count = 1 no PDB exists. See App_GKE.


Group 10: Observability & Health​

Unlike wrappers whose probes come from their _Common module's own variables, Stalwart GKE passes startup_probe_config and health_check_config to Stalwart_Common, so these two inputs are the probes on the Stalwart container.

Startup probe (startup_probe_config):

FieldDefaultNotes
type"TCP"TCP connect to container_port (443). Do not switch to HTTP — the probe cannot speak HTTPS.
initial_delay_seconds60
timeout_seconds5
period_seconds10
failure_threshold3

Liveness probe (health_check_config): TCP, initial_delay_seconds = 60, timeout_seconds = 5, period_seconds = 30, failure_threshold = 3.

What the probe catches. It fails a server that did not start (only 8080 is bound then). It does not fail a server that started with its DataStore connected but no server configuration — 443 is bound in that state as well, and on the verified deployment both 443 and 8080 answered /healthz/live with 200. Checking that 8080 is closed is an operator task:

kubectl exec -n "$NS" <pod> -- curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/healthz/live

A fully configured server refuses that connection. Do not repoint a failing probe at 8080.

uptime_check_config defaults to { enabled = false, path = "/" }. Without a Gateway, App_GKE's uptime check is a plain-HTTP (no SSL) check against service_port on the external address — but Stalwart serves 443 over HTTPS only, so leave it disabled.


Group 11: Jobs & Additional Services​

No initialization job is defined — leave initialization_jobs = []. cron_jobs and additional_services behave as in App_GKE.


Group 12: CI/CD & Binary Authorization​

Identical to App_GKE. See App_GKE.


Groups 13 and 14: Storage​

VariableDefaultNotes
enable_nfstrueMounts an NFS share at nfs_mount_path ("/mnt/nfs"). Stalwart does not read or write it; its state is on the PVC and in Cloud SQL.
create_cloud_storagetrueCreates the generic data bucket from storage_buckets. Stalwart_Common declares no buckets of its own.
gcs_volumes[]No GCS FUSE mounts by default.

The remaining storage and image-retention inputs behave as in App_GKE.


Group 15: Redis​

enable_redis defaults to false; leave it off. Redis would only be needed for multiple replicas, and Stalwart's Redis connection is a URL (redis://user:pass@host:port), into which a Memorystore AUTH string would have to be percent-encoded — the module does not do that.


Group 16: Database​

VariableDefaultNotes
database_type"MYSQL_8_0"Used only by plan-time validation. The engine provisioned and rendered into config.json is set by Stalwart_Common (MySQL 8.0); the wrapper does not forward this value to it.
application_database_name / application_database_user"stalwart"Inert — the foundation names the database and user after the service.
database_password_length32Do not change on a running deployment — it writes a new password to Secret Manager without updating the database user.
enable_postgres_extensionsfalseNot applicable to MySQL; leave false.
enable_auto_password_rotation / rotation_propagation_delay_secfalse / 90Stalwart reads the password from the DB_PASSWORD environment variable, so a rotated password takes effect when the pod is restarted after rotation.

Group 17: Backup​

Identical to App_GKE: backup_schedule ("0 2 * * *", a Kubernetes CronJob), backup_retention_days (7, a lifecycle rule on the backups bucket), and the enable_backup_import / backup_source / backup_file / backup_format restore inputs.


Group 18: Custom SQL Scripts​

Identical to App_GKE. Scripts run against the Stalwart database over the private IP. Do not alter the tables Stalwart owns.


Group 19: Custom Domain, Static IP & Network Tags​

VariableDefaultNotes
reserve_static_iptrueReserves a regional static external IP and assigns it to the LoadBalancer Service, so MX and DNS records have a stable target. (It is not applied while Cloud Deploy is enabled.)
static_ip_name""Auto-generated as <service-name>-lb-ip when empty.
enable_custom_domainfalseLeave false. true creates a Gateway, which is HTTP-only and duplicates the 443 already on the LoadBalancer — and uses another external IP.
application_domains[]Only used with a Gateway.
network_tags["nfsserver"]Node/pod network tags.

Groups 20–22: IAP, Cloud Armor & CDN, VPC Service Controls​

enable_iap, enable_cloud_armor and enable_cdn all act on a Gateway (HTTP); enabling Cloud Armor or CDN creates one. None of them protects the mail ports on the LoadBalancer. VPC Service Controls and audit logging (Group 22) are identical to App_GKE.


Validation Guards​

validation.tf fails the plan when min_instance_count > max_instance_count; when enable_redis = true with neither redis_host nor enable_nfs; when enable_iap = true without both OAuth client fields; or when enable_cloudsql_volume = true with database_type = "NONE".


Exploring the Deployment​

Google Cloud Console​

  • Kubernetes Engine → Workloads — the Stalwart StatefulSet and its pod.
  • Kubernetes Engine → Gateways, Services & Ingress — the LoadBalancer Service with its seven ports and external IP.
  • VPC network → IP addresses — the reserved …-lb-ip address.
  • SQL — the Cloud SQL instance holding the Stalwart database.
  • Secret Manager — the database password and your STALWART_RECOVERY_ADMIN secret.

gcloud CLI and kubectl​

# Cluster credentials
gcloud container clusters get-credentials <cluster-name> --region "$REGION" --project "$PROJECT"

# Namespace, pod, Service and PVC
NS=$(kubectl get ns -o name | grep stalwart | head -1 | cut -d/ -f2)
kubectl get statefulset,pods,svc,pvc -n "$NS"

# The ports published on the LoadBalancer
kubectl get svc -n "$NS" -o jsonpath='{range .items[*].spec.ports[*]}{.name}{"\t"}{.port}{"\n"}{end}'

# Startup lines written by the entrypoint (DataStore target, admin state)
kubectl logs -n "$NS" statefulset/"$(kubectl get statefulset -n "$NS" -o jsonpath='{.items[0].metadata.name}')" | grep '\[startup\]'

# Health from inside the pod (443 must answer; 8080 should refuse once configured)
POD=$(kubectl get pods -n "$NS" -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n "$NS" "$POD" -- curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1:443/healthz/live
kubectl exec -n "$NS" "$POD" -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/healthz/live

# Database password secret
gcloud secrets list --project "$PROJECT" --filter="name~stalwart"

Module Outputs​

OutputDescription
service_nameName of the Kubernetes Service
namespaceKubernetes namespace
service_cluster_ipClusterIP of the Service
service_external_ipExternal LoadBalancer IP — the address mail clients and MX records point to
service_urlRendered as http:// plus the external IP. Stalwart answers HTTPS on 443, so use https:// + service_external_ip.
database_instance_name / database_name / database_userCloud SQL instance, database and user
database_password_secretSecret Manager secret holding the DB password
database_hostAlways reports 127.0.0.1 (the foundation's proxy default); Stalwart actually connects to the Cloud SQL private IP
database_portDatabase port
storage_bucketsCreated GCS buckets
container_imageContainer image used for the deployment
initialization_jobs / db_import_jobJob names
cicd_enabled / github_repository_urlCI/CD status
kubernetes_readytrue when the cluster endpoint was reachable and the Kubernetes resources were deployed

Configuration Pitfalls & Sensible Defaults​

Risk levels: Critical (data loss, full outage, security breach) — High (service unavailable or significant degradation) — Medium (degraded function or increased cost) — Low (minor impact).

VariableSensible DefaultRiskConsequence of Incorrect Value
startup_probe_config / health_check_configtype = "TCP"CriticalAn HTTP probe cannot check Stalwart's HTTPS-only 443 and will fail a healthy pod. The probe port is fixed at 443 by Stalwart_Common; never work around a failing probe by targeting 8080, which answers exactly when Stalwart is unconfigured or has failed to start.
container_image_source"custom"CriticalWithout the wrapper there is no config.json; Stalwart opens 8080 in bootstrap mode and serves no mail.
STALWART_RECOVERY_ADMINSet via secret_environment_variablesHighUnset, the only administrator password is printed once to the container log.
max_instance_count1HighThe default 3 lets the HPA add replicas, which need a Redis-backed coordinator the module does not configure, and creates a PDB.
Server configuration (domains, listeners, TLS)Apply with stalwart-cli after deployHighNot done by the module. Until it is, port 8080 stays open in the pod and external TLS connections were observed to be closed without a certificate.
Outbound mailSmart host on 587HighGoogle Cloud blocks outbound port 25; direct MX delivery to other domains cannot work.
database_password_lengthLeave unchangedHighChanging it on a running deployment breaks database authentication.
application_versionAn exact release tagMediumA rebuild under an unchanged tag creates no new pod template; kubectl rollout restart pulls the rebuilt image (pull policy Always).
enable_custom_domain / enable_cloud_armor / enable_cdnfalseMediumEach creates a Gateway that carries HTTP only and consumes another external IP; mail ports are unaffected.
reserve_static_iptrueMediumWith false the LoadBalancer gets an ephemeral IP that changes whenever the Service is recreated — MX records would go stale.
stateful_pvc_storage_class / stateful_pvc_sizeDecide before first deployMediumStatefulSet PVC templates are immutable; later changes do not apply to the existing PVC.
application_display_name"Stalwart Mail Server"LowThe shipped default shows Wiki.js in the Console and dashboards.
enable_nfsfalse if nothing else needs itLowDefaults true and mounts an NFS share Stalwart never uses.

Need RAD to do something it does not do yet? Request it on the roadmap, or vote on what is already there.