Lỗi hay gặp khi đọc config và env var trong Node.js Trung cấp
Thời gian đọc ước tính: 10 phút
Trang này gom các dòng code đọc config mà dev hay viết, chuyện gì xảy ra ở production, và cách sửa. Output trong phần “Vì sao” chạy thật trên Node v24.17.0.
Gốc của gần như mọi lỗi dưới đây: process.env chỉ chứa string hoặc undefined. Cách sửa chung là một module config parse và validate lúc start, xem Parse và validate env.
Kiểu dữ liệu: string giả làm số, boolean
Phần tiêu đề “Kiểu dữ liệu: string giả làm số, boolean”Fallback cho giá trị quan trọng bằng || hoặc ??
Hay gặp
const secretKey = process.env.PAYMENT_API_SECRET_KEY ?? '';const dbUrl = process.env.DATABASE_URL || 'postgres://localhost:5432/app';await app.listen(process.env.PORT || 3000);Nên viết
// config.ts: key quan trọng KHÔNG có default, thiếu là exit(1) lúc startpayment: { secretKey: read(env, 'PAYMENT_API_SECRET_KEY', { parse: secretOf(16) }, e) },db: { url: read(env, 'DATABASE_URL', { parse: url('postgres:', 'postgresql:') }, e) },http: { port: read(env, 'HTTP_PORT', { parse: int(1, 65535), default: '3000' }, e) },Vì sao
Vì sao:
- Đặt nhầm tên key trong Secret thì app vẫn start, health check 200, rồi mọi request gọi provider trả
401. Lỗi lộ ra lúc có traffic, không phải lúc deploy. - Default
localhostcho DB: ở production fail muộn khi query đầu tiên; ở máy dev có thể trỏ nhầm DB của project khác. - Default chỉ dành cho giá trị đoán sai không gây hại (port, log level, pool size).
So sánh env với số, cộng env với số
Hay gặp
if (process.env.HTTP_PORT === 3000) { /* không bao giờ chạy */ }const nextPort = process.env.HTTP_PORT + 1; // '30001'const timeout = process.env.PAYMENT_TIMEOUT || 5000; // string | numberNên viết
// parse một lần, phần còn lại của app chỉ thấy numberconst config = loadConfig(process.env);config.http.port === 3000; // number === numberconfig.http.port + 1; // 3001Vì sao
Với HTTP_PORT=3000, Node 24 in ra:
process.env.HTTP_PORT === 3000 : falseprocess.env.HTTP_PORT + 1 : 30001|| còn sinh kiểu string | number: có env thì là '30000', không có thì là 5000. Code phía sau xử lý hai kiểu khác nhau tuỳ môi trường.
'false' là truthy, và === true không bao giờ đúng
Hay gặp
if (process.env.FEATURE_NEW_CHECKOUT_ENABLED) enableNewCheckout();if (process.env.FEATURE_NEW_CHECKOUT_ENABLED === true) enableNewCheckout();const enabled = Boolean(process.env.FEATURE_NEW_CHECKOUT_ENABLED);Nên viết
const bool = (v: string) => { if (v === 'true') return true; if (v === 'false') return false; throw new Error(`phải là 'true' hoặc 'false', nhận '${v}'`); // 'yes', '1' là lỗi config};newCheckout: read(env, 'FEATURE_NEW_CHECKOUT_ENABLED', { parse: bool, default: 'false' }, e),Vì sao
Với FEATURE_NEW_CHECKOUT_ENABLED=false, Node 24 in ra:
if (env) chạy nhánh : BẬTenv === true : falseBoolean(env) : true- Cách 1 và 3: ops đặt
falseđể tắt tính năng đang lỗi, tính năng vẫn bật. Kill switch vô dụng đúng lúc cần. - Cách 2: không bao giờ bật được, kể cả với
'true'.
parseInt và Number() nuốt giá trị sai
Hay gặp
const timeoutMs = parseInt(process.env.PAYMENT_API_TIMEOUT_MS); // không radix, không check NaNconst poolMax = Number(process.env.DATABASE_POOL_MAX);setTimeout(abort, timeoutMs);Nên viết
function intEnv(raw: string | undefined, key: string): number { if (raw === undefined || raw.trim() === '') throw new Error(`${key}: thiếu`); if (!/^\d+$/.test(raw.trim())) throw new Error(`${key}: phải là số nguyên, nhận '${raw}'`); return Number(raw);}Vì sao
Node 24, cùng một chuỗi qua hai hàm:
| Giá trị env | parseInt(v) |
Number(v) |
intEnv |
|---|---|---|---|
'30s' |
30 |
NaN |
lỗi |
'0x1F' |
31 |
31 |
lỗi |
'1e3' |
1 |
1000 |
lỗi |
'' |
NaN |
0 |
lỗi: thiếu |
parseIntđọc tới ký tự không phải số rồi dừng im lặng: ai đó ghi30sthì timeout thành 30 ms. Thêm radix10không cứu được trường hợp này.Number('')ra0: key bị để trống thành pool size0hay timeout0.setTimeout(fn, NaN)không ném lỗi: Node inTimeoutNaNWarningvà đặt timeout 1 ms.
Number(x) || default xoá mất giá trị 0 hợp lệ
Hay gặp
const poolMin = Number(process.env.DATABASE_POOL_MIN) || 5;const retryMax = Number(process.env.PAYMENT_RETRY_MAX) || 3;Nên viết
// quyết định "thiếu" trên CHUỖI GỐC, không trên số đã parseconst raw = env.DATABASE_POOL_MIN?.trim();const poolMin = raw === undefined || raw === '' ? 5 : intEnv(raw, 'DATABASE_POOL_MIN');Vì sao
Với DATABASE_POOL_MIN=0, Number('0') || 5 ra 5 (Node 24). Ops muốn tắt retry bằng PAYMENT_RETRY_MAX=0 thì app vẫn retry 3 lần, có thể trừ tiền 3 lần nếu endpoint không idempotent.
Truyền thẳng chuỗi env vào listen()
Hay gặp
server.listen(process.env.PORT); // PORT='3000abc' do gõ nhầmNên viết
server.listen(config.http.port); // number đã validate trong [1, 65535]Vì sao
server.listen() nhận cả port lẫn đường dẫn IPC. Chạy thử trên Node 24:
listen('3000abc')không báo lỗi: server nghe trên Unix socket tên3000abc,address()trả về'3000abc'. Port 3000 không mở, probe fail mà log không có lỗi nào.listen(NaN)thì némERR_SOCKET_BAD_PORT: ít ra còn chết ngay.
Cấu trúc: đọc env ở đâu, theo cái gì
Phần tiêu đề “Cấu trúc: đọc env ở đâu, theo cái gì”process.env rải khắp codebase
Hay gặp
const res = await fetch(`${process.env.PAYMENT_API_BASE_URL}/refunds`, { headers: { Authorization: `Bearer ${process.env.PAYMENT_API_SECRET_KEY}` },});Nên viết
// refund.service.ts: nhận config typed qua constructor/DIconstructor(private readonly payment: AppConfig['payment']) {}
const res = await fetch(`${this.payment.baseUrl}/refunds`, { headers: { Authorization: `Bearer ${reveal(this.payment.secretKey)}` },});Vì sao
- Thiếu key thì URL thành
undefined/refunds, header thànhBearer undefined. Lỗi nổ ở request refund đầu tiên, có thể nhiều giờ sau deploy. - Không ai liệt kê được service cần env var nào nếu không grep cả repo;
.env.examplelệch dần. - Test phải mock
process.envtoàn cục, dễ rò giữa các test.
Dùng NODE_ENV để quyết định nghiệp vụ
Hay gặp
const paymentBaseUrl = process.env.NODE_ENV === 'production' ? 'https://api.pay.example.com' : 'https://sandbox.pay.example.com';
if (process.env.NODE_ENV !== 'production') skipWebhookSignatureCheck = true;Nên viết
// mỗi hành vi một key riêng, mỗi môi trường đặt giá trị tường minhpayment: { baseUrl: read(env, 'PAYMENT_API_BASE_URL', { parse: url('https:') }, e) },webhook: { verifySignature: read(env, 'WEBHOOK_VERIFY_SIGNATURE_ENABLED', { parse: bool, default: 'true' }, e) },Vì sao
- Nhiều team cho staging chạy
NODE_ENV=productioncho giống prod. Khi đó nhánh trên khiến staging gọi API thanh toán thật. - Ngược lại, môi trường nào đặt
NODE_ENV=stagingthì webhook không kiểm chữ ký: ai cũng gửi được webhook giả. - 12-factor gọi đây là gom config thành nhóm “environments”, thứ không mở rộng được khi có thêm môi trường. Framework và thư viện cũng đọc
NODE_ENV, nên đừng gán thêm nghĩa nghiệp vụ cho nó.
Gán undefined để xoá env var trong test
Hay gặp
afterEach(() => { process.env.FEATURE_NEW_CHECKOUT_ENABLED = undefined;});Nên viết
afterEach(() => { delete process.env.FEATURE_NEW_CHECKOUT_ENABLED;});// tốt hơn nữa: loadConfig({ ...fakeEnv }) không đụng process.envVì sao
process.env convert mọi giá trị gán vào thành string. Node 24: sau khi gán undefined, giá trị là chuỗi "undefined", và Boolean(...) ra true. Test sau thấy flag bật dù tưởng đã xoá; test pass hay fail tuỳ thứ tự chạy.
Secret: image, ConfigMap, log
Phần tiêu đề “Secret: image, ConfigMap, log”Bake secret vào image
Hay gặp
FROM node:24-slimARG PAYMENT_API_SECRET_KEYENV PAYMENT_API_SECRET_KEY=$PAYMENT_API_SECRET_KEYCOPY .env.production ./.envNên viết
FROM node:24-slim# không ARG/ENV/COPY secret; cần token lúc build thì dùng secret mountRUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ciCOPY dist ./distCMD ["node", "dist/main.js"]Vì sao
- Docker docs: build arg và env var “are inappropriate for passing secrets” vì “they persist in the final image”. Ai pull được image là đọc được key.
- Image có
.env.productionthì không promote được từ staging sang production: phải build riêng, tức là chạy artifact chưa test. - Rotation cần build lại image thay vì chỉ cập nhật Secret.
Để secret trong ConfigMap
Manifest hay gặp
kind: ConfigMapmetadata: { name: payment-api-config }data: PAYMENT_API_BASE_URL: https://api.pay.example.com PAYMENT_API_SECRET_KEY: sk_live_...Manifest nên viết
kind: ConfigMapmetadata: { name: payment-api-config }data: PAYMENT_API_BASE_URL: https://api.pay.example.com---kind: Secret # + encryption at rest, RBAC hẹp, giá trị từ secret managermetadata: { name: payment-api-secrets }type: OpaquestringData: PAYMENT_API_SECRET_KEY: <inject từ secret manager, không commit>Vì sao
- Docs Kubernetes: ConfigMap “does not provide secrecy or encryption”. Người chỉ có quyền đọc ConfigMap để debug cũng đọc được key.
- ConfigMap thường nằm trong git; secret theo đó vào lịch sử git vĩnh viễn.
- Secret cũng chỉ là base64, nên vẫn phải bật encryption at rest và RBAC (xem Kubernetes và runtime).
Log nguyên object config lúc khởi động
Hay gặp
const config = { databaseUrl: process.env.DATABASE_URL, // postgres://app:p4ssw0rd@... paymentSecretKey: process.env.PAYMENT_API_SECRET_KEY, logLevel: process.env.LOG_LEVEL,};logger.info({ config }, 'config loaded'); // "để debug"Nên viết
const config = loadConfig(process.env); // secret bọc, toJSON → '[REDACTED]'// chọn tường minh giá trị không bí mật để loglogger.info({ nodeEnv: config.nodeEnv, port: config.http.port, logLevel: config.logLevel }, 'config loaded');Vì sao
- Một dòng log đưa password DB và API key vào kho log, nơi nhiều người đọc hơn Secret và giữ nhiều tháng. Rotate key xong, bản cũ vẫn nằm trong log.
- Demo ở trang Parse và validate env cho thấy secret bọc trong object có
toJSONvà custom inspect in[REDACTED]cả vớiJSON.stringifylẫnconsole.log. - Cần biết đã nạp gì: log tên key, hoặc giá trị không bí mật được chọn tường minh.
Sửa ConfigMap rồi tưởng đã áp dụng
Thao tác hay gặp
kubectl edit configmap payment-api-config # đổi LOG_LEVEL=debug lúc 2h sáng# ...rồi đi ngủ, tin rằng app đã log debugThao tác nên làm
# đổi trong git, CI apply; template có checksum annotation → tự rollout# hoặc tối thiểu:kubectl rollout restart deployment/payment-apikubectl exec deploy/payment-api -- printenv LOG_LEVEL # kiểm giá trị thật trong PodVì sao
- Docs Kubernetes: ConfigMap dùng làm env var “are not updated automatically and require a pod restart”. Pod đang chạy vẫn dùng giá trị cũ.
- Sửa ConfigMap không đổi Pod template nên Deployment không tự rollout.
- Sửa tay trên cluster tạo drift: lần apply manifest sau ghi đè mất, không ai biết vì sao hành vi đổi.
Checklist production
Phần tiêu đề “Checklist production”- Một image cho mọi môi trường, deploy theo digest; image không chứa
.envhay secret. - Chỉ một module đọc
process.env; phần còn lại nhận config typed qua DI. - Validate lúc start, báo mọi key thiếu/sai trong một lần,
exit(1)trướclisten(). - Không default cho secret, URL production,
NODE_ENV. Default chỉ cho giá trị an toàn. - Secret bọc để
console.log/JSON.stringifykhông in ra; không log nguyên object config. .env.exampleđược commit và khớp schema;.envnằm trong.gitignore.- Kubernetes: secret trong Secret (bật encryption at rest, RBAC), không trong ConfigMap; đổi config kèm rollout có checksum annotation.
- Mỗi feature flag có owner và hạn xoá; secret có quy trình rotation với giai đoạn hai key cùng hợp lệ.
Quay lại: Tổng quan Config và 12-factor.