Files
Stéphane Tailland ba11cc62eb Say where the string coercion has to happen
A ConfigMap's data values must be strings and this template cannot enforce it:
kustomize re-serialises before Flux substitutes, stripping quotes and !!str
alike, so a value that looks numeric or boolean parses as one and the apply
fails on the type. The quotes have to be inside the substituted value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:09:52 +02:00

6.1 KiB

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
EXECUTIONS_MAX_AGE_HOURS, BLOCK_ENV_ACCESS quote these inside the value: '"336"', '"false"'. They land in a ConfigMap, whose values must be strings, and the template cannot force it — kustomize strips quotes and !!str alike before substitution runs
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_PASSWORD
  • N8N_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.