m` offers faster performance and is cross-platform.
# Install fnm (Fast Node Manager)
curl -fsSL https://fnm.vercel.app/install | bash
# Install and set LTS version
fnm install --lts
fnm use --lts
2. Project Initialization and Manifest Configuration
Initialize the project with a strict package.json configuration. Enable ESM modules explicitly and define engine constraints to prevent runtime mismatches.
mkdir secure-node-service && cd secure-node-service
npm init -y
Update package.json with the following structure:
{
"name": "secure-node-service",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"start": "node --enable-source-maps dist/index.js",
"dev": "tsx watch src/index.ts",
"audit": "npm audit --production"
},
"dependencies": {
"dotenv": "^16.4.5",
"helmet": "^7.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.5.2",
"tsx": "^4.15.6",
"@types/node": "^20.14.9"
}
}
Architecture Decisions:
type: "module": Enforces ESM syntax, which aligns with modern JavaScript standards and enables top-level await.
engines: Prevents deployment on unsupported Node versions.
tsx: Provides fast TypeScript execution without separate compilation steps during development.
--enable-source-maps: Ensures stack traces in production point to source lines, not transpiled output.
3. Environment Validation with Zod
Never trust environment variables. Use zod to validate configuration at startup. This fails fast if critical settings are missing or malformed, preventing runtime errors in production.
Create src/config/env.ts:
import { z } from 'zod';
import 'dotenv/config';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
HOST: z.string().default('127.0.0.1'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
// Add required secrets here
// DATABASE_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
4. Core HTTP Server Implementation
Implement the server using the native http module. This example demonstrates a structured request handler, security headers, and a health check endpoint without framework overhead.
Create src/index.ts:
import http, { IncomingMessage, ServerResponse } from 'node:http';
import { env } from './config/env.js';
import { applySecurityHeaders } from './middleware/security.js';
import { handleHealthCheck } from './handlers/health.js';
import { handleNotFound } from './handlers/not-found.js';
const requestListener = (req: IncomingMessage, res: ServerResponse) => {
// Apply security headers to every response
applySecurityHeaders(res);
// Route handling
if (req.url === '/health' && req.method === 'GET') {
handleHealthCheck(req, res);
return;
}
// Fallback
handleNotFound(req, res);
};
const server = http.createServer(requestListener);
// Graceful shutdown handling
process.on('SIGTERM', () => {
console.info('SIGTERM received. Closing server...');
server.close(() => {
console.info('Server closed.');
process.exit(0);
});
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});
server.listen(env.PORT, env.HOST, () => {
console.info(`Service listening on ${env.HOST}:${env.PORT}`);
console.info(`Environment: ${env.NODE_ENV}`);
});
Create src/middleware/security.ts:
import { ServerResponse } from 'node:http';
export const applySecurityHeaders = (res: ServerResponse) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '0'); // Modern browsers handle XSS protection
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
};
Create src/handlers/health.ts:
import { IncomingMessage, ServerResponse } from 'node:http';
import { env } from '../config/env.js';
export const handleHealthCheck = (_req: IncomingMessage, res: ServerResponse) => {
const payload = {
status: 'ok',
uptime: process.uptime(),
version: process.version,
env: env.NODE_ENV,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
Create src/handlers/not-found.ts:
import { IncomingMessage, ServerResponse } from 'node:http';
export const handleNotFound = (_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found', path: _req.url }));
};
5. TypeScript Configuration
Configure tsconfig.json to enforce strict typing and output ESM-compatible code.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Pitfall Guide
Production Node.js applications fail due to predictable patterns. Avoid these common mistakes to ensure stability and security.
-
Event Loop Blocking
- Explanation: Node.js runs JavaScript on a single thread. Synchronous CPU-intensive operations (e.g., large JSON parsing, regex on untrusted input, heavy computation) block the event loop, preventing the server from handling other requests.
- Fix: Offload CPU work to Worker Threads or external services. Use streaming APIs for large data processing. Never use synchronous file I/O in request handlers.
-
Missing Unhandled Rejection Handler
- Explanation: In Node.js, unhandled promise rejections can leave the process in an undefined state. Future versions will terminate the process by default, but relying on this is risky.
- Fix: Always attach a
process.on('unhandledRejection') listener. Log the error and exit gracefully to trigger a restart by the process manager.
-
Hardcoded Secrets and Configuration
- Explanation: Embedding API keys, database URLs, or passwords in source code leads to credential leaks via version control and makes environment management impossible.
- Fix: Use environment variables validated by a schema (e.g., Zod). Never commit
.env files. Use secret management tools in production.
-
Ignoring Dependency Vulnerabilities
- Explanation:
npm install pulls in transitive dependencies that may contain known vulnerabilities. Teams often neglect regular audits.
- Fix: Run
npm audit in CI pipelines. Use npm audit fix for automatic patches. Consider tools like Snyk or Dependabot for continuous monitoring.
-
Insufficient Security Headers
- Explanation: Default HTTP responses lack headers that protect against clickjacking, MIME sniffing, and XSS. Developers often assume the browser handles these risks.
- Fix: Implement security headers on every response. Use the
helmet library or manually set headers like X-Content-Type-Options, Strict-Transport-Security, and X-Frame-Options.
-
Using Current Node Releases in Production
- Explanation: Current releases include experimental features and have shorter support lifecycles. They may introduce breaking changes or lack security patches for older versions.
- Fix: Pin the
engines field in package.json to an LTS version. Use CI to enforce version constraints.
-
Global node_modules Pollution
- Explanation: Installing packages globally can cause version conflicts between projects and lead to non-reproducible builds.
- Fix: Always install dependencies locally. Use
npx for running CLI tools. Rely on package.json scripts for project commands.
Production Bundle
This section provides actionable artifacts for deploying a secure, maintainable Node.js service.
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Microservice / API Gateway | Core http + TypeScript | Minimal overhead, fast cold starts, full control | Low infrastructure cost |
| Enterprise Monolith | NestJS or Express | Rich ecosystem, dependency injection, rapid development | Higher memory footprint |
| Real-time Application | ws or Socket.io | Native WebSocket support, event-driven architecture | Medium complexity |
| Serverless Function | Core http or minimal framework | Reduced bundle size improves invocation latency | Lower compute cost |
Configuration Template
.env.example
# Service Configuration
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
LOG_LEVEL=info
# Secrets (Never commit actual values)
# DATABASE_URL=postgresql://user:pass@host:5432/db
# JWT_SECRET=your-super-secret-key
Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "--enable-source-maps", "dist/index.js"]
Quick Start Guide
- Initialize Environment:
fnm install --lts
fnm use --lts
npm init -y
- Install Dependencies:
npm install dotenv helmet zod
npm install -D typescript tsx @types/node
- Create Entry Point:
Copy the
src/index.ts and configuration files from the Core Solution section.
- Run Development Server:
npm run dev
The service will start with hot-reloading, environment validation, and security headers active.
- Build and Start:
npm run build
npm start
This compiles TypeScript and runs the production bundle with source maps enabled.