Zulip on Kubernetes without Helm, with Kapitan
Zulip 12.3, team chat organized by channel and topic, with its first organization and owner created from parameters, rendered by the kadet-hub kubernetes generator 1.14.0 from Zulip’s Helm chart 2.3.0, on the included recipes postgresql, rabbitmq, memcached and valkey.
- Latest version:
1.0.0, published - Source chart:
oci://ghcr.io/zulip/helm-charts/zulip:2.3.0@sha256:e462aaa5d39e89661927ad9a744c57b938f8d75e09c5660712314111a2802593 - Proof level:
deploys, compared with the chart by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Prerequisites, installed once per cluster before the recipe:
cnpg,rabbitmq-cluster-operator,cert-manager - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade F 6000 findings, one per advisory and package, in the 1 image of version 1.0.0: CRITICAL 32, HIGH 1038, MEDIUM 3870, LOW 1055, UNKNOWN 5. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:ghcr.io/zulip/zulip-server.
What it deploys
recipes.zulip(init.yml)recipes.zulip.zulip(zulip.yml):zulip
Required parameters
applications.zulip.host is SETTING_EXTERNAL_HOST, the host name users
reach Zulip at. applications.zulip.administrator is
SETTING_ZULIP_ADMINISTRATOR, the address that gets Zulip’s error and
support mail, and the login of the organization’s owner. Zulip refuses to
start with the documentation’s examples zulip.example.com and
zulip-admin@example.com.
applications.zulip.secret_key is Django’s SECRET_KEY, which signs
sessions, so a new key logs every user out. Any random string works, such
as one that openssl rand -base64 48 prints.
applications.zulip.owner_password is the owner’s password. It needs 8
characters or more and must pass Zulip’s strength check, which rejects a
password that zxcvbn guesses in fewer than 10000 attempts.
applications.valkey.password, which the included recipe valkey
requires, is the password of Valkey’s user default, with which Zulip
connects to Redis. postgresql, rabbitmq and memcached require no
parameter. Set the three secrets as refs with the backend of your choice.
Without one of the parameters the compile stops with
Interpolation key '<key>' not found.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.zulip
parameters:
namespace: zulip
kapitan:
vars:
target: zulip
applications:
zulip:
host: chat.example.com
administrator: admin@example.com
secret_key: <ref>
owner_password: <ref>
valkey:
password: <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/zulip:1.0.0@sha256:bb1692b5063be69c03e73adbb3c22ced3f212afcf1c668d77955d61d871a66f0
output_path: inventory/classes/recipes/zulip
Version 1.0.0 includes memcached 1.0.1, postgresql 1.1.1, rabbitmq 1.0.1 and valkey 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/memcached@sha256:eeea246db03e3e6a06232d58e019b2ba44d21550396c8d8aeab0d3137b63a7b4
mkdir -p inventory/classes/recipes/memcached
tar -xf "$tmp/memcached.tar" -C inventory/classes/recipes/memcached --no-same-owner
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9
mkdir -p inventory/classes/recipes/postgresql
tar -xf "$tmp/postgresql.tar" -C inventory/classes/recipes/postgresql --no-same-owner
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/rabbitmq@sha256:a10fd3bef0def7949b4b90e609dffc99ed16a6e188a743d7e9acab56525173aa
mkdir -p inventory/classes/recipes/rabbitmq
tar -xf "$tmp/rabbitmq.tar" -C inventory/classes/recipes/rabbitmq --no-same-owner
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/valkey@sha256:a50d43cf9080b58040cf84909b0cd7497392709a23f5759e985bd912a9137ce5
mkdir -p inventory/classes/recipes/valkey
tar -xf "$tmp/valkey.tar" -C inventory/classes/recipes/valkey --no-same-owner
The first command fetches the recipe into inventory/classes/recipes/zulip, 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/zulip@sha256:bb1692b5063be69c03e73adbb3c22ced3f212afcf1c668d77955d61d871a66f0 \
--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/memcached@sha256:eeea246db03e3e6a06232d58e019b2ba44d21550396c8d8aeab0d3137b63a7b4 \
--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/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9 \
--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/rabbitmq@sha256:a10fd3bef0def7949b4b90e609dffc99ed16a6e188a743d7e9acab56525173aa \
--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/valkey@sha256:a50d43cf9080b58040cf84909b0cd7497392709a23f5759e985bd912a9137ce5 \
--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.
Settings
<c> stands for components.zulip.
| Chart value | Inventory |
|---|---|
image.tag |
<c>.image, tag and digest |
zulip.environment |
<c>.env, such as LOADBALANCER_IPS or SETTING_EMAIL_HOST |
zulip.persistence.size, 10Gi |
<c>.persistent_volume_claims.zulip-data.storage |
zulip.persistence.storageClass |
<c>.persistent_volume_claims.zulip-data.storage_class |
postSetup.scripts |
generators.kubernetes.configs.zulip-post-setup-scripts.data |
externalPostgresql.host, .database, .user |
applications.postgresql.cluster, .database and .owner, which postgresql’s README lists |
service.type |
<c>.service.type |
resources |
<c>.resources |
nodeSelector, tolerations, affinity |
<c>.node_selector, <c>.tolerations, <c>.affinity |
Zulip searches messages with PostgreSQL’s pg_catalog.english, because
the CloudNativePG image lacks the dictionaries of Zulip’s own PostgreSQL
image. The first migration logs a warning about it. The chart’s Ingress is
off by default and not part of the recipe.
Any list set in the target is appended to the recipe’s list. To replace
one, set the key to ~ in a class included after the recipe and the new
list in the target.
Proof
check zulip --level deploys with tests/values.yaml and
tests/target.yml, on kind for each Kubernetes minor CI tests, with
cnpg, cert-manager and rabbitmq-cluster-operator installed first.
tests/target.yml runs one PostgreSQL instance and one RabbitMQ node. The
objects of the included recipes are left out of the comparison and applied
with the recipe. The StatefulSet and the Service select on name, the
chart on app.kubernetes.io/name and instance, and both select the same
pods. The pod and the ServiceAccount set automountServiceAccountToken: true, the API default the chart leaves unset. The container ports are
sorted by name. The Secret zulip is only in the recipe, accepted as a
Referenced Secret: the chart’s container reads exactly its keys
secret-key and owner-password.
tests/smoke reaches Zulip through the Service zulip. The owner logs in,
and a member created with manage.py create_user and the owner each read
the other’s message in the channel general. Zulip’s queue workers
consume RabbitMQ, its cache holds items in memcached without SASL, and
manage.py check_redis reaches Valkey. A signup without an invitation and
a new organization without a creation link are refused. After the Zulip pod
and the PostgreSQL primary are deleted, the owner logs in again and reads
both messages.
Versions
1.0.0, :sha256:bb1692b5063be69c03e73adbb3c22ced3f212afcf1c668d77955d61d871a66f0
The recipe's files are in kadet-hub/recipes. All recipes