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

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 start
payment: { 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 localhost cho 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 | number

Nên viết

// parse một lần, phần còn lại của app chỉ thấy number
const config = loadConfig(process.env);
config.http.port === 3000; // number === number
config.http.port + 1; // 3001

Vì sao

Với HTTP_PORT=3000, Node 24 in ra:

process.env.HTTP_PORT === 3000 : false
process.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ẬT
env === true : false
Boolean(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 NaN
const 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 đó ghi 30s thì timeout thành 30 ms. Thêm radix 10 không cứu được trường hợp này.
  • Number('') ra 0: key bị để trống thành pool size 0 hay timeout 0.
  • setTimeout(fn, NaN) không ném lỗi: Node in TimeoutNaNWarning và đặ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ố đã parse
const 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ầm

Nê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ên 3000abc, address() trả về '3000abc'. Port 3000 không mở, probe fail mà log không có lỗi nào.
  • listen(NaN) thì ném ERR_SOCKET_BAD_PORT: ít ra còn chết ngay.

process.env rải khắp codebase

Hay gặp

refund.service.ts
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/DI
constructor(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ành Bearer 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.example lệch dần.
  • Test phải mock process.env toà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 minh
payment: { 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=production cho 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=staging thì 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.env

Vì 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.

Bake secret vào image

Hay gặp

FROM node:24-slim
ARG PAYMENT_API_SECRET_KEY
ENV PAYMENT_API_SECRET_KEY=$PAYMENT_API_SECRET_KEY
COPY .env.production ./.env

Nên viết

FROM node:24-slim
# không ARG/ENV/COPY secret; cần token lúc build thì dùng secret mount
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ci
COPY dist ./dist
CMD ["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.production thì 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: ConfigMap
metadata: { 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: ConfigMap
metadata: { 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 manager
metadata: { name: payment-api-secrets }
type: Opaque
stringData:
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 để log
logger.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ó toJSON và custom inspect in [REDACTED] cả với JSON.stringify lẫn console.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

Terminal window
kubectl edit configmap payment-api-config # đổi LOG_LEVEL=debug lúc 2h sáng
# ...rồi đi ngủ, tin rằng app đã log debug

Thao tác nên làm

Terminal window
# đổi trong git, CI apply; template có checksum annotation → tự rollout
# hoặc tối thiểu:
kubectl rollout restart deployment/payment-api
kubectl exec deploy/payment-api -- printenv LOG_LEVEL # kiểm giá trị thật trong Pod

Vì 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.
  1. Một image cho mọi môi trường, deploy theo digest; image không chứa .env hay secret.
  2. Chỉ một module đọc process.env; phần còn lại nhận config typed qua DI.
  3. Validate lúc start, báo mọi key thiếu/sai trong một lần, exit(1) trước listen().
  4. Không default cho secret, URL production, NODE_ENV. Default chỉ cho giá trị an toàn.
  5. Secret bọc để console.log / JSON.stringify không in ra; không log nguyên object config.
  6. .env.example được commit và khớp schema; .env nằm trong .gitignore.
  7. Kubernetes: secret trong Secret (bật encryption at rest, RBAC), không trong ConfigMap; đổi config kèm rollout có checksum annotation.
  8. 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.