Building Node.js Applications with CleanStart
CleanStart provides optimized Node.js base images (cleanstart/node) with pre-scanned OS and npm ecosystem, Node.js 18, 20, 22 LTS versions, npm and yarn pre-installed, multi-stage build optimization, and built-in security best practices.
Quick Start: A Simple Express Server
Step 1: Initialize Your Project
mkdir my-node-appcd my-node-appnpm init -yStep 2: Install Dependencies
npm install express pino pino-prettynpm install --save-dev nodemonCreate package.json scripts:
{ "scripts": { "start": "node server.js", "dev": "nodemon server.js", "test": "jest" }}Step 3: Write Your Application
Create server.js:
const express = require('express');const pino = require('pino'); const app = express();const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', options: { colorize: true } }}); app.use(express.json()); app.get('/health', (req, res) => { res.json({ status: 'healthy', version: '1.0' });}); app.get('/api/data', (req, res) => { logger.info('Data endpoint called'); res.json({ message: 'Hello from CleanStart' });}); const PORT = process.env.PORT || 3000;app.listen(PORT, '0.0.0.0', () => { logger.info(`Server running on port ${PORT}`);}); // Graceful shutdownprocess.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down gracefully'); process.exit(0);});Step 4: Create Production Dockerfile
# Build stageFROM cleanstart/node:20 AS builder WORKDIR /app # Copy package filesCOPY package*.json ./ # Install all dependencies (including dev)RUN npm ci # Production stageFROM cleanstart/node:20 WORKDIR /app # Create non-root userRUN useradd -m -u 1000 appuser # Copy node modules from builderCOPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package*.json ./ # Copy application codeCOPY --chown=appuser:appuser . . USER appuserEXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" CMD ["node", "server.js"]Step 5: Build and Run
# Build the imagedocker build -t my-node-app:latest . # Run locallydocker run --rm -p 3000:3000 my-node-app:latest # Testcurl http://localhost:3000/api/dataMulti-Stage Optimization
Reduce final image size by excluding development dependencies:
# Stage 1: DependenciesFROM cleanstart/node:20 AS dependencies WORKDIR /tmpCOPY package*.json ./ RUN npm ci --only=production && \ npm cache clean --force # Stage 2: CodeFROM cleanstart/node:20 AS code WORKDIR /buildCOPY . .RUN npm ci # includes devDependencies for builds # Could compile TypeScript here, run linters, etc.RUN npm run build # Stage 3: RuntimeFROM cleanstart/node:20 WORKDIR /app RUN useradd -m -u 1000 appuser && \ chown -R appuser:appuser /app # Copy only production dependenciesCOPY --from=dependencies /tmp/node_modules ./node_modulesCOPY --from=dependencies /tmp/package*.json ./ # Copy built applicationCOPY --from=code --chown=appuser:appuser /build/dist .COPY --from=code --chown=appuser:appuser /build/*.json ./ USER appuserEXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" CMD ["node", "index.js"]TypeScript Support
Setup TypeScript
npm install --save-dev typescript ts-node @types/nodenpm install --save-dev @types/expressnpx tsc --initCreate tsconfig.json:
{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }}TypeScript Dockerfile
FROM cleanstart/node:20 AS builder WORKDIR /appCOPY package*.json ./COPY tsconfig.json .COPY src ./src RUN npm ci && \ npm run build && \ npm prune --production FROM cleanstart/node:20 WORKDIR /appRUN useradd -m -u 1000 appuser COPY --from=builder /app/node_modules ./node_modulesCOPY --from=builder /app/package*.json ./COPY --from=builder /app/dist ./ USER appuserEXPOSE 3000 CMD ["node", "index.js"]Testing and Quality
Jest Setup
npm install --save-dev jest @types/jestCreate jest.config.js:
module.exports = { testEnvironment: 'node', coverageThreshold: { global: { branches: 70, functions: 70, lines: 70, statements: 70 } }};Test in Docker:
FROM cleanstart/node:20 AS test WORKDIR /appCOPY package*.json ./RUN npm ci COPY . .RUN npm run test -- --coverageProduction Best Practices
Environment Configuration
const config = { port: process.env.PORT || 3000, nodeEnv: process.env.NODE_ENV || 'production', logLevel: process.env.LOG_LEVEL || 'info', databaseUrl: process.env.DATABASE_URL, apiKey: process.env.API_KEY}; if (!config.databaseUrl && config.nodeEnv === 'production') { throw new Error('DATABASE_URL is required in production');} module.exports = config;Run with secrets:
docker run \ -e NODE_ENV=production \ -e LOG_LEVEL=info \ -e DATABASE_URL=postgresql://user:pass@db:5432/app \ -e API_KEY=secret-key \ my-node-app:latestStructured Logging
const logger = pino({ level: process.env.LOG_LEVEL, base: { app: 'my-node-app', environment: process.env.NODE_ENV }}); // Always log to stdout for container orchestrationlogger.info({ event: 'request_received', path: req.path, method: req.method});Graceful Shutdown
let isShuttingDown = false; process.on('SIGTERM', () => { logger.info('SIGTERM received, beginning graceful shutdown'); isShuttingDown = true; server.close(() => { logger.info('Server closed'); process.exit(0); }); // Force shutdown after 30 seconds setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 30000);}); app.use((req, res, next) => { if (isShuttingDown) { res.status(503).json({ error: 'Server shutting down' }); } else { next(); }});Security Hardening
Non-Root User
RUN useradd -m -u 1000 appuserUSER appuserRead-Only Filesystem (when possible)
RUN chmod -R a-w /app/node_modulesDependency Scanning
npm auditnpm audit fixnpm install npm@latest -g # Update npm firstSecurity Headers (Express)
const helmet = require('helmet');app.use(helmet());Performance Tuning
NODE_ENV Production
ENV NODE_ENV=productionNode.js Memory Limits
docker run \ -e NODE_OPTIONS="--max-old-space-size=512" \ my-node-app:latestWorker Threads for CPU-Bound Tasks
const { Worker } = require('worker_threads'); function runExpensiveComputation(data) { return new Promise((resolve, reject) => { const worker = new Worker('./worker.js'); worker.on('message', resolve); worker.on('error', reject); worker.postMessage(data); });}Deploying to Cloud Run
# Build and pushgcloud builds submit --tag gcr.io/my-project/my-node-app # Deploygcloud run deploy my-node-app \ --image gcr.io/my-project/my-node-app:latest \ --platform managed \ --region us-central1 \ --allow-unauthenticated \ --memory 512Mi \ --cpu 1 \ --set-env-vars NODE_ENV=production,LOG_LEVEL=infoImage Options
Image | Use Case |
|---|---|
| Latest LTS, new features |
| Stable production (recommended) |
| Legacy support |
Troubleshooting
Build Issues
Problem: Cannot find module 'xyz' at build or runtime Cause: node_modules not copied in multi-stage build; npm ci not run; dependencies not installed; incorrect COPY paths Fix: Use COPY --from=builder /app/node_modules ./node_modules in production stage. Verify builder stage uses npm ci (deterministic) instead of npm install. Check package.json and package-lock.json both exist in Docker build context. Run npm ls xyz locally to verify package is listed.
Problem: npm install or npm ci fails with permission errors in builder Cause: npm cache directory permissions issue in CleanStart; read-only filesystem; incorrect user permissions Fix: Ensure npm cache is cleaned: RUN npm cache clean --force after install. For CleanStart with UID 65532, npm operations must complete in build stage (before USER directive). If building with non-root user, ensure /app directory ownership: RUN chown -R appuser:appuser /app.
Problem: npm scripts with sh/bash don't work (e.g., npm run build script contains mkdir -p ...) Cause: npm scripts fail because CleanStart production images are shell-less (no /bin/sh). The shell-less nature is the root cause—not a read-only FS issue, but the absence of a shell interpreter. Fix: Convert shell scripts to pure JavaScript or Node.js. Instead of bash script in package.json, create scripts/build.js and call with node scripts/build.js. For npm scripts, structure as: "build": "node build.js" not "build": "mkdir -p dist && ...". In Dockerfile, pre-create directories needed by scripts.
Problem: node_modules permissions broken with non-root UID 65532 Cause: node_modules installed as root, then copied to non-root user; symlinks or binaries not executable Note: CleanStart's default user is UID 65532. If your Dockerfile creates a custom user (e.g., UID 1000), adjust the troubleshooting commands accordingly. Fix: Install as appuser in builder: RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app before npm operations. For UID 65532, ensure builder runs as 1000, but production container can switch UID. Or, copy node_modules and then fix permissions: COPY --from=builder /app/node_modules ./node_modules && chown -R 65532:65532 node_modules.
Problem: Slow Docker builds, every change rebuilds all dependencies Cause: package.json copied late in Dockerfile; Docker layer caching defeated by early COPY . Fix: Restructure Dockerfile to copy package files first: COPY package*.json ./ then RUN npm ci before COPY . .. This way, node_modules caches when dependencies unchanged. See multi-stage example in guide.
Problem: .dockerignore doesn't exclude node_modules from build context Cause: .dockerignore syntax error; .dockerignore file not in build root; Docker doesn't copy but still sends to daemon Fix: Verify .dockerignore exists in same directory as Dockerfile. Content should be on separate lines: node_modules then .git etc. Run docker build --no-cache to force fresh build and see actual context size. Even if excluded, Docker sends to daemon but doesn't copy.
Problem: Native modules (node-gyp) fail to build in CleanStart Cause: C compiler or build tools not available in read-only root FS; missing system headers; architecture mismatch Fix: Install build dependencies in builder stage only: RUN apt-get update && apt-get install -y python3 make g++ && npm ci && rm -rf /var/lib/apt/lists/*. Build-only packages removed after npm ci. Alternatively, use pre-built binaries: many native modules offer prebuilt packages via node-pre-gyp or prebuild-install. Check package docs for binary availability.
Problem: Multi-stage node_modules copy doesn't include transitive dependencies or binaries Cause: Only copying node_modules/ directory without .bin symlinks; missing package.json which npm needs Fix: Copy both node_modules and package.json: COPY --from=builder /app/node_modules ./node_modules and COPY --from=builder /app/package*.json ./. Ensure .bin symlinks intact: ls -la node_modules/.bin/ after copy. If using npm 7+, also copy package-lock.json for consistency.
Runtime Issues
Problem: Application won't start in shell-less container with cleanimg-init Cause: Node.js path not found; npm script tries to use shell; signal handling issues Fix: Use absolute paths to Node.js: ENTRYPOINT ["/cleanimg-init", "--"] CMD ["node", "server.js"]. Don't use shell wrappers. Node.js handles SIGTERM by default, but ensure graceful shutdown code. Test locally: docker run -it --rm my-app /cleanimg-init -- node -v to verify node is accessible.
Problem: Port binding fails - "EADDRINUSE: address already in use" Cause: Binding to localhost (127.0.0.1) instead of 0.0.0.0; PORT env var not respected; previous container still using port Fix: Always bind to 0.0.0.0: app.listen(PORT, '0.0.0.0', ...). Verify PORT env var: const PORT = process.env.PORT || 3000;. Check for orphaned processes: docker ps -a | grep node or on host: lsof -i :3000.
Problem: HEALTHCHECK fails in shell-less container (can't use curl or wget) Cause: curl/wget not available in distroless image; shell required for HTTP checks; health check syntax may be malformed Fix: Use Node.js http module for health check without external tools. Verify the health check command has correct syntax with balanced parentheses and proper callback closure: HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})". Test locally: docker run -it my-app node -e "require('http').get('http://localhost:3000/health', (r) => {console.log(r.statusCode)})". Alternatively, implement a lightweight health check endpoint that doesn't require external tools.
Problem: Graceful shutdown not working; requests dropped during SIGTERM Cause: No signal handler for SIGTERM; process.exit() called immediately; server.close() not draining connections Fix: Implement proper shutdown handler (see guide example with 30-second grace period). Test with docker kill -s TERM container-id and monitor logs. Ensure all connections pooled (database, HTTP clients) are closed before process.exit(). With cleanimg-init, signals are forwarded directly to Node process.
Problem: Memory keeps growing; "JavaScript heap out of memory" errors Cause: Memory leaks in application; unbounded caches; event listeners not cleaned up; large request bodies buffered Fix: Check NODE_OPTIONS --max-old-space-size: increase if genuinely needed docker run -e NODE_OPTIONS="--max-old-space-size=512" my-app. Profile with clinic.js or autocannon. Verify no circular references or event listener leaks. Check for unbounded arrays/caches that grow indefinitely. Monitor heap: node --inspect=0.0.0.0:9229 server.js (requires port exposure for dev).
Problem: UID 65532 (non-root user) can't write to node_modules/.bin or application directories Cause: node_modules installed as root; /tmp read-only; application tries to write to read-only locations Fix: Ensure npm operations run before USER directive in Dockerfile, or build as appuser. For read-only root, mount writable emptyDir volumes: /tmp and /home/appuser/.npm. Test with docker run my-app node -e "require('fs').writeFileSync('/tmp/test.txt', 'ok')".
Problem: npm audit shows vulnerabilities; npm audit fix breaks dependencies Cause: Outdated dependency versions; security patches require semver-breaking updates; transitive dependency conflicts Fix: Run npm audit in CI pipeline, not production. Use npm audit fix to auto-patch compatible updates. For incompatible versions, manual package.json updates needed. Consider Dependabot for automated PR creation. Lock dependencies in production with package-lock.json and npm ci (not npm install).
Problem: NODE_ENV=production is not set; application running in dev mode Cause: ENV NODE_ENV not in Dockerfile; env var not passed at runtime; process.env.NODE_ENV undefined Fix: Add ENV NODE_ENV=production in Dockerfile. Verify: docker run my-app node -e "console.log(process.env.NODE_ENV)". Code should check: if (process.env.NODE_ENV !== 'production') { ... }. Default to production if not set: process.env.NODE_ENV ||= 'production'.
Performance Issues
Problem: HEALTHCHECK hangs or times out Cause: Application slow to respond; timeout too short; node-e approach has syntax errors Fix: Increase --timeout 3s to 10s. Implement lightweight /health endpoint. Verify endpoint responds in host: curl http://localhost:3000/health. Test health check command directly: docker run --rm my-app node -e "require('http').get('http://localhost:3000/health', (r) => {console.log(r.statusCode)})".
Problem: Container startup slow; Node.js application takes 10+ seconds to be ready Cause: Large node_modules parsing; many dependencies; database connections on startup; compiled TypeScript Fix: Defer non-critical initialization. Use lazy loading for heavy libraries. Profile startup: node --profile server.js then analyze profile. Move database connection checks to readiness probe, not startup. For TypeScript, pre-compile to JavaScript and copy compiled output, don't compile in container.
Problem: Memory limits with --max-old-space-size don't help; still OOM Cause: Allocating more heap than physical container limit; leaks outside JavaScript heap; too many concurrent requests Fix: Calculate correctly: if container has 512Mi limit, set max-old-space-size to 300 (leaving 200Mi for runtime). Monitor actual usage with docker stats. If leaks, enable heap snapshot on crash: --abort-on-uncaught-exception flag. Consider workers for CPU-bound tasks to distribute load.
Problem: Multiple worker processes cause memory bloat Cause: Cluster module launching too many workers for container CPU; each worker uses full heap; no memory limits per worker Fix: Calculate workers based on actual container CPU: os.cpus().length may return host CPUs. Instead, read /sys/fs/cgroup/cpuset.cpus for container limit. Or explicitly set: --max-instances 2 if container is 1 CPU. Monitor with docker stats --no-stream and adjust worker count.
CleanStart Production Hardening
Read-Only Root Filesystem
Enforce immutable infrastructure with a read-only root filesystem and minimal writable paths:
apiVersion: v1kind: Podmetadata: name: node-appspec: containers: - name: app image: cleanstart/node:20 securityContext: readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: node-cache mountPath: /home/appuser/.npm - name: logs mountPath: /app/logs volumes: - name: tmp emptyDir: {} - name: node-cache emptyDir: {} - name: logs emptyDir: {}Shell-Less ENTRYPOINT
Remove the shell for minimal attack surface. Update your Dockerfile:
FROM cleanstart/node:20 WORKDIR /appCOPY package*.json ./RUN npm ci --only=production COPY . . # Declarative Image Builder: # Use cleanimg-init as PID 1ENTRYPOINT ["/cleanimg-init", "--"]CMD ["node", "server.js"]cleanimg-customize: Production Configuration
Inject Node.js-specific hardening configuration:
FROM cleanstart/node:20 WORKDIR /appCOPY package*.json ./RUN npm ci --only=production COPY . . # Set Node.js flags for production hardeningENV NODE_OPTIONS="--max-old-space-size=256 --abort-on-uncaught-exception" ENTRYPOINT ["/cleanimg-init", "--"]CMD ["node", "server.js"]Security Context
Complete Kubernetes securityContext for hardened Node.js containers:
apiVersion: apps/v1kind: Deploymentmetadata: name: node-appspec: template: spec: securityContext: fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: cleanstart/node:20 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 capabilities: drop: - ALL resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512MiNext Steps
Advanced Node.js Deployment. CleanStart Image Registry. Kubernetes Deployment Guide.
