Node.js 项目管理环境变量应选择哪些工具和实践?
应该选择哪种方案?
对于这个中型 Node.js 项目,建议保留 dotenv 加载本地配置,选择 envalid 或 zod 负责启动时的校验和类型转换。staging、production 的配置与密钥由部署平台管理。
如果项目已经使用 zod,可以继续用它定义环境变量 schema;如果只需要校验环境变量,envalid 的专用 API 更直接。使用 zod 并不意味着方案符合所有项目的“最佳实践”,还需要落实配置加载顺序、启动失败处理和密钥管理。
为什么只有 dotenv 不够?
当前遇到的问题涉及几个不同的环节:
- 配置加载:从 .env 文件或部署环境取得值。
- 配置校验:检查必填项、允许值和格式,并转换类型。
- 环境隔离:决定每次部署使用哪组配置。
- 密钥管理:限制访问、阻止泄漏,并支持撤销和轮换。
环境变量通常以字符串形式提供。dotenv 负责加载这些值,不会自动将端口转换成数字,也不会为应用建立业务 schema。默认情况下,dotenv 不覆盖进程中已存在的变量,因此可以让部署平台注入的配置优先。dotenv 官方文档
dotenv-safe、envalid 和 zod 怎么选?
| 方案 | 主要能力 | 适用情况 |
|---|---|---|
| dotenv-safe | 根据 .env.example 检查必需变量是否存在,默认不允许空值 | 主要需求是防止漏配变量 |
| envalid | 提供环境变量专用校验器、类型转换、默认值和 TypeScript 类型推导 | 配置以字符串、端口、布尔值等常见类型为主 |
| zod | 用 schema 定义校验、转换规则,并推导 TypeScript 类型 | 已使用 zod,或需要自定义规则、字段间约束 |
dotenv-safe 检查加载后的环境,变量既可以来自文件,也可以由外部注入。它不提供完整的类型转换和业务 schema 校验。
envalid 的 cleanEnv() 可配合 str()、port()、bool() 等校验器使用,支持 default。默认错误处理会在 Node.js 中报告问题并退出。
zod 可以让运行时校验与 TypeScript 类型共用一份 schema。使用 safeParse() 可以自行控制启动失败时的日志和退出行为。
对于当前需求,单独使用 dotenv-safe 仍不足以解决类型校验问题。envalid 和 zod 通常选择一个作为统一校验入口即可。
如何用 zod 建立统一配置入口?
下面以已有的 TypeScript Node.js 工程为例。变量名称仅用于演示,应替换成应用实际需要的配置项。
安装依赖:
npm install dotenv zod
创建 src/config.ts:
import * as dotenv from 'dotenv';
import { z } from 'zod';
// ENV_FILE 由启动环境指定;未指定时只读取已有环境变量。
const envFile = process.env.ENV_FILE;
if (envFile !== undefined) {
if (envFile.trim() === '') {
throw new Error('ENV_FILE 不能为空');
}
const loaded = dotenv.config({
path: envFile,
override: false,
});
if (loaded.error) {
throw new Error('无法加载 ENV_FILE 指定的配置文件');
}
}
const envSchema = z.object({
APP_ENV: z.enum(['development', 'staging', 'production']),
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().min(1),
PORT: z.string()
.regex(/^[0-9]+$/)
.default('3000')
.transform(Number)
.pipe(z.number().int().min(1).max(65535)),
DEBUG: z.enum(['true', 'false'])
.default('false')
.transform(value => value === 'true'),
});
const result = envSchema.safeParse(process.env);
if (!result.success) {
const invalidKeys = [
...new Set(result.error.issues.map(issue => issue.path.join('.'))),
];
// 仅记录字段名称,避免把密钥值写入日志。
console.error('环境变量缺失或格式错误:', invalidKeys.join(', '));
process.exit(1);
}
export const env = Object.freeze(result.data);
export type Env = Readonly<z.infer<typeof envSchema>>;
业务模块统一导入 env,使用 env.PORT、env.DEBUG 等值,它们分别具有 number、boolean 类型。不要再从 process.env 重复读取这些配置,也不要仅通过 as string 或扩展 ProcessEnv 接口来声称它们已经通过校验。
示例先约束字符串,再进行转换,以避免直接强制转换带来的意外结果。例如,z.coerce.boolean() 使用 JavaScript 的 Boolean(input) 语义,字符串 "false" 会得到 true;这里明确只接受 "true" 和 "false"。Zod schema API
示例中的 DATABASE_URL 仅检查非空。具体数据库协议和连接参数是否合法,需要根据实际数据库继续增加规则。通过配置校验也不代表数据库一定能够连接。
应在监听端口、建立数据库连接或启动后台任务之前完成校验。依赖配置的业务模块应直接导入这个配置模块,避免模块加载时提前读取环境变量。
development、staging 和 production 如何分离?
可以采用以下约定:
| 环境 | 配置来源 | 环境标识示例 |
|---|---|---|
| 本地开发 | 使用 ENV_FILE 显式选择 .env.local | APP_ENV=development,NODE_ENV=development |
| staging | 部署平台注入独立配置和密钥 | APP_ENV=staging,NODE_ENV=production |
| production | 部署平台注入生产配置和密钥 | APP_ENV=production,NODE_ENV=production |
这里的 APP_ENV 是应用自行约定的部署标识。如果 staging 环境需要模拟生产运行方式,可以使用 NODE_ENV=production,再通过 APP_ENV 区分部署位置。
上述代码不会自动加载 .env。本地开发时,需要在启动环境中设置 ENV_FILE=.env.local。路径默认相对于进程工作目录,团队应统一启动位置。
如果仍使用多个配置文件,应明确选择目标文件并记录覆盖优先级,避免生产配置缺失时悄悄回退到开发配置。staging 与 production 也应使用各自的凭据和访问权限。
默认值适用于端口、重试次数等允许缺省的参数。数据库密码、API 密钥等必需凭据缺失时,应阻止启动。
如何防止密钥再次进入 Git?
在仓库中保留不含真实凭据的 .env.example,供开发者了解必需配置:
APP_ENV=development
NODE_ENV=development
PORT=3000
DEBUG=false
# 在实际运行环境中填写,不能留空
DATABASE_URL=
同时配置 .gitignore:
.env
.env.*
!.env.example
还需要处理已有泄漏并做好提交防护:
- 如果真实密钥已经提交,应先撤销或轮换。仅删除文件或新增忽略规则无法消除泄漏影响。
.gitignore不会停止跟踪已纳入 Git 的文件,需要另行移出跟踪范围,并按团队流程处理历史记录。- 在提交检查和 CI 中加入密钥扫描,降低误提交概率。
- staging、production 的密钥由部署平台的密钥功能或专用密钥管理服务提供,避免打包进镜像或提交到仓库。
校验库负责判断配置能否被应用使用;密钥的存储、分发和轮换需要由部署流程落实。
备注:内容仅供参考。
