n8n on Kubernetes without Helm, with Kapitan
n8n 2.45.5, the workflow automation tool, in queue mode with one main instance and two workers, rendered by the kadet-hub kubernetes generator 1.14.0 from n8n’s Helm chart 1.18.0. The database is the included recipe postgresql, the queue the included recipe valkey.
- Latest version:
1.0.1, published - Source chart:
oci://ghcr.io/n8n-io/n8n-helm-chart/n8n:1.18.0@sha256:da0d216953b83656935ae31b7869437a796dd2ed68718b623ddbfa5346dbfc0d - 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 - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade F 129 findings, one per advisory and package, in the 1 image of version 1.0.1: CRITICAL 1, HIGH 43, MEDIUM 56, LOW 29, UNKNOWN 0. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.io/n8nio/n8n.
What it deploys
recipes.n8n(init.yml)recipes.n8n.main(main.yml):n8n-mainrecipes.n8n.worker(worker.yml):n8n-worker
Required parameters
applications.n8n.encryption_key is N8N_ENCRYPTION_KEY. n8n encrypts
the credentials of its workflows with it before it stores them in
PostgreSQL, so keep it for the life of the database. Any random string
works, such as one that openssl rand -base64 32 prints.
applications.valkey.password, which the included recipe valkey
requires, is the password of Valkey’s user default, with which n8n
connects to the queue. Set both as refs with the backend of your choice.
Without one of them the compile stops with
Interpolation key '<key>' not found. postgresql requires no parameter.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.n8n
parameters:
namespace: n8n
kapitan:
vars:
target: n8n
applications:
n8n:
encryption_key: <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.1:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/n8n:1.0.1@sha256:ea7c4597bf89cb94981a952aadc0c4f4d08066853d54b02bbcde477e87e05bf4
output_path: inventory/classes/recipes/n8n
Version 1.0.1 includes postgresql 1.1.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/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/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/n8n, 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/n8n@sha256:ea7c4597bf89cb94981a952aadc0c4f4d08066853d54b02bbcde477e87e05bf4 \
--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/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
<m> stands for components.n8n-main, <w> for components.n8n-worker
and <d> for applications.n8n.component_defaults, which both
components take.
| Chart value | Inventory |
|---|---|
image.tag |
<d>.image, tag and digest |
queueMode.workerReplicaCount |
<w>.replicas, 2 |
queueMode.workerConcurrency |
<w>.args.concurrency, --concurrency=10 |
secretRefs.env.N8N_HOST, N8N_PROTOCOL |
generators.kubernetes.secrets.n8n.string_data.N8N_HOST.value and .N8N_PROTOCOL.value, n8n.example.com and http |
service.port, secretRefs.env.N8N_PORT |
applications.n8n.port, "5678", a string |
config.timezone |
generators.kubernetes.configs.n8n.data.TZ.value, UTC |
database.database, database.user, database.host |
applications.postgresql.database, .owner and .cluster, which postgresql’s README lists |
executions.* |
<m>.env.EXECUTIONS_* |
webhook.url, editor.url, config.extraEnv |
N8N_WEBHOOK_URL, N8N_EDITOR_BASE_URL and other variables in <m>.env and <w>.env |
resources.main, resources.worker |
<d>.resources for both, or <m>.resources and <w>.resources |
nodeSelector, tolerations, affinity |
<d>.node_selector, <d>.tolerations, <d>.affinity |
The worker takes each variable of the chart from <m>.env by name, so a
change there reaches both Deployments. A variable added to <m>.env
reaches n8n-main only. Values are strings, so quote "true" and numbers.
The chart’s queue settings, QUEUE_* in the ConfigMap n8n, are below
generators.kubernetes.configs.n8n.data. n8n connects to PostgreSQL with
TLS and does not verify the server’s certificate, because CloudNativePG
signs it with its own CA. This is the chart’s database.ssl.enabled: true
with rejectUnauthorized: false, as tests/values.yaml sets them.
The pod annotations checksum/config and checksum/secret are the
chart’s hashes for the values of tests/values.yaml. They do not follow
the target’s values, so after changing the ConfigMap or the Secret restart
both Deployments with kubectl rollout restart. The ConfigMap’s
N8N_DEPLOYMENT_ARTIFACT is helm-chart/1.18.0, as the chart sets it for
n8n’s telemetry.
The ServiceAccount n8n of both Deployments may read every ConfigMap and
Secret of the namespace through the Role n8n, as the chart’s
rbac.create sets it up. Webhook processors, multi-main, task runners,
the Ingress, the HPA, KEDA, S3 storage and the NetworkPolicy are off by
default and not part of the recipe. Without task runners the chart still
renders the Secret n8n-task-runners with a random token that no
container reads. tests/values.yaml keeps it from rendering, and the
recipe has none.
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.
Left out
The chart’s Helm test runs only inside helm test, and tests/smoke
checks more on the installed recipe. It is not rendered:
Pod/n8n-test-connection.
Proof
check n8n --level deploys with tests/values.yaml and
tests/target.yml, on kind for each Kubernetes minor CI tests, with
cnpg installed first. The objects of postgresql and valkey are left
out of the comparison and applied with the recipe. The Deployments and the
Service select on name, the chart on app.kubernetes.io/name,
instance and component, and both select the same pods. The pods and
the ServiceAccount set automountServiceAccountToken: true, the API
default the chart leaves unset. tests/target.yml runs one PostgreSQL
instance.
tests/smoke waits for the Cluster and gets ok from /healthz and
/healthz/readiness of n8n-main through the Service, and from
/healthz/readiness of each worker pod, which needs its connections to
PostgreSQL and Valkey. It sets up the owner account through
/rest/owner/setup, logs in through /rest/login, activates a workflow
with a production webhook and calls it. In queue mode n8n-main queues the
execution in Valkey and a worker runs it, so the answer comes from a
worker. It then restarts n8n-main, whose home is an emptyDir, logs in
again and calls the webhook again, which needs the owner and the workflow
from PostgreSQL.
Versions
1.0.1, :sha256:ea7c4597bf89cb94981a952aadc0c4f4d08066853d54b02bbcde477e87e05bf41.0.0, :sha256:14eebc4a55b1564e64b3dadd2665e9c9c9f1e3409b42b01a170be1f3dbc863d7
The recipe's files are in kadet-hub/recipes. All recipes