环境变量与密钥管理
构建时变量和运行时变量的分界、密钥不进仓库的做法,以及"降级不报错"为什么会让配置错误在生产静默存在。
环境变量看起来是最简单的一环:写几行 KEY=value 就完事。实际上它是上线阶段最容易出静默故障的地方——配置错了程序照样启动,页面照样打开,只是行为不对。
这一页讲三件事:构建时和运行时的分界、密钥怎么管、怎么让配置错误在上线前就暴露。
快速版
- 分清哪些变量在构建时被写进产物,改了必须重新构建。
.env进.gitignore,.env.example进仓库但只写占位值。- 构建阶段用假值满足必填校验,真密钥只在运行时注入。
- 前端可见的变量(通常有
NEXT_PUBLIC_之类前缀)任何人都能看到,别放密钥。 - 部署后核对一遍生产环境变量的实际值,尤其是价格、开关这类数字。
- 给"缺少配置时降级"的行为加显式警告,否则错误会静默存在。
- 换域名、换主体、轮换密钥时,列一张清单逐项过。
构建时和运行时的分界
这是这一页最重要的概念。
构建时变量在构建阶段被读取,并且写进了产物。之后改环境变量不重新构建,不会有任何变化。典型的是要发送到浏览器的值:站点地址、公开 API 端点、埋点 ID。
运行时变量在程序启动时读取,改完重启就生效。典型的是数据库连接串、第三方密钥、业务开关。
摆摊自己的例子:
# 构建阶段:站点地址必须在这里传进来,它会被写进前端产物
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
# 构建阶段:这两个只是为了让构建通过,给的是假值
ENV DATABASE_URL=postgres://build:build@localhost:5432/build
ENV BETTER_AUTH_SECRET=build-placeholder两种变量在同一个文件里,但用途完全不同。上面那个必须是真的,下面两个必须是假的。
NEXT_PUBLIC_ 这类前缀的含义是**"这个值会被发送到浏览器"**。任何访问你网站的人都能在页面源码或网络请求里看到它。密钥、私有端点、内部 ID 一律不能用这个前缀,加进去就等于公开了。
为什么构建阶段要给假值
很多框架和库在模块加载时就校验必填配置,缺了就直接报错,构建过不去。
如果为了让构建通过而把真的数据库连接串和真的签名密钥传进构建阶段,这些值会留在镜像的构建历史里。任何拿到镜像的人都能读出来。
所以正确做法是:构建阶段给结构合法但内容是假的值,让校验通过;真值在容器启动时通过运行时环境注入。
判断标准很简单:如果这个值需要被浏览器看到,它必须在构建时给真的;否则构建时给假的。
密钥不进仓库
三条规则,没有例外:
# .gitignore
.env
.env.local
.env*.local.env进.gitignore。 在写第一个密钥之前就加,不是之后。.env.example进仓库,但只写占位值和注释。 它的作用是告诉别人(包括三个月后的你)需要哪些变量、从哪里申请、有什么坑。- 任何时候不要把密钥粘到聊天工具、issue、提交信息里。
.env.example 值得认真写。这是我们自己的版本里几条注释:
# 生成: openssl rand -base64 32
BETTER_AUTH_SECRET=
# 回调地址: {BETTER_AUTH_URL}/api/auth/callback/github
GITHUB_CLIENT_ID=
# 商户号和 KEY 在支付平台「API 安全」页获取;KEY 绝不能进前端
JIANPAY_KEY=
# 本地没有商户号时设 1,走模拟支付验证内部链路(生产环境自动忽略)
JIANPAY_MOCK=每条都在回答"我拿这个值要干什么、从哪拿、有什么限制"。注释里的一句提示,能省掉半小时翻文档。
已经提交了密钥怎么办
先接受一件事:从 git 历史里删掉不等于安全。 只要它被推送过,就应该假定已经泄露。
顺序是:
- 先去平台后台轮换密钥,让旧的失效。这是唯一真正有效的一步。
- 再处理仓库历史(改写历史或让仓库作废)。
- 检查该密钥对应的账户有没有异常活动。
不要先花两小时研究怎么改写 git 历史,那期间旧密钥一直是有效的。
三层配置
| 层 | 放什么 | 谁能看到 |
|---|---|---|
| 前端公开 | 站点地址、公开端点、埋点 ID | 所有访客 |
| 服务端运行时 | 数据库、第三方密钥、签名 KEY | 服务器上有权限的人 |
| 构建参数 | 需要写进产物的公开值 | 拿到镜像的人 |
写每个变量之前问一句:它属于哪一层? 大部分密钥泄露事故是因为一个变量被放错了层。
降级会掩盖配置错误
这是我们自己在生产环境踩的两个坑,都属于同一类问题:程序在缺少配置时"优雅降级",结果错误配置在生产静默存在。
例一:邮件没配,验证码打到了控制台
为了方便本地开发,我们的邮件模块在没有 SMTP 配置时不报错,而是把验证码直接打印到服务端控制台。
这在开发时很方便。但生产服务器的 .env 里也没有 SMTP 配置——结果是登录页一切正常,用户点击发送验证码看到成功提示,验证码却只出现在容器日志里。没有任何报错,页面也不会告诉你出了问题。
例二:价格变量还是测试值
会员价格通过环境变量配置,单位是分。我们本地测试时把它设成 20(两毛钱)方便走通支付链路。
生产 .env 里留着这个测试值,定价页面显示 699 元,实际下单只扣两毛。 程序完全正常运行,没有任何异常日志。
怎么防
| 做法 | 效果 |
|---|---|
| 降级时打一条显式 WARN 日志 | 至少日志里能看到 |
| 生产环境启动时校验必填项,缺了就拒绝启动 | 最有效,问题变成"起不来"而不是"行为不对" |
| 部署后核对关键数字变量的实际值 | 抓住价格、额度、开关这类 |
| 页面上显示的数字和后端来源保持同一处 | 从根上避免两边不一致 |
最关键的是第二条。开发方便和生产安全应该用不同策略:本地缺配置可以降级,生产缺配置应该直接启动失败。
部署后核对
部署完成后,在服务器上过一遍实际生效的值。只看键名和是否设置,不要把密钥值打印到终端或粘到别处:
# 只列出键名,确认该有的都在(不打印值)
docker compose exec app printenv | cut -d= -f1 | sort对于价格、额度、开关这类非密钥的业务数字,要核对实际值:
docker compose exec app printenv | grep -E 'PRICE|LIMIT|MOCK|ENABLE'不要用会打印全部环境变量值的命令,也不要把这类输出贴进聊天、issue 或 AI 对话。密钥一旦进入这些地方就应视为已泄露。核对是否设置用键名列表就够了。
变更清单
下面这些操作会牵动多个变量,容易漏。每种存一张清单:
换域名
- 构建时的站点地址变量(必须重新构建)
- 认证服务的回调基地址
- 第三方 OAuth 应用里登记的回调地址
- 支付平台后台配置的回调地址
- 邮件发信域名和相关解析记录
- 旧域名的跳转,以及旧域名证书要保留
轮换密钥
- 在平台生成新密钥,先不删旧的
- 更新服务器上的值并重启
- 验证功能正常
- 再去平台删除旧密钥
顺序很重要:先加新的、验证、再删旧的。 反过来会有一段服务不可用。
常见卡点
| 卡在哪 | 可能原因 | 试试这样 |
|---|---|---|
| 改了环境变量没生效 | 那是构建时变量 | 重新构建,不是重启 |
| 密钥出现在浏览器里 | 用了前端可见的前缀 | 换成服务端变量,并立即轮换该密钥 |
| 构建报"缺少必填配置" | 模块加载时校验 | 构建阶段给假值,运行时给真值 |
| 功能"正常"但行为不对 | 降级掩盖了缺失配置 | 生产环境改成缺配置就启动失败 |
| 生产价格和页面不一致 | 测试值留在了生产 | 部署后核对业务数字变量 |
| 换域名后登录跳回旧地址 | 回调地址有多处 | 按换域名清单逐项过 |
| 轮换密钥导致服务中断 | 先删了旧的 | 先加新的、验证、再删旧的 |
| 不小心提交了密钥 | 没先加 gitignore | 先去平台轮换,再处理历史 |
验收标准
-
.env在.gitignore里,仓库里搜不到真实密钥 -
.env.example存在,每个变量都有"从哪拿"的注释 - 每个变量都明确属于前端公开 / 服务端运行时 / 构建参数中的一层
- 前端可见前缀的变量里没有任何密钥
- 构建阶段不接收真实密钥
- 生产环境缺少必填配置时会拒绝启动,而不是降级
- 部署后核对过价格、额度、开关这类业务数字的实际值
- 有换域名清单和轮换密钥清单
本章动作
在生产环境跑一遍键名核对,然后专门确认一件事:页面上显示的价格和后端实际使用的值,是不是同一个来源。
如果是两个地方各写一份,现在就改成一处。我们就是在这里翻的车。
下一步
- 自建服务器部署——变量在部署链路里怎么传
- 生产环境验收——上线前逐项检查
- 隐私、安全与最小合规——密钥之外的安全面
- 支付接入与对账——支付密钥的特殊要求