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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user