Skip to main content
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.