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

118 lines
5.7 KiB
Markdown

# 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:
```yaml
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.