Every environment value is a ${VAR} for the caller to substitute: hostname,
images, storage, ingress class, TLS issuer, Vault server and path, timezone,
retention. Nothing in k8s/ names a host, a realm, an agent or an estate.
Removed: the Flux plumbing under dev/, which described one deployment, and the
ExternalSecret pulling a specific agent's Keycloak client secret. Both belong to
whoever deploys this. The lab CA ConfigMap goes with them — the mount stays,
parameterised and marked optional, because 'trust a private CA' is a property of
an environment and not of n8n.
The README is now the template's: how to consume it, what every variable means,
and the three things it deliberately does not do.
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" |
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 |
a Secret whose keys become environment variables for workflows. Optional in intent — supply an empty Secret if there is nothing |
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.