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
-36
View File
@@ -1,36 +0,0 @@
# The lab CA, so n8n can verify keycloak.sttlab.local and the domain gateways
# rather than disabling verification. A CA certificate is public by
# construction — this is the certificate, never the key, and it is checked in so
# the deployment has no hidden prerequisite.
#
# Issued by the sttlab-local-ca ClusterIssuer, valid until 2026-11-24.
# Regenerate on rotation:
# kubectl -n cert-manager get secret sttlab-local-ca \
# -o jsonpath='{.data.tls\.crt}' | base64 -d
apiVersion: v1
kind: ConfigMap
metadata:
name: sttlab-ca
namespace: n8n
data:
ca.crt: |
-----BEGIN CERTIFICATE-----
MIIDPDCCAiSgAwIBAgIUd/utDf+f0yQR3v3qTjZqx+wvTZMwDQYJKoZIhvcNAQEL
BQAwGTEXMBUGA1UEAwwOKi5zdHRsYWIubG9jYWwwHhcNMjUxMTI0MTUzODQ1WhcN
MjYxMTI0MTUzODQ1WjAZMRcwFQYDVQQDDA4qLnN0dGxhYi5sb2NhbDCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALj0sryq8qXtkShT/B+/EjVJNN/TLHIn
QJYc6xuUH/f7q3dYdLm4iH0XnFgrSQLUo4iqd/Mczef5iWCYwT5FFa7P17/TcLVf
gso0j41qNLBjGxDQGQ6Z1VCxi6oan8ow98rumjVj0E0Qgs5CX/rN+gcEmZ/TZFWU
qudVoztpeiVqJ0dxCIq+KMutzd1BU7BXx4VRO9Y+t1idUc7oY7BPmS2qPAN7TtXP
j1KhH2BgLsmF5GRhYHNS578eCltcmS2sbQ3c7GwOV96lpgF9TixEvFXeN4xk43E6
1COUD7CRiWAZ0XI8FMlpZGeNZeV4bbV/MqVig1ybWNOz9MDKYj3eoXUCAwEAAaN8
MHowHQYDVR0OBBYEFDbzYWOPr4vxa+VONs7xHbcNx70YMB8GA1UdIwQYMBaAFDbz
YWOPr4vxa+VONs7xHbcNx70YMA8GA1UdEwEB/wQFMAMBAf8wJwYDVR0RBCAwHoIO
Ki5zdHRsYWIubG9jYWyCDHN0dGxhYi5sb2NhbDANBgkqhkiG9w0BAQsFAAOCAQEA
tU4p5/ONXm7x4YDzSeL8NdThVLguiFur0jcTxvvPUS4NFNsnQJk4OXxBgjy1YwpL
3i56/4mCEd9OCQF/Hl0eFwGZBzNKlMoTA08AC/lBDvPrnVjuUu0qe/b/2KOkhESa
x8rQNp0o8pcj0f29nu0JfRqYGkzy0Lh2qgXhPu2iC+t2PfdleRAky1tt68wogdFL
TvHDsJbVo/ihsPpONlSyHfxG6bb/0Mkz64PH46TTt9GvJflEKMqDwivNFUA1P5S/
U4Ic8Qapnxc7h2+WbRjmYrnuygC2wMBE6rL9OF3gwCr1fkgGGRr5lVRUq2X2HZV5
5jqViv6eurq8O61N3vWGCQ==
-----END CERTIFICATE-----
+7 -8
View File
@@ -1,17 +1,16 @@
# cert-manager issues this into the `n8n-tls` Secret, from the same
# ClusterIssuer as the rest of the estate.
# cert-manager issues this into the `n8n-tls` Secret.
#
# Deliberately not a copy of a certificate committed to git: the private key
# stays in the cluster, is rotated by cert-manager, and never reaches a repo.
# Deliberately not a certificate copied into the repo: the private key is
# generated in the cluster, rotated by cert-manager, and never reaches git.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: n8n-tls
namespace: n8n
namespace: ${NAMESPACE}
spec:
secretName: n8n-tls
dnsNames:
- n8n.sttlab.local
- ${N8N_HOSTNAME}
issuerRef:
name: sttlab-local-ca
kind: ClusterIssuer
name: ${TLS_ISSUER_NAME}
kind: ${TLS_ISSUER_KIND}
+5 -5
View File
@@ -2,24 +2,24 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: n8n
namespace: n8n
namespace: ${NAMESPACE}
annotations:
# The editor pushes execution progress over a long-lived connection, and
# imports can be large. Buffering it defeats the first; the default body
# size defeats the second.
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-body-size: ${INGRESS_BODY_SIZE}
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
spec:
ingressClassName: nginx
ingressClassName: ${INGRESS_CLASS}
tls:
- hosts:
- n8n.sttlab.local
- ${N8N_HOSTNAME}
secretName: n8n-tls
rules:
- host: n8n.sttlab.local
- host: ${N8N_HOSTNAME}
http:
paths:
- path: /
-2
View File
@@ -2,10 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- ca-configmap.yaml
- secrets/clustersecretstore.yaml
- secrets/externalsecret.yaml
- secrets/agent-externalsecret.yaml
- postgres/statefulset.yaml
- postgres/service.yaml
- n8n/pvc.yaml
+12 -16
View File
@@ -4,21 +4,21 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: n8n-config
namespace: n8n
namespace: ${NAMESPACE}
data:
GENERIC_TIMEZONE: Europe/Paris
TZ: Europe/Paris
GENERIC_TIMEZONE: ${TIMEZONE}
TZ: ${TIMEZONE}
NODE_ENV: production
# What n8n believes it is reachable at. It goes into the webhook URLs it
# generates and into the links it puts in its own UI, so it must be the
# ingress host and not the Service — a webhook advertised as `n8n` inside the
# cluster is a webhook nothing outside can call.
N8N_HOST: n8n.sttlab.local
N8N_HOST: ${N8N_HOSTNAME}
N8N_PORT: "5678"
N8N_PROTOCOL: https
WEBHOOK_URL: https://n8n.sttlab.local/
N8N_EDITOR_BASE_URL: https://n8n.sttlab.local/
WEBHOOK_URL: https://${N8N_HOSTNAME}/
N8N_EDITOR_BASE_URL: https://${N8N_HOSTNAME}/
# TLS terminates at the ingress, so n8n itself serves plain HTTP and must be
# told it is behind a proxy — otherwise it reads the hop's address instead of
@@ -28,18 +28,14 @@ data:
# The default is to keep every execution for ever, which turns the database
# into a log. Two weeks is long enough to debug a failed run.
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "336"
EXECUTIONS_DATA_MAX_AGE: ${EXECUTIONS_MAX_AGE_HOURS}
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
N8N_RUNNERS_ENABLED: "true"
# Lets a workflow read `$env.<NAME>`. n8n blocks this by default, which is the
# right default for an instance where anyone can write a workflow — here it is
# how an agent's client secret reaches the workflow that acts as that agent,
# without the secret being stored in the workflow JSON and committed.
#
# It does mean any workflow can read any variable in the pod. That is
# acceptable while the instance runs this estate's own agents and nothing
# else; it stops being acceptable the moment it is shared.
N8N_BLOCK_ENV_ACCESS_IN_NODE: "false"
# "false" lets a workflow read `$env.<NAME>`, which is how a secret reaches a
# workflow without being stored in the workflow JSON. n8n blocks it by
# default, and that default is right wherever more than one team can write a
# workflow: any workflow can then read any variable in the pod.
N8N_BLOCK_ENV_ACCESS_IN_NODE: ${BLOCK_ENV_ACCESS}
+19 -15
View File
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n
namespace: n8n
namespace: ${NAMESPACE}
spec:
# One replica. n8n's queue mode splits the editor from workers and needs Redis
# to do it; without that, a second replica is two schedulers racing over the
@@ -26,7 +26,7 @@ spec:
fsGroup: 1000
initContainers:
- name: wait-for-postgres
image: postgres:16.10-alpine
image: ${POSTGRES_IMAGE}
command:
- sh
- -c
@@ -36,7 +36,7 @@ spec:
done
containers:
- name: n8n
image: docker.n8n.io/n8nio/n8n:2.37.4
image: ${N8N_IMAGE}
ports:
- name: http
containerPort: 5678
@@ -64,24 +64,27 @@ spec:
secretKeyRef:
name: n8n-secrets
key: N8N_ENCRYPTION_KEY
# The lab CA, so a workflow can call keycloak.sttlab.local and the
# domain gateways without disabling verification. n8n is Node, hence
# this variable rather than a trust store.
# A private CA to trust, so a workflow can call an internal
# endpoint over TLS without anyone disabling verification to make it
# work. n8n is Node, hence this variable rather than a trust store.
# Provided by the deployment; an empty ConfigMap disables it in
# practice, since the file will not exist and Node falls back to its
# own bundle.
- name: NODE_EXTRA_CA_CERTS
value: /etc/sttlab-ca/ca.crt
value: /etc/extra-ca/ca.crt
envFrom:
- configMapRef:
name: n8n-config
# The agents' Keycloak client secrets, reached from a workflow as
# `$env.APPROVISIONNEMENT_CLIENT_SECRET`. A workflow acts as its
# agent, so it is the workflow that must be able to exchange tokens.
# Whatever else this deployment's workflows need in their
# environment. Provided by the deployment, not by this repo: an
# empty Secret of that name is a valid answer.
- secretRef:
name: n8n-agent-secrets
name: ${EXTRA_ENV_SECRET}
volumeMounts:
- name: data
mountPath: /home/node/.n8n
- name: sttlab-ca
mountPath: /etc/sttlab-ca
- name: extra-ca
mountPath: /etc/extra-ca
readOnly: true
readinessProbe:
httpGet:
@@ -110,6 +113,7 @@ spec:
- name: data
persistentVolumeClaim:
claimName: n8n-data
- name: sttlab-ca
- name: extra-ca
configMap:
name: sttlab-ca
name: ${EXTRA_CA_CONFIGMAP}
optional: true
+2 -2
View File
@@ -5,9 +5,9 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: n8n-data
namespace: n8n
namespace: ${NAMESPACE}
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 2Gi
storage: ${N8N_STORAGE}
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: n8n
namespace: n8n
namespace: ${NAMESPACE}
spec:
selector:
app: n8n
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: n8n
name: ${NAMESPACE}
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: n8n-postgres
namespace: n8n
namespace: ${NAMESPACE}
spec:
selector:
app: n8n-postgres
+3 -3
View File
@@ -6,7 +6,7 @@ apiVersion: apps/v1
kind: StatefulSet
metadata:
name: n8n-postgres
namespace: n8n
namespace: ${NAMESPACE}
spec:
serviceName: n8n-postgres
replicas: 1
@@ -22,7 +22,7 @@ spec:
fsGroup: 999
containers:
- name: postgres
image: postgres:16.10-alpine
image: ${POSTGRES_IMAGE}
ports:
- name: postgres
containerPort: 5432
@@ -68,4 +68,4 @@ spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
storage: ${POSTGRES_STORAGE}
-24
View File
@@ -1,24 +0,0 @@
# The agents' Keycloak client secrets, for the workflows that act as those
# agents. Read from the realm's own Vault entry through the keycloak store, not
# copied into `n8n/*`: there is one source for a client secret, and rotating it
# in Vault rotates it everywhere rather than leaving a stale twin behind.
#
# It is a narrow grant — this ExternalSecret names the properties it wants, so
# the n8n namespace receives those and nothing else of the realm.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: n8n-agent-secrets
namespace: n8n
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-keycloak
target:
name: n8n-agent-secrets
data:
- secretKey: APPROVISIONNEMENT_CLIENT_SECRET
remoteRef:
key: keycloak/classic-models-realm
property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT
+16 -17
View File
@@ -1,37 +1,36 @@
# One AppRole per estate, each with a policy that can read only its own path.
# The n8n store cannot read `keycloak/*` and the keycloak store cannot read
# `n8n/*` — so a compromised workload in one namespace does not become a way
# into the other's secrets.
# Reads this deployment's secrets from Vault. Scope the AppRole's policy to the
# path this store reads and nothing more, so a workload here cannot reach
# another estate's secrets.
#
# Its Vault side is created by hand, once:
# The Vault side is created once, outside this repo:
#
# vault policy write n8n-readonly - <<'HCL'
# path "secrets/data/n8n/*" { capabilities = ["read"] }
# path "secrets/metadata/n8n/*" { capabilities = ["list", "read"] }
# vault policy write <policy> - <<'HCL'
# path "<mount>/data/<path>/*" { capabilities = ["read"] }
# path "<mount>/metadata/<path>/*" { capabilities = ["list", "read"] }
# HCL
# vault write auth/approle/role/n8n-eso token_policies=n8n-readonly \
# vault write auth/approle/role/<role> token_policies=<policy> \
# token_ttl=1h token_max_ttl=4h
# kubectl -n external-secrets create secret generic vault-approle-n8n \
# --from-literal=roleId=$(vault read -field=role_id auth/approle/role/n8n-eso/role-id) \
# --from-literal=secretId=$(vault write -f -field=secret_id auth/approle/role/n8n-eso/secret-id)
# kubectl -n external-secrets create secret generic ${VAULT_APPROLE_SECRET} \
# --from-literal=roleId=$(vault read -field=role_id auth/approle/role/<role>/role-id) \
# --from-literal=secretId=$(vault write -f -field=secret_id auth/approle/role/<role>/secret-id)
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: vault-n8n
name: ${VAULT_STORE_NAME}
spec:
provider:
vault:
server: https://vault.sttlab.eu
path: secrets
server: ${VAULT_SERVER}
path: ${VAULT_MOUNT}
version: v2
auth:
appRole:
path: approle
roleRef:
name: vault-approle-n8n
name: ${VAULT_APPROLE_SECRET}
namespace: external-secrets
key: roleId
secretRef:
name: vault-approle-n8n
name: ${VAULT_APPROLE_SECRET}
namespace: external-secrets
key: secretId
+3 -3
View File
@@ -8,15 +8,15 @@ apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: n8n-secrets
namespace: n8n
namespace: ${NAMESPACE}
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-n8n
name: ${VAULT_STORE_NAME}
target:
name: n8n-secrets
creationPolicy: Owner
dataFrom:
- extract:
key: n8n/instance
key: ${VAULT_SECRET_PATH}