Files
infra-n8n/README.md
T
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

119 lines
6.1 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 |
| `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.