optional: true covers a Secret that does not exist; it does not cover the variable being unset. Flux substitutes in strict mode, so the unused second slot failed the whole build and every change to this repo since has silently stopped reaching the cluster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
infra-n8n
A reusable n8n deployment: Postgres, the workload, TLS and an ingress, with
every environment value left as a ${VAR} for the caller to substitute.
Nothing here is specific to a deployment. No hostname, no issuer, no Vault
path, no image tag. A consumer supplies those through its own Flux
Kustomization and its postBuild.substitute block — the same shape used for
any templated estate. This repo is the template; it is not itself deployed.
Using it
Create, in the repo that owns the deployment:
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata: { name: infra-n8n, namespace: <ns> }
spec:
interval: 5m
url: <this repo>
ref: { branch: main }
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: { name: n8n, namespace: <ns> }
spec:
interval: 10m
sourceRef: { kind: GitRepository, name: infra-n8n }
path: ./k8s
prune: true
wait: true
postBuild:
substitute:
NAMESPACE: <ns>
# … every variable in the table below
The namespace must exist first, and so must the AppRole Secret the store reads.
Variables
| Variable | What it is |
|---|---|
NAMESPACE |
where everything lands |
N8N_HOSTNAME |
the public host. It goes into WEBHOOK_URL and the editor's links, so it must be what callers outside the cluster use — an in-cluster name here produces webhooks nothing can call |
N8N_IMAGE, POSTGRES_IMAGE |
pinned images, tag included |
N8N_STORAGE, POSTGRES_STORAGE |
claim sizes, e.g. 2Gi, 5Gi |
TIMEZONE |
e.g. Europe/Paris; drives cron triggers |
INGRESS_CLASS, INGRESS_BODY_SIZE |
e.g. nginx, "50m" |
N8N_WEBHOOK_AUTH_URL |
an endpoint that answers 2xx for an authorised caller and 401/403 otherwise. Required — n8n's trigger endpoints carry no authentication of their own, so without this anyone who can reach the host can run any workflow. An OIDC provider's userinfo works as-is |
TLS_ISSUER_NAME, TLS_ISSUER_KIND |
a cert-manager issuer, e.g. ClusterIssuer |
VAULT_SERVER, VAULT_MOUNT, VAULT_SECRET_PATH |
where the two secrets live |
VAULT_STORE_NAME, VAULT_APPROLE_SECRET |
the ClusterSecretStore this creates, and the AppRole Secret in external-secrets it authenticates with |
EXTRA_ENV_SECRET, EXTRA_ENV_SECRET_2 |
Secrets whose keys become environment variables for workflows. Two slots, because a deployment's environment usually comes from more than one owner. Both carry defaults and are optional, so an unused slot needs neither a variable nor a Secret. Substitution runs in strict mode — without the default, leaving one unset fails the whole build |
EXTRA_CA_CONFIGMAP |
a ConfigMap with ca.crt, mounted and set as NODE_EXTRA_CA_CERTS, so workflows can call internal TLS endpoints without verification being disabled. Marked optional, so an absent one is not an error |
BLOCK_ENV_ACCESS |
"true" blocks $env in workflow expressions. n8n's default, and the right one wherever more than one team writes workflows: any workflow can otherwise read any variable in the pod. "false" is how a secret reaches a workflow without being committed in its JSON |
EXECUTIONS_MAX_AGE_HOURS |
how long executions are kept. n8n keeps them for ever by default, which turns the database into a log |
Two secrets, both of which must outlive any pod
Read from VAULT_SECRET_PATH as a whole, so the entry must hold exactly:
DB_PASSWORDN8N_ENCRYPTION_KEY— encrypts the credentials n8n stores in its database. Never regenerate it. A new key does not re-encrypt what is stored; it orphans it. The workflows survive, every credential in them does not.
The AppRole's policy should reach VAULT_SECRET_PATH and nothing else, so a
workload here cannot read another deployment's secrets. The commands are in
k8s/secrets/clustersecretstore.yaml.
Choices worth knowing
One replica, and strategy: Recreate. n8n separates editor from workers
only in queue mode, which needs Redis; without it a second replica is two
schedulers racing over the same cron triggers. And the data volume is
ReadWriteOnce, so a rolling update deadlocks — the new pod waiting for a volume
the old one has not released.
Postgres is a StatefulSet with a volumeClaimTemplate. The claim is not
deleted with the workload, so losing the database to a kubectl delete takes
more than one mistake.
The certificate is issued, never committed. The private key is generated in the cluster and rotated by cert-manager.
What this repo deliberately does not do
Authentication. n8n's own user management is left at its defaults. A fresh instance is unclaimed: anyone who can reach it can create the owner account. Claim it immediately, and put it behind an identity-aware proxy or n8n's SSO before it holds anything that matters.
Backups. The Postgres claim survives a delete of the workload; it does not survive the cluster.
Webhooks are not open
n8n serves a workflow's trigger endpoints — /webhook, /form, /mcp and
their test and waiting variants — on the same host as the editor, and they
carry no authentication of their own. For an automation platform those
endpoints are the platform: whatever a workflow reads or changes is reachable
by an unauthenticated request unless something in front says otherwise. The
editor's login protects the editor, nothing else.
So they are served by a second Ingress that puts an authentication subrequest in
front of every one of them, via ${N8N_WEBHOOK_AUTH_URL}. There is deliberately
no way to deploy this without naming one.
Callers inside the cluster reach the Service directly and never traverse the Ingress, so a facade or a gateway in front of a workflow keeps working while the outside door is shut.
If you rename an endpoint with N8N_ENDPOINT_WEBHOOK and friends, rename it in
k8s/ingress/ingress-webhooks.yaml too. A prefix left out of that list is the
hole all over again.