.env 的真实定位
.env 文件是十二要素应用里「配置来自环境」的落地形态:代码不读文件,只读 `process.env`,由外部注入。.env 只是本地开发时的便利层——用 dotenv 之类的库在启动时把文件读进环境。它绝不能进版本库,因为一旦提交,密钥就永久留在 Git 历史里,删文件也删不掉。
社区里常见做法是提交 `.env.example` 只含键名不含值,新人 clone 后复制成 `.env`。这个模式可用,但必须注意两点:一是 `.gitignore` 必须包含 `.env` 且所有 `.env.*` 变体(`.env.local`、`.env.production`)也要覆盖;二是 CI 里绝不能用「把 example 复制过来」的思路生成真实配置,那等于把密钥写死在流水线定义里。
更稳妥的是让本地环境变量有多个来源并按优先级合并:系统环境变量 > 项目根 `.env.local` > 项目根 `.env` > 全局 `~/.env`。这样个人机器上的临时覆盖不会污染仓库默认配置。
# .gitignore —— 必须覆盖所有变体
.env
.env.*
!.env.example
# .env.example —— 只有键名,没有值
DATABASE_URL=
REDIS_URL=
SESSION_SECRET=
STRIPE_SECRET_KEY=
FEATURE_NEW_CHECKOUT=false
# 本地启动:dotenv 只负责把文件读进环境
import "dotenv/config"
const sessionSecret = process.env.SESSION_SECRET
if (!sessionSecret) {
throw new Error("SESSION_SECRET is required")
}
console.log(sessionSecret.length >= 32 ? "secret ok" : "secret too short")四级演进:按团队规模选
第一级是纯 .env:1-5 人、没有合规要求、密钥类型少(数据库密码、一个第三方 key)。此阶段最重要的是把启动时的配置校验做成硬失败——缺一个键就启动失败并打印缺哪个,而不是等到第一次调用数据库时才报错。
第二级是平台环境变量:托管平台(Cloud Run、Heroku、Vercel 等)自带的配置面板,适合静态密钥(API endpoint、公开 client id)。这一级的问题是没有版本、没有审批、没有审计日志,而且改一次配置就是一个隐式发布。
第三级是容器化注入:K8s 用 ConfigMap 存非敏感配置、Secret 存敏感配置,通过环境变量或挂载文件注入 Pod。好处是配置跟着部署描述走、可 dry-run 审阅;代价是 base64 不是加密,仍需依赖 etcd 静态加密或外部密钥系统。
第四级是密钥管理服务(AWS Secrets Manager、GCP Secret Manager、HashiCorp Vault):支持版本化、自动轮换、细粒度 IAM 与完整审计。适合密钥数量多、需要频繁轮换、有合规要求的场景。代价是引入了网络延迟、可用性依赖和本地开发的新摩擦,通常最后才引入。
# 第一级:启动期硬校验 + 明确报错
const required = ["DATABASE_URL", "SESSION_SECRET", "STRIPE_SECRET_KEY"] as const
const missing = required.filter((k) => !process.env[k])
if (missing.length > 0) {
console.error("Missing required config:", missing.join(", "))
process.exit(1)
}
# 第三级:K8s 注入,配置与部署描述同源
example@prod:
containers:
- name: app
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
- name: LOG_LEVEL
value: "info"
envFrom:
- configMapRef:
name: app-config密钥轮换:双密钥重叠是基本功
轮换的难点从来不是生成新值,而是让新旧值在重叠期内同时有效。任何轮换方案都必须回答:旧密钥何时失效、谁负责触发、失败了怎么回滚。缺少重叠期的轮换等于计划中的短暂全站故障。
工程上的标准做法是支持双密钥:新旧各一个环境变量,先发布接受两者的版本 → 切到新密钥 → 观察一段时间 → 撤销旧密钥。对对称加密的会话密钥尤其如此,需要能同时用两个 secret 解密历史 token,或用密钥版本号(key id)把密文与密钥对应起来。
别忘了数据库密码这类难以原子切换的场景。稳妥模式是「建第二个用户/第二个密码 → 灰度切流量 → 撤销旧用户」,而不是原地改密码。同时把轮换动作写进 runbook:触发条件、负责人、验证命令、回滚步骤。
# 双密钥重叠:先兼容,再切换,最后撤销
const legacyKey = process.env.SESSION_SECRET_LEGACY
const currentKey = process.env.SESSION_SECRET_CURRENT
function sign(payload: string): string {
return hmac(payload, currentKey!) // 签发只用新密钥
}
function verify(token: string): boolean {
const payload = decode(token)
if (hmacVerify(payload, currentKey!)) return true
return legacyKey ? hmacVerify(payload, legacyKey) : false // 过渡期仍接受旧密钥
}
# 观察期结束后撤销:删除 SESSION_SECRET_LEGACY,代码里的分支自然失效常见失败模式与规避
第一个失败模式是把 `.env.production` 提交上去。规避手段是 pre-commit 钩子 + 服务端 pre-receive 钩子双层拦截,并配一个 CI 步骤用 gitleaks 之类工具扫全历史,而不是只扫 HEAD。
第二个是把「配置」和「密钥」混在一个 ConfigMap 里。日志若打印整个环境变量就会泄露,必须在代码层做白名单打印(如只打印 `LOG_LEVEL`、`APP_VERSION`),并对 CI 日志同样设置脱敏。
第三个是本地开发摩擦。密钥管理服务通常需要登录或 VPN,正确的解法是本地缓存一份加密的短期凭证(如 `~/.aws/credentials` 或 `gcloud auth application-default login`),而不是把生产密钥复制进 `.env`。第四个是配置漂移:不同环境的配置项集合不一致,建议用一份 schema(如 JSON Schema 或 zod)做交叉校验。
// 只允许白名单配置进入日志
const LOGGABLE_KEYS = ["LOG_LEVEL", "APP_VERSION", "NODE_ENV", "REGION"] as const
function safeEnvSnapshot(): Record<string, string> {
return Object.fromEntries(LOGGABLE_KEYS.filter((k) => process.env[k]).map((k) => [k, process.env[k]!]))
}
console.log("startup", safeEnvSnapshot())
# CI 中扫描历史泄露
gitleaks detect --source . --redact --exit-code 1