Skip to main content
New2026 CISO Guide to Software Trust87% of public container images ship with high or critical CVEs.Get the guide
CleanStart

Knowledge Hub

Getting Started with Node.js on CleanStart

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 -y

Step 2: Install Dependencies

npm install express pino pino-prettynpm install --save-dev nodemon

Create 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/data

Multi-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 --init

Create 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/jest

Create 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 -- --coverage

Production 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:latest

Structured 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 appuser

Read-Only Filesystem (when possible)

RUN chmod -R a-w /app/node_modules

Dependency Scanning

npm auditnpm audit fixnpm install npm@latest -g  # Update npm first

Security Headers (Express)

const helmet = require('helmet');app.use(helmet());

Performance Tuning

NODE_ENV Production

ENV NODE_ENV=production

Node.js Memory Limits

docker run \    -e NODE_OPTIONS="--max-old-space-size=512" \    my-node-app:latest

Worker 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=info

Image Options

Image

Use Case

cleanstart/node:22

Latest LTS, new features

cleanstart/node:20

Stable production (recommended)

cleanstart/node:18

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: 512Mi

Next Steps

Advanced Node.js Deployment. CleanStart Image Registry. Kubernetes Deployment Guide.