Stalwart Mail Server on Kubernetes with Kapitan
Stalwart Mail Server for one mail domain: SMTP, submission and IMAP over TLS, DKIM signing, the domain’s DNS records, a first mailbox and an admin from required parameters, designed in kadet-hub on Stalwart’s image and rendered by the kadet-hub kubernetes generator 1.14.0.
- Latest version:
1.0.0, published - Source: container image
docker.io/stalwartlabs/stalwart:v1.0.3@sha256:73c684a84c24f5b4206ec266a8deb8986fe44a9f623997e07f084388d943c8bd, recipe designed in kadet-hub - Proof level:
deploys, deployed on kind and smoke tested by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities:
added-capabilities - Inventory backends:
omegaconf - Prerequisites, installed once per cluster before the recipe:
cert-manager - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080, certmanager 1.0.2sha256:ba7632e9e06a593fbe20e9fa6774139203d7df65992adff9db94779606888ad9 - Image security: grade D 308 findings, one per advisory and package, in the 2 images of version 1.0.0: CRITICAL 0, HIGH 54, MEDIUM 106, LOW 144, UNKNOWN 4. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.io/stalwartlabs/cli,docker.io/stalwartlabs/stalwart.
What it deploys
recipes.stalwart(init.yml)recipes.stalwart.stalwart(stalwart.yml):stalwartrecipes.stalwart.stalwart-apply(stalwart-apply.yml):stalwart-applyrecipes.stalwart.dkim(dkim.yml), an optionrecipes.stalwart.certificate(certificate.yml), an optionrecipes.stalwart.relay(relay.yml), an optionrecipes.stalwart.powerdns(powerdns.yml), an option:stalwart
Required parameters
applications.stalwart.domain, the mail domain, such asexample.com.applications.stalwart.admin.password, as a ref. It is the password of the administratoradmin, who has full access to Stalwart and its API.applications.stalwart.mailbox.nameandapplications.stalwart.mailbox.password, the local part and the password of the first mailbox, the password as a ref. Stalwart refuses weak passwords. The plan carries the password inside JSON, so it holds no double quote and no backslash. A ref such as?{gpg:mail/alice||randomstr}has neither.- With
recipes.stalwart.dkim:applications.stalwart.dkim.private_key, as a ref to an RSA private key in PEM, such as?{gpg:mail/dkim||rsa:2048}. - With
recipes.stalwart.certificate:applications.stalwart.issuer, thenameandkindof the cert-manager issuer. - With
recipes.stalwart.relay:applications.stalwart.relay.host,applications.stalwart.relay.usernameandapplications.stalwart.relay.password, the password as a ref without a double quote or a backslash. - With
recipes.stalwart.powerdns:applications.powerdns.api_key, the key of PowerDNS’s HTTP API, which the included recipepowerdnsrequires, as a ref.
Without one of them the compile stops with Interpolation key ... not found and that key.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.stalwart
# The DKIM key from a ref
- recipes.stalwart.dkim
# The certificate from cert-manager
- recipes.stalwart.certificate
# Outbound mail through a relay
- recipes.stalwart.relay
# The DNS records published into the included recipe powerdns
- recipes.stalwart.powerdns
parameters:
namespace: mail
kapitan:
vars:
target: stalwart
applications:
stalwart:
domain: example.com
admin:
password: <ref>
mailbox:
name: alice
password: <ref>
dkim:
private_key: <ref>
issuer:
name: letsencrypt
kind: ClusterIssuer
relay:
host: smtp.example.net
username: example
password: <ref>
powerdns:
zone: example.com
api_key: <ref>
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.0:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/stalwart:1.0.0@sha256:50a65946157d87e2f2918c12db9b835598298c5fdab20a4e7aa29470ef6fd24f
output_path: inventory/classes/recipes/stalwart
Version 1.0.0 includes powerdns 1.0.3. The consumer workflow adds the included recipes at the digests the index lists. Kapitan's --fetch reads no index, so for a local compile pull them by digest first:
tmp=$(mktemp -d)
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/powerdns@sha256:0a0798713b0aee64568de5c597eba2e162b8eefefee4326c5d93625173752de1
mkdir -p inventory/classes/recipes/powerdns
tar -xf "$tmp/powerdns.tar" -C inventory/classes/recipes/powerdns --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/stalwart, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe and of the recipes it includes:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/stalwart@sha256:50a65946157d87e2f2918c12db9b835598298c5fdab20a4e7aa29470ef6fd24f \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/powerdns@sha256:0a0798713b0aee64568de5c597eba2e162b8eefefee4326c5d93625173752de1 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
The output rules found added-capabilities in the compiled output of the recipe's test target. The consumer workflow fails unless the policy .kapitan-sandbox.yaml accepts these capabilities for the target's output path:
output_capabilities:
"compiled/<target>/*": [added-capabilities]
Settings
| Setting | Inventory |
|---|---|
| Stalwart version | components.stalwart.image, tag and digest |
host name of the server, mail.<domain> |
applications.stalwart.hostname |
name of the administrator, admin |
applications.stalwart.admin.name |
DKIM selector, stalwart |
applications.stalwart.dkim.selector |
DMARC policy, quarantine |
applications.stalwart.dmarc.policy |
MTA-STS mode, testing |
applications.stalwart.mta_sts.mode |
MTA-STS policy id, 1 |
applications.stalwart.mta_sts.id |
| port of the relay, 465 | applications.stalwart.relay.port |
| volume size, 10Gi | components.stalwart.volume_claims.data.spec.resources.requests.storage |
| resources | components.stalwart.resources |
Service type, ClusterIP |
components.stalwart.service.type |
Proof
check stalwart --level deploys with tests/target.yml on kind for each
Kubernetes minor CI tests. The image has no Kubernetes objects to compare
with, so the recipe is proven by deploying it (RCP-44). The target includes
the options certificate, relay and powerdns, with a self-signed issuer.
Its DKIM key comes from a cert-manager Certificate in place of the option
dkim: a key in the repository fails the registry’s secret scan, and a key
that a Kapitan ref function generates fails its read-only compile. All
addresses are in example.test (RFC 2606).
tests/smoke waits for stalwart-apply and starts a client pod. The
admin creates the second mailbox b. The first mailbox a sends to b on
465 with TLS and AUTH, and b reads the message on 993 with TLS, the
certificate verified both times. The smoke test resolves each record of the
zone through the Service powerdns and verifies the message’s
DKIM-Signature against the key of the DKIM record in Python, not with
Stalwart. It reads the MTA-STS policy over HTTPS. It requires 550 5.7.1
for a relay attempt without AUTH on port 25, 503 for AUTH there, and 403
for the API on port 8080. It reads the route of the option relay back
through the API. b registers an S/MIME certificate whose key the client pod
generates and never prints, and the next message for b is stored as an
S/MIME enveloped message without its text, which the key decrypts. A second
pod logs in, and its 101st failed login after that gets its address banned,
while the first pod still connects. A certificate that cert-manager issues
anew is served without a restart. After stalwart-0 is deleted, b reads
the first message again.
Versions
1.0.0, :sha256:50a65946157d87e2f2918c12db9b835598298c5fdab20a4e7aa29470ef6fd24f
The recipe's files are in kadet-hub/recipes. All recipes