返回博客

import 'dotenv/config' 不加载 .env.local,密钥永远 undefined

从 Next.js 迁移出一个独立的 Node.js API 服务时,我习惯性地把密钥放在 .env.local(gitignore 掉)、默认值放在 .env(可提交)。服务一起,报错就来:

Error: DEEPSEEK_API_KEY 未设置

打开 .env.local 一看,key 明明在那里。重启、检查文件编码、确认路径——什么问题都没有。整整折腾了十多分钟。

现象

import 'dotenv/config'
// ...
if (!process.env.DEEPSEEK_API_KEY) throw new Error('DEEPSEEK_API_KEY 未设置')
$ grep DEEPSEEK_API_KEY .env.local
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

grep 有结果,运行时 process.env.DEEPSEEK_API_KEY 却是 undefined。服务用的是 Fastify + tsx,不是 Next.js。

根因

import 'dotenv/config'dotenv.config() 的快捷写法——它只加载 process.cwd() 下的 .env 文件,仅此而已

.env.local.env.development.env.<NODE_ENV> 这套级联规则是 框架层 实现的,不是 dotenv 本身的能力:

  • Next.js@next/env 包实现了完整的 .env.*.local 优先级链
  • ViteloadEnv() 实现了同套规则
  • 裸 dotenv:只认 .env,到此为止

所以在 Fastify / Express / tsx 脚本 / BullMQ worker 等纯 Node.js 环境里,.env.local 对 dotenv 完全不可见。

坑:从 Next.js 迁出服务时,.env.local 的加载规则不随之迁移

Next.js 项目里 .env.local 总是被自动加载,且优先级高于 .env——这是 @next/env 的魔法。一旦你把代码移到独立的 Node.js 进程,这个魔法就消失了,但你很可能不会意识到,因为代码看起来完全一样。

正解

在入口文件最顶部,用两次显式加载替换那个一行版:

// src/index.ts — 必须在所有其他 import 之前
import dotenv from 'dotenv'
import { resolve } from 'path'
import { existsSync } from 'fs'
 
const localEnv = resolve(process.cwd(), '.env.local')
if (existsSync(localEnv)) dotenv.config({ path: localEnv })
dotenv.config() // .env 作为兜底,已存在的 key 不会被覆盖

顺序是关键dotenv.config() 默认不覆盖已设置的变量,所以先加载 .env.local,其中的值就会赢过 .env 里的同名 key——与 Next.js / Vite 的优先级语义一致。

加完之后加一行快速验证:

console.log('[env] API key loaded =', !!process.env.DEEPSEEK_API_KEY)

输出 true 后删掉这行,完事。

正解:两次显式 dotenv.config(),.env.local 先加载

dotenv.config({ path: '.env.local' }),再 dotenv.config()(无参数加载 .env)。由于 dotenv 默认不覆盖已有 key,先加载的 .env.local 天然拥有更高优先级。

备选:dotenv-flow 一行搞定全套级联

如果项目还需要 .env.development.env.production 等分环境文件,可以用 dotenv-flow 替代裸 dotenv:

import 'dotenv-flow/config'

它完整复刻了 Next.js 的级联语义。但对于只有两个文件的大多数项目,手写两行更简单,也不增加依赖。

一句话外卖

.env.local 的自动加载是框架特性,不是 dotenv 特性——从框架迁出的服务必须自己实现这套优先级。