Files
infra-n8n/README.md
T
Stéphane Tailland e9d7504fcd Put every trigger endpoint behind an authentication subrequest
n8n's webhook, form and mcp endpoints carry no authentication of their own, and
for an automation platform those endpoints are the platform: whatever a
workflow reads or changes answers an unauthenticated request. The editor's
login protects the editor and nothing else.

N8N_WEBHOOK_AUTH_URL is required rather than optional — there is no way to
deploy this without naming an authenticator.

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

5.7 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
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 optional, so an unused slot needs no placeholder
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.