Bỏ qua để đến nội dung
Knowledge Hub

Parse và validate env lúc khởi động: báo đủ lỗi rồi thoát Trung cấp

Thời gian đọc ước tính: 8 phút

Trang này trả lời: viết một module config thế nào để mọi key sai lộ ra lúc start, key nào được có default, và chạy local với .env ra sao.

process.env chỉ chứa string (hoặc undefined). Mọi số, boolean, enum, URL đều phải parse.

Node còn tự convert khi gán: theo docs, gán process.env.X = 3000 sẽ thành '3000', và implicit conversion này đã deprecated.

Đang tải diagram…

payment.client.ts
const timeout = process.env.PAYMENT_TIMEOUT || 5000; // string | number
const key = process.env.PAYMENT_API_SECRET_KEY ?? ''; // rỗng vẫn chạy
// checkout.service.ts
if (process.env.FEATURE_NEW_CHECKOUT) { /* ... */ } // 'false' là truthy
// main.ts
await app.listen(process.env.PORT || 3000);

Vấn đề:

  • Lỗi xuất hiện lúc dùng, không phải lúc start. Key rỗng chỉ lộ ra khi có request refund đầu tiên.
  • Không ai trả lời được “service cần những env var nào” mà không grep cả repo.
  • Mỗi chỗ parse một kiểu: chỗ Number(), chỗ parseInt, chỗ không parse.
// config.ts (rút gọn từ demo bên dưới)
export function loadConfig(env: Env) {
const e: string[] = [];
const config = {
nodeEnv: read(env, 'NODE_ENV', { parse: oneOf('development', 'test', 'production') }, e),
http: {
port: read(env, 'HTTP_PORT', { parse: int(1, 65535), default: '3000' }, e),
},
db: {
url: read(env, 'DATABASE_URL', { parse: url('postgres:', 'postgresql:') }, e), // không default
},
payment: {
baseUrl: read(env, 'PAYMENT_API_BASE_URL', { parse: url('https:') }, e), // không default
secretKey: read(env, 'PAYMENT_API_SECRET_KEY', { parse: secretOf(16) }, e), // không default
},
features: {
newCheckout: read(env, 'FEATURE_NEW_CHECKOUT_ENABLED', { parse: bool, default: 'false' }, e),
},
};
if (e.length > 0) throw new ConfigError(e); // gom đủ lỗi rồi mới ném
return Object.freeze(config);
}
// main.ts
let config: AppConfig;
try {
config = loadConfig(process.env); // chỗ DUY NHẤT đọc process.env
} catch (err) {
console.error((err as Error).message);
process.exit(1); // trước listen(): chưa có request nào
}

Ba quyết định đáng giá:

  1. Nhận env làm tham số. Test truyền object vào, không cần mock process.env.
  2. Gom mọi lỗi rồi mới ném. Báo từng lỗi một nghĩa là sửa một key, deploy, chờ crash, sửa key tiếp. 6 key sai là 6 vòng deploy.
  3. Thông báo lỗi không in giá trị secret. PAYMENT_API_SECRET_KEY: quá ngắn là đủ để sửa.

Demo truyền env giả lập vào loadConfig, không đọc biến môi trường thật của máy:

Terminal window
npx --prefix examples ts-node examples/src/config-twelve-factor/demo.ts

Output thật (Node v24.17.0):

=== 1. env sai ===
Config không hợp lệ (6 lỗi):
- NODE_ENV: phải là một trong development|test|production, nhận 'prod'
- HTTP_PORT: phải là số nguyên, nhận '3000abc'
- DATABASE_URL: protocol phải là postgres:|postgresql:, nhận mysql:
- PAYMENT_API_BASE_URL: protocol phải là https:, nhận http:
- PAYMENT_API_SECRET_KEY: quá ngắn (cần >= 16 ký tự)
- FEATURE_NEW_CHECKOUT_ENABLED: phải là 'true' hoặc 'false', nhận 'yes'
→ production sẽ process.exit(1) tại đây, trước khi listen()
=== 2. env thiếu hẳn ===
Config không hợp lệ (4 lỗi):
- NODE_ENV: thiếu (bắt buộc)
- DATABASE_URL: thiếu (bắt buộc)
- PAYMENT_API_BASE_URL: thiếu (bắt buộc)
- PAYMENT_API_SECRET_KEY: thiếu (bắt buộc)
=== 3. env đúng ===
typeof http.port : number 8080
features.newCheckout : false
JSON.stringify(payment) : {"baseUrl":"https://pay.example.com","secretKey":"[REDACTED]"}
console.log(payment) : { baseUrl: 'https://pay.example.com', secretKey: [REDACTED] }
reveal(secretKey).length : 24
=== 4. bẫy khi đọc process.env thô ===
raw.HTTP_PORT === 3000 : false
raw.HTTP_PORT + 1 : 30001
Boolean('false') : true
if ('false') chạy nhánh : BẬT
Number('') : 0
Number('3000abc') : NaN | parseInt: 3000

Đọc output:

  • Lần 2 chỉ báo 4 lỗi: key có default an toàn (HTTP_PORT, LOG_LEVEL…) không bắt buộc. Key không có default là key mà đoán sai sẽ gây hại.
  • Lần 3: secret bọc trong object có toJSON và custom inspect, nên JSON.stringify và console.log đều in [REDACTED]. Chỉ reveal() lấy được giá trị; grep reveal( cho biết chính xác chỗ nào dùng secret.
  • Phần 4 là lý do không dùng parseInt hay Boolean() cho env: parseInt('3000abc') ra 3000 im lặng, Number('') ra 0. Chi tiết từng bẫy ở Lỗi hay gặp.

ConfigModule.forRoot() (package @nestjs/config, cài riêng) hỗ trợ hai cách validate. Theo docs, validate thất bại thì app không bootstrap.

Option Dùng khi Ghi chú từ docs
validationSchema Đã dùng Joi hoặc thư viện theo Standard Schema (Zod, Valibot, ArkType) Joi cần v18 trở lên; với Joi, mặc định allowUnknown: true, abortEarly: false (báo đủ lỗi)
validate Muốn tự viết, như loadConfig ở trên Nhận object env, trả về config đã validate, ném lỗi để chặn bootstrap
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
validate: (raw) => loadConfig(raw), // raw: env đã gộp, trả về config typed
}),
],
})
export class AppModule {}
  • Key có ở cả .env và environment thật thì environment thật thắng (theo docs). Image production không chứa .env, hoặc đặt ignoreEnvFile: true.
  • Ở service, dùng configService.getOrThrow() thay cho get(): getOrThrow ném lỗi khi giá trị là undefined, còn get âm thầm trả undefined hoặc default.
Được có default Không bao giờ có default
HTTP_PORT=3000, LOG_LEVEL=info Secret, password, API key
Pool size, timeout hợp lý URL production (DB, payment, webhook)
Flag tắt (false) cho tính năng mới NODE_ENV (thiếu thì phải báo, không đoán)
  • Default cho secret hay URL production nghĩa là thiếu config vẫn chạy, với giá trị ai đó viết trong code từ năm ngoái. Đó chính là lỗi “sống và sai”.
  • Default DB URL kiểu postgres://localhost/app còn tệ hơn: ở production nó fail muộn, ở máy dev nó trỏ nhầm DB.

Quy ước chi tiết ở Đặt tên. Tóm tắt cho config:

Quy tắc Nên Tránh
UPPER_SNAKE_CASE, prefix theo nhóm DATABASE_, PAYMENT_API_, FEATURE_ dbUrl, PayKey
Hậu tố đơn vị PAYMENT_API_TIMEOUT_MS=30000 TIMEOUT=30 (giây hay ms?)
Boolean dạng khẳng định FEATURE_NEW_CHECKOUT_ENABLED DISABLE_OLD_CHECKOUT
Một key một ý nghĩa CACHE_TTL_SECONDS, RETRY_MAX MODE=fast bật ngầm ba hành vi
File Commit? Nội dung
.env.example Có Mọi key app cần, giá trị giả hoặc rỗng, comment đơn vị
.env Không (.gitignore) Giá trị thật trên máy dev

.env.example là tài liệu sống: người mới copy, điền, chạy được. Có thể thêm test CI so khớp key trong .env.example với schema của loadConfig.

Node có sẵn loader, không cần package dotenv cho trường hợp đơn giản:

Terminal window
node --env-file=.env dist/main.js
node --env-file-if-exists=.env dist/main.js # không lỗi nếu file không có

Những điều docs Node nói rõ:

  • --env-file có từ v20.6.0; --env-file-if-exists từ v22.9.0. Cả hai hết experimental từ v24.10.0 và v22.21.0.
  • Biến đã có trong environment thắng giá trị trong file. Truyền nhiều --env-file thì file sau ghi đè file trước.
  • --env-file ném lỗi nếu file không tồn tại.
  • process.loadEnvFile(path) (từ v21.7.0, v20.12.0) làm việc tương tự từ trong code.

Không dùng .env ở production. Ở đó env var đến từ orchestrator (ConfigMap, Secret, secret manager).

Tiếp theo: Kubernetes và runtime.