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.
Env var là string
Phần tiêu đề “Env var là string”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.
Luồng nạp config
Phần tiêu đề “Luồng nạp config”Đang tải diagram…
Trước: process.env rải khắp nơi
Phần tiêu đề “Trước: process.env rải khắp nơi”const timeout = process.env.PAYMENT_TIMEOUT || 5000; // string | numberconst key = process.env.PAYMENT_API_SECRET_KEY ?? ''; // rỗng vẫn chạy
// checkout.service.tsif (process.env.FEATURE_NEW_CHECKOUT) { /* ... */ } // 'false' là truthy
// main.tsawait 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.
Sau: một module config, typed, đóng băng
Phần tiêu đề “Sau: một module config, typed, đóng băng”// 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.tslet 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á:
- Nhận
envlàm tham số. Test truyền object vào, không cần mockprocess.env. - 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.
- Thông báo lỗi không in giá trị secret.
PAYMENT_API_SECRET_KEY: quá ngắnlà đủ để sửa.
Demo chạy thật
Phần tiêu đề “Demo chạy thật”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:
npx --prefix examples ts-node examples/src/config-twelve-factor/demo.tsOutput 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 8080features.newCheckout : falseJSON.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 : falseraw.HTTP_PORT + 1 : 30001Boolean('false') : trueif ('false') chạy nhánh : BẬTNumber('') : 0Number('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ó
toJSONvà custom inspect, nênJSON.stringifyvàconsole.logđều in[REDACTED]. Chỉreveal()lấy được giá trị; grepreveal(cho biết chính xác chỗ nào dùng secret. - Phần 4 là lý do không dùng
parseInthayBoolean()cho env:parseInt('3000abc')ra3000im lặng,Number('')ra0. Chi tiết từng bẫy ở Lỗi hay gặp.
Với NestJS: @nestjs/config
Phần tiêu đề “Với NestJS: @nestjs/config”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ả
.envvà environment thật thì environment thật thắng (theo docs). Image production không chứa.env, hoặc đặtignoreEnvFile: true. - Ở service, dùng
configService.getOrThrow()thay choget():getOrThrowném lỗi khi giá trị làundefined, còngetâm thầm trảundefinedhoặc default.
Default: chỉ cho giá trị không quan trọng
Phần tiêu đề “Default: chỉ cho giá trị không quan trọng”| Đượ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/appcòn tệ hơn: ở production nó fail muộn, ở máy dev nó trỏ nhầm DB.
Đặt tên env var
Phần tiêu đề “Đặt tên env var”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 |
Local dev: .env và node --env-file
Phần tiêu đề “Local dev: .env và node --env-file”| 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:
node --env-file=.env dist/main.jsnode --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-filecó từ v20.6.0;--env-file-if-existstừ 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-filethì file sau ghi đè file trước. --env-filené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.