Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Environnement — un seul fichier .env, les variables, et qui gagne

stablenodefony (cœur)mis à jour 2026-10-05

📍 Documentation › Cœur — @nodefony/core › Environnement

Une variable d’environnement qui « ne prend pas » est le bug le plus long à comprendre du métier : rien n’échoue, rien ne s’affiche, la valeur est simplement ignorée et un défaut s’applique en silence. Cette page dit où poser une valeur, qui l’emporte, et surtout comment le vérifier au lieu de le supposer.

🧭 Démarrage rapide#

nodefony env          # chaque variable, sa valeur EFFECTIVE et sa PROVENANCE
nodefony env --json   # le même rapport, pour un script ou un agent

La commande ne démarre rien (cli/env.ts#readLevels) — elle répond même sur une application qui ne boote plus, ce qui est précisément le moment où on la lance. Elle sort en 78 (EX_CONFIG) si une variable requise manque, pour qu’un script s’arrête là plutôt que de tenter un démarrage voué à l’échec.

Ce qu’elle montre, et qu’aucune lecture de fichier ne donne :

Pourquoi ces deux dernières listes sont séparées. Le framework écrit des NF_* dans l’environnement sans que vous les ayez demandées : le lanceur du CLI marque sa délégation au nodefony du projet (NF_CLI_DELEGATED), les commandes de démarrage inscrivent le mode (NF_MODE_START), le maître de grappe signale la grappe (NF_CLUSTER), la déclaration MCP porte son jeton (NF_MCP_TOKEN). Rangées parmi les inconnues, elles vous accusaient d’une faute de frappe que vous n’aviez pas commise — et proposaient de « corriger » NF_CLI_DELEGATED en NF_ADMIN_PASSWORD. Elles ne sont pas tues pour autant : une variable présente que le rapport passe sous silence vous ferait chercher pourquoi votre environnement ne ressemble pas à ce qu’il montre.

Le modèle : deux fichiers, une question, un seul lecteur#

Trois décisions gouvernent tout ce qui suit, et rien d’autre n’est à retenir (ADR-0014).

Une question décide où va une valeur : qui la fournit ? Le framework (un défaut : rien à poser), le poste (.env), l’exploitation (l’orchestrateur ou le gestionnaire de secrets), le code (nodefony.config.ts, pour un réglage non secret qui dépend du mode).

Deux fichiers, pas un de plus : .env porte les valeurs du POSTE et ne se commite jamais ; .env.example est sa notice, commitée et jamais chargée. C’est la convention de Node — dotenv, node --env-file, le .gitignore Node de GitHub. La production n’a aucun fichier.

Un seul lecteur de process.env : env.ts. Tout le reste de l’application lit un objet typé, validé au démarrage. Une variable non déclarée là n’existe pas, quoi qu’en dise .env — c’est ce qui rend une faute de frappe muette, et c’est pourquoi nodefony env existe.

Les deux axes : mode et déploiement#

Nodefony sépare ce que la plupart des frameworks confondent :

Axe Variable Valeurs Ce qu’il décide
Mode NODE_ENV development / production comment le code s’exécute (optimisations, journaux)
Déploiement APP_ENV / NF_ENV chaîne libre : staging, canary… où il s’exécute (quelle base, quels secrets)

Un staging tourne en mode production : ce sont deux questions différentes, et les mélanger oblige à choisir entre « optimisé » et « pointe la bonne base ». Aucun des deux ne choisit un fichier : le déploiement reçoit SES valeurs de son orchestrateur, et le mode se lit dans nodefony.config.ts (ctx.isProd) ou dans requiredIn (ci-dessous).

Et si NODE_ENV n’est pas posé ?#

C’est le cas de tous les jours sur une machine de développement — et la réponse n’est pas « au hasard ». Poser NODE_ENV est un acte de déploiement ; ne rien poser est l’état d’un poste de développement.

NODE_ENV Mode retenu Pourquoi
development / dev development déclaré
production / prod production déclaré
absent development personne n’a rien dit → poste de développement
posé mais autre (staging, canary, prod-eu…) production un DÉPLOIEMENT est nommé — il tourne comme prod
chaîne vide production choix conservateur : « vidée » ≠ « jamais posée »

Une chaîne vide compte comme posée : on ne distingue pas « vidée par erreur » de « vidée exprès », et se tromper vers la production ne coûte qu’une commande utilitaire, là où l’inverse exposerait la console d’administration d’un serveur.

Le défaut ne gouverne jamais un serveur. nodefony development, nodefony production (alias start, prod) et nodefony cluster posent leur mode eux-mêmes, et il n’existe pas d’autre façon d’en démarrer un. Le défaut ne concerne donc que les commandes utilitaires — inspect, doctor, env, security:*.

⚠️ Le piège à connaître : une commande utilitaire ne tourne PAS dans le mode du serveur que vous avez lancé. Chacune démarre son propre noyau. Si votre serveur tourne par nodefony development mais que NODE_ENV n’est pas dans votre shell, une commande lancée à côté partira bien en development — mais le jour où vous exportez NODE_ENV=production pour un essai, elle changera de base de données sans rien dire d’autre. Demandez le mode plutôt que de le supposer :

npx nodefony env                 # le mode, et d'où vient chaque variable
npx nodefony inspect routes      # la dernière ligne indique l'environnement

Pour forcer explicitement, préfixez la commande : NODE_ENV=production npx nodefony ….

Les fichiers — qui gagne#

Source Commité ? Lu au démarrage Rôle
process.env — oui shell, orchestrateur, k8s — gagne toujours
.env ❌ non oui valeurs du POSTE, secrets de développement compris
.env.example ✅ oui jamais la notice : toutes les variables, commentées, sans valeur

Il n’y a pas de troisième rang. L’injection (loadEnv.ts#loadEnv) n’écrase jamais une clé déjà posée : une variable exportée par le shell ou l’orchestrateur ne peut être contredite par .env. Le nom du fichier vit à un seul endroit, envFileOrder (loadEnv.ts#envFileOrder) — celui que nodefony env affiche.

Le chargement a lieu une fois, au démarrage du binaire, avant la construction du noyau : les configurations de modules lisent process.env pendant le boot, il doit donc être peuplé avant elles.

En production, pas de fichier. .env n’entre ni dans le dépôt ni dans l’image (le .gitignore et le .dockerignore générés l’écartent, nodefony image:check le refuse) : les secrets viennent de l’orchestrateur, les réglages non secrets de nodefony.config.ts.

Un module n’a pas de .env. Un process sert une application, donc un environnement : celui de la racine. Un module reçoit ses valeurs par sa configuration (use()), par NF__<MODULE>__… (ci-dessous) ou par une variable du env.ts de l’application. Seule exception, qui n’est pas la nôtre : les .env.* d’un front Vite (frontend/.env.production), lus par Vite lui-même pour des variables VITE_ publiques.

Les fichiers de l’ancienne convention sont refusés#

.env.local, .env.development, .env.production, .env.<APP_ENV> et leurs .local ne sont plus lus. S’ils restent dans une application, le démarrage s’arrête (code 78) en les nommant (loadEnv.ts#findLegacyEnvFiles) — les ignorer en silence ferait disparaître leurs variables. Le geste : recopier leurs lignes actives dans .env, puis les supprimer ; un réglage de production non secret va dans nodefony.config.ts, un secret de production dans le gestionnaire de secrets.

Un coéquipier qui clone#

.env n’est pas dans le dépôt :

cp .env.example .env                       # puis décommenter ce qu'il te faut
npx nodefony security:secrets --write      # tes propres clés de chiffrement

Déclarer une variable — env.ts#

env.ts est le seul endroit du projet qui lit process.env (defineEnv.ts#BaseOpts.requiredIn). Une variable non déclarée là n’existe pas pour l’application, quoi qu’un fichier .env en dise.

import { defineEnv, envNumber, envEnum, envString } from "nodefony";

export const env = defineEnv({
  NF_PORT: envNumber({ default: 5151, description: "Port HTTP." }),
  NF_LOG_DRIVER: envEnum(["stdout", "file", "null"], {
    default: "stdout",
    description: "Destination des journaux.",
  }),
  NF_DATABASE_URL: envString({
    optional: true,
    description: "URL de la base.",
  }),
});

Ce que la déclaration apporte, et qu’une lecture directe de process.env ne donne pas : la valeur est typée (number, boolean, énumération), validée au démarrage (une valeur hors énumération échoue tout de suite, avec le nom de la variable), documentée (description alimente .env.example et nodefony env), et atteignable typée dans la configuration via ctx.env.

Une variable requise est celle qui n’a ni défaut ni optional: true. nodefony env les nomme, et sort en erreur si l’une manque.

Lire la notice .env.example#

La notice est un fichier commité, produit par une commande — rien ne la régénère tout seul :

npx nodefony env --example            # réécrit .env.example depuis env.ts
npx nodefony env --example --check    # vérifie sans écrire ; sortie 78 si elle a dérivé

Une application générée lance le contrôle dans npm run verify (script env:check), donc en CI : une variable ajoutée à env.ts sans régénérer la notice y est refusée.

Chaque variable s’y présente de la même façon, générée depuis env.ts : un bandeau avec son titre, une explication en phrases simples (à quoi elle sert, ce qui se passe sans elle), puis ses métadonnées, au format des décorateurs de la spécification @env-spec — une par ligne, @nom ou @nom=valeur. Deux lignes vides séparent deux variables :

# ─── Base de données ────────────────────────────────────────────────────────
#
# L'adresse de ta base de données, en une seule URL : son début dit de quelle
# base il s'agit (sqlite:, postgres://, mysql://, mongodb://).
#
# Sans elle, l'application utilise une base SQLite dans var/databases/ — rien
# à installer, et les données survivent au redémarrage.
#
# @optional
# @placement=secrets
# @default=aucun
# @example=postgres://app:motdepasse@localhost:5432/app
# NF_DATABASE_URL=
Décorateur Ce qu’il dit
@required / @optional faut-il la poser ?
@required=forEnv(production) obligatoire là-bas seulement (requiredIn)
@requiredWhen="…" exigée par le CODE sous condition (requiredWhen)
@sensitive un secret : jamais dans git, jamais de valeur d’exemple
@placement toujours présent — où la poser en production
@type=enum(a, b) les seules valeurs admises
@default toujours présent — ce qui s’applique si on ne pose rien
@example une valeur réaliste, pour voir la forme attendue

@default vaut le défaut déclaré, sinon defaultNote quand c’est le CODE qui applique un défaut (le catalogue ne le connaît pas), sinon aucun. Une valeur avec des espaces est entre guillemets : toute ligne de métadonnée se lit d’une seule expression régulière, ^# @(\w+)(?:=(.*))?$.

Les variables sont rangées par sections numérotées — leur bandeau est un séparateur au sens d’@env-spec (une ligne # ===), la spec n’ayant pas de décorateur de section — (« Réseau et processus », « Base de données et cache », « Connexion Keycloak »…), annoncées par un sommaire en tête du fichier qui liste, pour chaque section, les noms qu’elle contient : on trouve une variable par son thème comme par Ctrl+F. Une section apparaît à la place de sa première variable dans env.ts ; une variable sans section ferme la marche sous « Autres réglages ».

@sensitive vient de sensitive quand la variable le déclare, sinon de son nom (isSensitiveEnvVar, la même règle que nodefony env) : déclare-le quand le nom trompe — NF_KEYCLOAK_ISSUER contient « key » et n’a rien de secret.

@placement dit où la valeur se pose en production — sur le poste, tout va dans .env :

Valeur Où
platform les variables du déploiement, fournies par l’hébergeur ou l’orchestrateur
secrets le gestionnaire de secrets (Secret Kubernetes, Vault…), en variable ou en fichier monté (<NOM>_FILE)
workstation le poste seulement : un réglage de développement ou de diagnostic

Il vient de placement quand la variable le déclare, sinon il se déduit (envVarPlacement) : un secret va au gestionnaire de secrets, le reste aux variables du déploiement. Déclare-le quand la déduction se trompe — une URL de base de données porte un mot de passe que son nom ne dit pas.

@requiredWhen couvre l’exigence que ni optional ni requiredIn ne savent dire : celle qu’un module impose sous condition. Sans clé de signature, une application qui émet des jetons refuse de démarrer en production ; une application qui n’en émet pas démarre très bien. La déclarer requiredIn: ["production"] exigerait la clé de qui n’en a pas l’usage. requiredWhen ne contrôle rien — c’est le module qui refuse — mais la notice cesse de dire « optionnelle » :

NF_JWT_KEYSET: envString({
  optional: true,
  requiredWhen:
    "en production, si l'application émet des jetons — sinon refus de démarrer",
}),

La notice se termine par les réglages du framework — les variables que Nodefony lit lui-même et qu’on peut poser (nombre de workers, délai de démarrage, pilote du backplane…), en deux sections : « Framework : déploiement » et « Framework : poste et diagnostic ». Ils viennent de la liste des variables réservées du CŒUR (config/reservedEnv.ts) ; seuls ceux des modules que déclare le manifeste apparaissent, et une variable que env.ts déclare garde sa propre déclaration.

Suivent les réglages des modules : les noms courts que chaque module déclare sur un champ de son schéma (.meta({ env: "NF_REDIS_HOST" })), une section par module. Le schéma peut aussi dire où la variable se pose (.meta({ env: "NF_MONGODB_DEBUG", placement: "workstation" })) ; sinon la même règle que pour l’application s’applique. Ils ne sont recopiés nulle part : la notice les lit dans le schéma, que le module désigne dans son package.json ("nodefony": { "configSchema": "./dist/…" }). Une application neuve les reçoit après l’installation des paquets, create app relançant nodefony env --example à ce moment-là.

Le texte vient de la déclaration : section, title (le bandeau), description (un \n sépare deux paragraphes, le repli à 78 colonnes est automatique ; une ligne qui commence par des espaces est recopiée telle quelle), example, defaultNote, placement, requiredWhen. Écrire pour quelqu’un qui découvre le projet : à quoi sert la variable, ce qui se passe si on ne la pose pas, où trouver sa valeur.

NF_ADMIN_PASSWORD: envString({
  optional: true,
  section: "Comptes créés au démarrage",
  title: "Mot de passe administrateur",
  description:
    "Le compte « admin » est créé au premier démarrage avec ce mot de passe. " +
    "En production, sans cette variable, aucun compte n'est créé.",
  defaultNote: "secret-de-dev-42 en développement ; aucun en production",
}),

Requise LÀ-BAS seulement — requiredIn#

Certaines variables ne sont indispensables qu’en production. Les déclarer optional est vrai sur le poste du développeur et faux là où ça compte ; les déclarer requises empêcherait de démarrer en local. requiredIn nomme les environnements où l’absence devient une faute :

NF_CSRF_SECRET: envString({
  optional: true,
  requiredIn: ["production"],
  description: "Secret des jetons anti-CSRF — partagé entre process en cluster.",
}),

Le cas qui fonde cette règle n’est pas un secret laissé en dur, c’est l’inverse : un secret absent est engendré à la volée. Rien ne va mal au premier démarrage — c’est au deuxième exemplaire que les jetons émis par l’un se font refuser par l’autre, sans une ligne dans les journaux.

Les noms sont libres : ils se comparent aux étiquettes de l’environnement courant, à savoir le mode d’exécution (NODE_ENV) et l’environnement de déploiement (NF_ENV, ou l’alias de plateforme APP_ENV) quand il en diffère. Une préproduction qui tourne en production porte donc les deux, et requiredIn: ["preprod"] y mord.

Trois lecteurs appliquent la MÊME règle (isEnvVarRequired) : le démarrage refuse de partir, nodefony env marque la variable, et nodefony doctor la nomme avant qu’on déploie.

Demander ce qui manquera ailleurs — --env#

nodefony doctor --env production et nodefony env --env production évaluent les exigences pour l’environnement visé, avec les valeurs présentes ici : on ne simule pas un déploiement, on demande ce qui manquera là-bas. Le rapport l’annonce en tête, et sort en erreur si une variable requise à destination n’a aucune valeur.

doctor signale aussi un .env suivi par git — l’historique garde les secrets même après suppression. Sans dépôt git, il ne conclut pas : il énonce le contrôle comme non fait.

Secrets : <VARIABLE>_FILE#

Un secret monté par Docker ou Kubernetes est un fichier, pas une valeur. Toute variable accepte donc la forme <VARIABLE>_FILE, qui pointe le fichier à lire :

NF_TOTP_KEY_FILE=/run/secrets/totp_key    # au lieu de NF_TOTP_KEY=…

Poser les deux échoue au démarrage : entre deux sources contradictoires, deviner serait le pire service. Les valeurs des variables dont le nom évoque un secret ne sont jamais rendues en clair par nodefony env — seulement leur présence, leur longueur et leur provenance.

Surcharger une clé de module — NF__#

Deux mécanismes coexistent, et les confondre est l’erreur la plus fréquente :

Forme Ce que c’est Où c’est déclaré
NF_PORT=5151 variable de l’application, typée et validée env.ts — non déclarée = sans effet
NF__HTTP__SERVERS__HTTPS__PORT=8443 surcharge directe d’une clé de module rien à déclarer — __ sépare les segments

Le second (envOverride.ts#coerceEnvValue) vise une clé de configuration par son chemin, sans passer par env.ts : NF__<MODULE>__<CHEMIN…>. Une liste s’écrit en valeurs séparées par des virgules. Un segment mal orthographié est signalé au démarrage avec la clé la plus proche — le « vouliez-vous dire » de git.

Réserve ce mécanisme à ce qu’il fait bien : régler une brique en exploitation sans toucher au code. Ce que l’application possède en propre se déclare dans env.ts.

🧪 Tests#

Le calcul du rapport est un module pur (envReport.ts#collectUnknown) : il reçoit les fichiers déjà lus et l’environnement effectif, et conclut. Cette séparation est ce qui rend éprouvables les trois affirmations sur lesquelles on va se fier pour corriger une configuration — d’où vient une valeur, ce qui est masqué, ce qui n’a aucun effet. Se tromper sur l’une d’elles est pire que de ne rien afficher : on croit le rapport, et on cherche ailleurs.

⚠️ Pièges#

📖 Lexique#

🔗 Pour aller plus loin#