kadet-hub logokadet-hub

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.

What it deploys

Required parameters

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

The recipe's files are in kadet-hub/recipes. All recipes