---
title: "Déployer Nodefony sur Kubernetes — les manifests"
source: docs/guides/kubernetes.md
url: https://nodefony.github.io/nodefony-core/docs/guides/kubernetes/
---


📍 [Documentation](../../) › [Guides](../) › **Kubernetes**

[docker-cloud-native](../docker-cloud-native/) explique le **modèle** — un process au premier plan,
l'orchestrateur au-dessus — et livre le Dockerfile. Cette page donne les manifests, et surtout les
quatre choses qu'on ne devine pas : l'identité du pod, le cloisonnement du bus, le moment des
migrations, et le délai d'arrêt.

## 🧠 Le modèle mental — ce que le cluster doit fournir

Le framework ne suppose rien de son environnement, et il ne le découvre pas non plus : tout ce dont
il a besoin lui est **déclaré**, par des variables. Trois familles, trois traitements dans un
cluster.

| Famille                                    | Exemple                                           | Où ça vit                           |
| ------------------------------------------ | ------------------------------------------------- | ----------------------------------- |
| Ce qui est **secret**                      | `NF_DATABASE_URL`, `NF_REALTIME_BACKPLANE_SECRET` | un `Secret`, monté en variables     |
| Ce qui est **public mais par déploiement** | `NF_REALTIME_BACKPLANE_NAMESPACE`, `NODE_ENV`     | un `ConfigMap`                      |
| Ce que **le cluster seul connaît**         | `NF_POD_NAME` (`reservedEnv.ts#RESERVED_ENV`)     | un `fieldRef` — jamais écrit en dur |

## 📖 Lexique

- **Backplane** — le bus qui relaie les messages temps réel entre pods. Sans lui, un message publié
  par un pod n'atteint que les clients connectés **à ce pod**.
- **Readiness** — l'état « je peux recevoir du trafic ». Distinct de « je suis vivant » : un pod qui
  vide ses connexions est vivant et indisponible.
- **fieldRef** — la façon dont un conteneur lit une information de son propre pod (son nom, son
  espace de noms) sans que personne ait à l'écrire.

## Qu'est-ce que ça résout — quatre pièges que le YAML par défaut ne voit pas

1. **Tous les pods portent la même identité** si `NF_POD_NAME` n'est pas câblé : l'origine des
   messages du backplane devient indistinguable, et un pod peut se réémettre à lui-même.
2. **Deux applications partagent un Redis** et se parlent sans le savoir, faute d'espace de noms.
3. **Les migrations partent en même temps que les pods**, et N exemplaires appliquent le même
   schéma en concurrence.
4. **Le délai d'arrêt est plus court que le drain**, et le déploiement coupe des requêtes en vol.

## 🚀 Démarrage rapide — le Deployment

```yaml
# deployment.yaml — un process = un pod
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mon-app
spec:
  replicas: 3
  selector:
    matchLabels: { app: mon-app }
  template:
    metadata:
      labels: { app: mon-app }
    spec:
      # > la durée du drain. Le drain mesuré est bien inférieur à la seconde,
      # mais c'est la requête la plus LENTE qui décide, pas la moyenne.
      terminationGracePeriodSeconds: 30
      containers:
        - name: app
          image: registry.example.com/mon-app:10.0.0
          ports: [{ containerPort: 5151 }]
          env:
            - name: NODE_ENV
              value: production
            # 🔴 L'identité d'origine du backplane. Sans elle, tous les pods
            # se présentent pareil sur le bus.
            - name: NF_POD_NAME
              valueFrom:
                fieldRef: { fieldPath: metadata.name }
          envFrom:
            - configMapRef: { name: mon-app-config }
            - secretRef: { name: mon-app-secrets }
          livenessProbe:
            httpGet: { path: /livez, port: 5151 }
            initialDelaySeconds: 10
          readinessProbe:
            httpGet: { path: /readyz, port: 5151 }
            periodSeconds: 5
          resources:
            requests: { cpu: 250m, memory: 256Mi }
            limits: { memory: 512Mi }
```

**`/livez` et `/readyz` ne se remplacent pas l'un l'autre.** `/livez` reste à `200` pendant l'arrêt :
un pod qui vide ses connexions n'est pas un pod mort, et le redémarrer casserait exactement ce qu'on
protège. `/readyz` bascule en `503` **dès le début de l'arrêt**, ce qui retire le pod du service
avant que la première connexion ne tombe. Les deux sont servies par un court-circuit total du
pipeline (`http-kernel.ts#HttpKernel.onConfigChanged`), monté **avant** la limitation de débit (`http-kernel.ts#HttpKernel.setErrorRenderer`) — un
kubelet qui reçoit un `429` croit le pod mort. Le détail vit dans
[servers](../../modules/http/servers/).

> **`initialDelaySeconds` ne se recopie pas.** Le boot est dominé par l'import et l'instanciation
> des modules de **votre** application : mesurez le vôtre. `NF_BOOT_TIMEOUT_MS` le borne — un module
> suspendu fait échouer le pod vite (`reservedEnv.ts#RESERVED_ENV`), au lieu de le laisser à moitié vivant.

## Secret et ConfigMap — ce qui va où

```yaml
apiVersion: v1
kind: Secret
metadata: { name: mon-app-secrets }
type: Opaque
stringData:
  # 🔴 Sans ces deux-là, le pod REFUSE de démarrer (code 78, la variable nommée) :
  # générés UNE fois, identiques pour tous les pods — jamais régénérés au déploiement.
  #   docker run --rm mon-app:1.0 node_modules/.bin/nodefony security:secrets --env
  NF_CSRF_SECRET: "…"
  NF_JWT_KEYSET: '{"active":"…","keys":[…]}'
  # Sans elles, le pod démarre, mais 2FA et webhooks sont désactivés (CRITIC au boot).
  NF_TOTP_KEY: "…"
  NF_WEBHOOK_KEY: "…"
  # Le compte `admin` créé au premier démarrage — sans lui, aucun compte.
  NF_ADMIN_PASSWORD: "…"
  # La base : le SCHÉMA de l'URL décide du dialecte (`infra.ts#parseDatabaseUrl`), et un schéma
  # inconnu fait échouer le boot — jamais de choix silencieux.
  NF_DATABASE_URL: "postgres://app:…@postgres:5432/app"
  NF_REDIS_URL: "redis://redis:6379"
  # Scelle les enveloppes du bus temps réel (`reservedEnv.ts#RESERVED_ENV`) : sans lui, tout
  # ce qui peut publier sur le Redis peut injecter un message qui ressortira chez
  # vos clients.
  NF_REALTIME_BACKPLANE_SECRET: "…"
---
apiVersion: v1
kind: ConfigMap
metadata: { name: mon-app-config }
data:
  NODE_ENV: production
  # 🔴 Ce qui CLOISONNE deux applications sur un même bus (`reservedEnv.ts#RESERVED_ENV`).
  # Deux déploiements qui partagent un Redis sans espaces de noms distincts se
  # voient mutuellement.
  NF_REALTIME_BACKPLANE_NAMESPACE: mon-app-prod
```

Les alias de plateforme `DATABASE_URL` et `REDIS_URL` sont acceptés — ce sont les noms qu'un
hébergeur pose lui-même —, mais la forme préfixée `NF_` gagne quand les deux sont présentes ; la
lecture est faite une seule fois, par `resolveInfra()` (`infra.ts#resolveInfra`). Le
détail des trois familles d'infrastructure et des profils qui en découlent vit dans
[persistence](../persistence/).

## Service et Ingress

```yaml
apiVersion: v1
kind: Service
metadata: { name: mon-app }
spec:
  selector: { app: mon-app }
  ports: [{ port: 80, targetPort: 5151 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mon-app
  annotations:
    # Le battement WebSocket est de 20 s : une inactivité plus courte tranche
    # des sockets VIVANTES. Viser au moins quatre battements.
    nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
spec:
  ingressClassName: nginx
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: mon-app, port: { number: 80 } } }
```

L'ingress est un reverse-proxy : la moitié applicative du contrat — `trustProxy`, `trustedHosts` —
reste à poser, et elle est décrite dans [reverse-proxy](../reverse-proxy/). Sans elle, toutes vos
IP clientes seront celles de l'ingress.

## Connexion Keycloak — le fournisseur d'identité dans le cluster

Quand Keycloak tourne dans le même cluster, l'application le joint par son **service interne**
(`http://keycloak.sso:8080`), et les navigateurs par son **adresse publique**
(`https://sso.example.com`). Les deux sont légitimes ; il faut juste le dire à chacun.

```yaml
apiVersion: v1
kind: Secret
metadata: { name: mon-app-keycloak }
type: Opaque
stringData:
  # L'émetteur : l'adresse PUBLIQUE du realm, celle que portent les jetons.
  NF_KEYCLOAK_ISSUER: https://sso.example.com/realms/mon-entreprise
  NF_KEYCLOAK_CLIENT_ID: mon-app
  NF_KEYCLOAK_CLIENT_SECRET: "…"
  # Où l'application LIT la configuration du realm : le service interne. Le
  # document doit toujours se déclarer de NF_KEYCLOAK_ISSUER, sinon refus.
  NF_KEYCLOAK_DISCOVERY_URL: http://keycloak.sso:8080/realms/mon-entreprise/.well-known/openid-configuration
  # L'adresse PUBLIQUE de l'application : Keycloak y renvoie l'utilisateur.
  NF_OAUTH_REDIRECT_BASE: https://app.example.com
```

Côté Keycloak, `KC_HOSTNAME=https://sso.example.com` (URL complète) et
`KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true` : le point de jeton et le jeu de clés suivent alors
l'adresse interne, et l'émetteur reste public. Le `http` n'est accepté que sur l'origine exacte de
`NF_KEYCLOAK_DISCOVERY_URL`, et seulement pour ces appels de serveur à serveur — le détail et ce
qui reste vérifié : [Keycloak › En cluster](../../modules/security/keycloak/#-en-cluster--joindre-keycloak-par-son-adresse-interne).

**Keycloak signé par l'autorité de l'entreprise ?** Monter le fichier PEM de l'autorité et le
déclarer — l'application le charge au démarrage, et refuse de démarrer s'il manque :

```yaml
# deployment.yaml (extrait)
env:
  - { name: NF_TLS_CA_FILE, value: /etc/nodefony/ca/autorite.pem }
volumeMounts:
  - { name: autorite, mountPath: /etc/nodefony/ca, readOnly: true }
volumes:
  - name: autorite
    configMap: { name: autorite-entreprise } # une autorité est publique : un ConfigMap suffit
```

## Les migrations passent AVANT les pods

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: mon-app-migrate
  annotations:
    # Le Job s'exécute avant que le déploiement ne soit mis à jour.
    "helm.sh/hook": pre-upgrade,pre-install
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/mon-app:10.0.0
          command: ["npx", "nodefony", "orm:migrate"]
          envFrom:
            - secretRef: { name: mon-app-secrets }
```

**Un Job, pas un `initContainer`.** Un `initContainer` s'exécute dans **chaque** pod : trois
exemplaires appliqueraient la même migration en concurrence. Le Job passe une fois, et le
déploiement ne démarre que s'il a réussi.

Et si un pod démarre avec un schéma en retard, **il retient sa mise en service de lui-même** au
lieu de servir des erreurs. Avec Drizzle, c'est le défaut en production (`migrations.check: "fail"`,
`config.ts#limitsSchema`) : le module compare la base à ses migrations, inscrit le contributeur
`drizzle:schema:<connecteur>` (`DrizzleService.ts#DrizzleService.#applySchemaPolicy`), et `/readyz` répond `503` tant que des
migrations restent à appliquer — `/livez` reste vert, l'ancien exemplaire continue de servir. La
sonde est rejouée toutes les 15 secondes : le pod devient disponible tout seul une fois le Job
passé, sans redéploiement. Hors production, le défaut est `warn` (journalisé, le trafic passe).

Pour un état que le framework ne connaît pas (un cache froid, un service tiers), le même mécanisme
s'appelle à la main : `kernel.setReadiness(nom, false, raison)` (`Kernel.ts#Kernel.setReadiness`). Ses règles
vivent dans [servers](../../modules/http/servers/).

## Mise à l'échelle

```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: mon-app }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: mon-app }
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
```

Deux conditions pour que l'ajout d'un pod serve à quelque chose :

- **les sessions sont partagées** — un stockage en mémoire déconnecte un utilisateur sur deux à
  chaque montée en charge ([session-storage](../session-storage/)) ;
- **le backplane est déclaré** — sinon un message publié n'atteint que les clients du pod qui l'a
  publié.

> ⚠️ **Le pool de connexions à la base n'est pas réglable aujourd'hui** : le pilote PostgreSQL est
> instancié avec ses défauts (`DrizzleOrm.ts#DrizzleOrm.#rawExec`), soit dix connexions par process. Dix pods
> demandent donc jusqu'à cent connexions, à confronter au `max_connections` du serveur avant de
> pousser `maxReplicas`.

## ⚠️ Pièges (symptôme → cause → correction)

| Symptôme                                                       | Cause                                                                 | Correction                                                              |
| -------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Le déploiement coupe des requêtes en vol                       | `terminationGracePeriodSeconds` plus court que le drain               | l'allonger — c'est la requête la plus lente qui décide, pas la moyenne  |
| Des messages temps réel arrivent en double, ou d'une autre app | espace de noms du backplane absent ou partagé entre deux déploiements | `NF_REALTIME_BACKPLANE_NAMESPACE` distinct par déploiement              |
| Les pods se présentent tous pareil sur le bus                  | `NF_POD_NAME` non câblé                                               | le `fieldRef` sur `metadata.name`                                       |
| Le pod redémarre en boucle pendant l'arrêt                     | la sonde de vivacité pointe `/readyz`                                 | `/livez` pour la vivacité, `/readyz` pour le service — jamais l'inverse |
| Le kubelet déclare le pod mort sous charge                     | les sondes se font limiter en débit                                   | rien à faire : elles sont montées **avant** la limitation               |
| Les migrations s'appliquent N fois                             | elles sont dans un `initContainer`                                    | un `Job`, en amont du déploiement                                       |
| Le pod démarre, sert, et rend des erreurs de colonne inconnue  | le schéma est en retard et rien ne retient la mise en service         | `kernel.setReadiness("schema", false, …)`                               |
| La base refuse des connexions quand le nombre de pods augmente | dix connexions par process, multipliées par le nombre d'exemplaires   | confronter `maxReplicas × 10` au `max_connections` du serveur           |
| Pas de bouton Keycloak, WARNING `fetch failed` au démarrage    | le pod ne joint pas l'adresse publique du realm                       | `NF_KEYCLOAK_DISCOVERY_URL` vers le service interne                     |
| Le pod refuse de démarrer (78) : `extraCaFiles … illisible`    | `NF_TLS_CA_FILE` désigne un fichier non monté                         | monter le ConfigMap de l'autorité au chemin déclaré                     |

## 🧪 Tests & couverture

Les chiffres exacts vivent dans la carte de l'aperçu, régénérée en comptant — jamais figés ici.

| Type | Où | Ce qui est prouvé |
| --- | --- | --- |
| Unitaires — signaux | `nodefony` `Cli.test.ts` | les signaux attachés, le second qui coupe court, les codes de sortie |
| Unitaires — grappe | `nodefony` `ClusterManager.test.ts`, `ClusterProbeAggregator.test.ts` | la supervision des ouvriers et l'agrégation des sondes |
| Intégration — CLI réelle | `nodefony` `CliIntegration.test.ts` | le démarrage effectif d'un processus |

> **Ce que rien ici ne prouve** : le comportement d'un cluster Kubernetes réel. Le contrat
> « SIGTERM → arrêt propre » est vérifié sur le processus ; ce que l'orchestrateur en fait dépend de
> vos sondes et de votre délai d'arrêt. Le banc qui s'en approche le plus est le banc multi-pods du
> dépôt, qui fait tourner plusieurs applications sur un bus Redis partagé — il éprouve le
> cloisonnement et le relais, pas l'ordonnanceur.

## 🔗 Pour aller plus loin

- ⬆️ **Retour au hub** : [Guides](../) · [Toute la documentation](../../)
- 🐳 **Le modèle, les signaux, le Dockerfile** : [`docker-cloud-native.md`](../docker-cloud-native/)
- 🛡️ **Ce que l'ingress doit poser, et ce que l'application doit croire** : [`reverse-proxy.md`](../reverse-proxy/)
- 🗄️ **Les trois familles d'infrastructure et les profils qui en découlent** : [`persistence.md`](../persistence/)
- 🔑 **Brancher Keycloak, adresse interne comprise** : [`keycloak.md`](../../modules/security/keycloak/)
- 🗝️ **Pourquoi les sessions décident de votre mise à l'échelle** : [`session-storage.md`](../session-storage/)
- 📖 [Lexique général](../../lexique/) du framework.
