refactor: make this a reusable deployment, not ours

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>
This commit is contained in:
Stéphane Tailland
2026-08-29 13:10:05 +02:00
parent c4747a6149
commit d68d20c6f4
18 changed files with 141 additions and 247 deletions
+71 -55
View File
@@ -1,79 +1,95 @@
# infra-n8n
n8n on the lab cluster: the runtime for the estate's autonomous agents,
`Approvisionnement` and `Recouvrement`, whose workflows live in
`classic-models-agent-flows`.
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 business-specific is here. This repo deploys the engine; the workflows
are imported into it.
**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.
## Layout
## 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 |
|---|---|
| `k8s/postgres/` | the database, a StatefulSet with its own volume claim |
| `k8s/n8n/` | the workload, its config and its data volume |
| `k8s/ingress/` | a cert-manager `Certificate` and the nginx `Ingress` |
| `k8s/secrets/` | the `ClusterSecretStore` for this estate and the `ExternalSecret` |
| `dev/` | the Flux plumbing that applies `k8s/`, and manages itself |
Reachable at **https://n8n.sttlab.local** once `192.168.139.101` (ingress-nginx)
resolves for that name.
| `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
They live in Vault at `secrets/n8n/instance`, read through the `vault-n8n`
store — an AppRole whose policy can read `n8n/*` and nothing else, so a
compromise here is not a way into the keycloak estate's secrets.
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.
## Bootstrap
Three manual applies, once. The Vault side is set up first — the commands are in
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`.
```
kubectl apply -f k8s/namespace.yaml
kubectl apply -f dev/gitrepository.yaml
kubectl apply -f dev/bootstrap-kustomization.yaml
```
After that everything reconciles from git, `dev/` included. Never `kubectl
apply` the objects under `k8s/` afterwards — Flux reverts them within its
interval and the cluster silently stops matching what you edited.
## Choices worth knowing
**One replica, and `strategy: Recreate`.** n8n splits 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 would deadlock — the new pod waiting for a volume the old one has
not released.
**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.
**The certificate is issued, not committed.** cert-manager writes `n8n-tls` from
the `sttlab-local-ca` ClusterIssuer. The private key stays in the cluster.
**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 CA certificate *is* committed**, in `k8s/ca-configmap.yaml`, and mounted
as `NODE_EXTRA_CA_CERTS`. It is a certificate, never a key, and checking it in
means a workflow can call `keycloak.sttlab.local` and the domain gateways
without anyone disabling verification to make it work. Regenerate on rotation —
it expires 2026-11-24.
**The certificate is issued, never committed.** The private key is generated in
the cluster and rotated by cert-manager.
**`N8N_HOST` and `WEBHOOK_URL` are the ingress host, not the Service.** They go
into the webhook URLs n8n hands out; advertised as the in-cluster name, they
would be URLs nothing outside can call.
## What this repo deliberately does not do
**Executions are pruned after two weeks.** The default keeps them for ever,
which turns the database into a log.
**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.
## What is deliberately not here
Authentication. n8n's own user management is left at its defaults, and the
instance is reachable to anyone who can resolve the host on the lab network.
Before it holds anything that matters, it needs either n8n's SSO against the
`classic-models` realm or an `openid-connect` filter at the ingress — the same
Keycloak the rest of the estate already uses.
**Backups.** The Postgres claim survives a delete of the workload; it does not
survive the cluster.