Intermediate

Developing Node.js Apps with Docker

Build Node images, configure and debug containers and run a multi-tier app with Docker Compose.

Sample application: Carved Rock Fitness (multi-tier architecture with a React front-end, workout-gateway, and run-controller)


Table of Contents

  1. Course Overview
  2. Building Node Images
  3. Configuring and Running Containers
  4. Debugging Containers
  5. Interactive Debugging with IDEs
  6. Running Multi-tier Applications with Docker Compose
  7. Reference Tables

1. Course Overview

This course covers the complete development lifecycle of Node.js applications in Docker containers:

ModuleContent
Building Node ImagesDockerfile, layers, cache, multi-stage builds
Configuring and Running Containersdocker run, ENV, ARG, volumes, ENTRYPOINT
Debugging Containersdocker logs, docker inspect, docker exec, Node.js debugger
Interactive Debugging with IDEsWebStorm, VS Code with Docker
Running Multi-tier Apps with Docker ComposeNetworking, docker-compose.yml, interdependent services

The sample application used throughout the course is Carved Rock Fitness — a web application with:

  • A React front-end (port 3000)
  • A workout-gateway Node.js/Express (port 9000)
  • A run-controller Node.js/Express (port 8000)

2. Building Node Images

Anatomy of a Node.js Dockerfile

A Dockerfile is a sequence of instructions that defines how to build a Docker image. Each instruction creates a layer in the final image. These layers are cached to speed up subsequent builds.

# Select an appropriate base image
FROM node:16-alpine

# Metadata via labels
LABEL maintainer="dev@carvedrockfitness.com"
LABEL version="1.0"

# Set the working directory inside the container
WORKDIR /app

# Copy ONLY package.json first (cache optimization)
COPY package*.json ./

# Install dependencies (layer cached as long as package.json doesn't change)
RUN npm ci --only=production

# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Copy the rest of the source code
COPY . .

# Document the exposed port
EXPOSE 9000

# Default command on container startup
CMD ["node", "src/index.js"]

Key Dockerfile Instructions

InstructionRoleExample
FROMBase imageFROM node:18-alpine
WORKDIRWorking directoryWORKDIR /app
COPYCopy files (preferred over ADD)COPY package*.json ./
ADDCopy + extract archives/URLsADD archive.tar.gz /app
RUNExecute a command at build timeRUN npm install
CMDDefault command on startupCMD ["node", "index.js"]
ENTRYPOINTFixed entry pointENTRYPOINT ["docker-entrypoint.sh"]
ENVPersistent environment variableENV NODE_ENV=production
ARGBuild-only variableARG NODE_VERSION=18
EXPOSEPort documentationEXPOSE 3000
LABELMetadataLABEL version="1.0"
USERExecution userUSER node
VOLUMEMount pointVOLUME ["/data"]

Important: Prefer COPY over ADD to avoid unexpected behavior. ADD can extract archives and download URLs, which makes behavior less predictable.

Build Cache Optimization

Docker cache works layer by layer. As soon as a layer is invalidated, all subsequent layers must be rebuilt.

Bad practice — invalidates the cache on every code change:

FROM node:18-alpine
WORKDIR /app
# Copying EVERYTHING invalidates the cache whenever any file changes
COPY . .
RUN npm install
CMD ["node", "index.js"]

Good practice — separate package.json from source code:

FROM node:18-alpine
WORKDIR /app
# Copy only package.json first
# npm install only runs when package.json changes
COPY package*.json ./
RUN npm ci --only=production
# Source code last: changes don't invalidate npm install
COPY . .
CMD ["node", "index.js"]

Force rebuild without cache:

docker build --no-cache -t myapp:latest .

.dockerignore

The .dockerignore file excludes files from the build context sent to the Docker daemon. This reduces context size and avoids unnecessarily invalidating the cache.

node_modules
npm-debug.log
.env
.env.*
.git
.gitignore
*.md
dist
coverage
.nyc_output
Dockerfile*
docker-compose*

Diagram: Node.js Architecture in Docker

graph TB
    subgraph Host["Host Machine"]
        subgraph DockerEngine["Docker Engine"]
            subgraph Container["Container: workout-gateway"]
                NodeProcess["Node.js Process\n(src/index.js)"]
                NodeModules["node_modules/"]
                AppCode["Application Code"]
                NodeProcess --> AppCode
            end
        end
        HostPort["Host Port 9000"]
    end
    Client["Browser / Client"]
    Client -->|"HTTP :9000"| HostPort
    HostPort -->|"Port Forward -p 9000:9000"| NodeProcess
    
    style Container fill:#e8f4f8,stroke:#2196F3
    style Host fill:#f5f5f5,stroke:#9e9e9e

Diagram: Multi-stage Build for Production

A multi-stage build creates lightweight production images by separating the build environment from the runtime environment.

flowchart LR
    subgraph Stage1["Stage 1: builder"]
        S1FROM["FROM node:18 AS builder"]
        S1COPY["COPY package*.json ./"]
        S1NPM["RUN npm ci"]
        S1BUILD["RUN npm run build"]
        S1FROM --> S1COPY --> S1NPM --> S1BUILD
    end
    
    subgraph Stage2["Stage 2: production (final image)"]
        S2FROM["FROM node:18-alpine"]
        S2COPY["COPY --from=builder /app/dist ./dist"]
        S2DEPS["COPY --from=builder /app/node_modules ./node_modules"]
        S2CMD["CMD [node, dist/index.js]"]
        S2FROM --> S2COPY --> S2DEPS --> S2CMD
    end
    
    Stage1 -->|"Artifacts copied"| Stage2
    
    style Stage1 fill:#fff3e0,stroke:#FF9800
    style Stage2 fill:#e8f5e9,stroke:#4CAF50

Example multi-stage Dockerfile:

# ── Stage 1: builder ──────────────────────────────────────────────
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── Stage 2: production ───────────────────────────────────────────
FROM node:18-alpine AS production
WORKDIR /app

# Copy only the necessary artifacts from the builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 9000
CMD ["node", "dist/index.js"]

The final image does not include build tools, TypeScript sources, devDependencies, etc. — result: a much smaller and more secure image.


3. Configuring and Running Containers

Essential docker run Commands

# Basic startup
docker run node:18-alpine node --version

# Named container, in background, with port forwarding
docker run -d --name workout-gateway -p 9000:9000 carvedrockfitness/workout-gateway

# Interactive container (for debugging)
docker run -ti --rm node:18-alpine sh

# With environment variable
docker run -e NODE_ENV=production -e PORT=9000 myapp:latest

# Remove the container automatically when done
docker run --rm myapp:latest

# With mounted volume
docker run -v $(pwd)/src:/app/src myapp:latest

# With read-only bind mount
docker run -v $(pwd)/.env:/app/.env:ro myapp:latest

Most used switches:

SwitchDescription
-dDetached (background)
-tiInteractive terminal
--rmRemove after stop
--nameName the container
-p host:containerPort forwarding
-e KEY=valueEnvironment variable
-v src:dstVolume or bind mount
--networkDocker network
--net=hostShare host network

Environment Variables: ENV vs ARG

The fundamental difference between ENV and ARG:

ENVARG
Available during build
Available in the container
Usable before FROM
Overridable at runtimeVia -eNot applicable
Visible in docker inspect
# ARG before FROM: useful to parameterize the base image version
ARG NODE_VERSION=18
FROM node:${NODE_VERSION}-alpine

# ARG after FROM: visible during build, not in the container
ARG BUILD_DATE
RUN echo "Built on: $BUILD_DATE"

# ENV: persistent in the container
ENV NODE_ENV=production
ENV PORT=9000
ENV APP_VERSION=3.2.1

Passing ARG at build time:

docker build --build-arg NODE_VERSION=20 --build-arg BUILD_DATE=$(date) -t myapp .

Overriding ENV at runtime:

docker run -e NODE_ENV=development -e PORT=3000 myapp:latest

Volumes and Bind Mounts

Docker containers are stateless by default. Volumes allow data persistence independent of the container lifecycle.

Two mechanisms:

VolumeBind Mount
Managed byDockerHost filesystem
Location/var/lib/docker/volumes/Any host path
Primary useData persistence (DB)Local development, hot-reload
PortabilityHighDepends on host path
PermissionsManaged by DockerCan be problematic
# Create a named volume
docker volume create appdata

# Use a named volume
docker run -v appdata:/var/lib/postgresql/data postgres:15

# Bind mount (development) — share source code
docker run -v $(pwd)/src:/app/src -p 9000:9000 myapp:dev

# Read-only bind mount
docker run -v $(pwd)/.env:/app/.env:ro myapp:latest

# List volumes
docker volume ls

# Remove a volume
docker volume rm appdata

Diagram: Volumes for Hot-reload in Development

graph LR
    subgraph Host["Host Machine"]
        IDE["VS Code / IDE"]
        HostSrc["./src/ (source code)"]
        HostEnv[".env"]
        IDE -->|edits| HostSrc
    end
    
    subgraph Container["Container: myapp-dev"]
        NodeDev["Node.js + nodemon"]
        ContSrc["/app/src/"]
        ContEnv["/app/.env (read-only)"]
        NodeDev -->|watches| ContSrc
    end
    
    HostSrc <-->|"bind mount -v ./src:/app/src"| ContSrc
    HostEnv -->|"bind mount :ro"| ContEnv
    
    NodeDev -->|"auto hot-reload"| Reload["♻️ App restarted"]
    
    style Host fill:#fff3e0,stroke:#FF9800
    style Container fill:#e3f2fd,stroke:#2196F3

Development Dockerfile with nodemon:

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
# Source code comes from a bind mount, not copied into the image
EXPOSE 9000
CMD ["npx", "nodemon", "--legacy-watch", "src/index.js"]

Launch in development mode with hot-reload:

docker run -d \
  --name myapp-dev \
  -p 9000:9000 \
  -v $(pwd)/src:/app/src \
  -e NODE_ENV=development \
  myapp:dev

Reading Configuration in Node.js

// Method 1: process.env (standard)
const port = process.env.PORT || 9000;
const dbUrl = process.env.DATABASE_URL;

// Method 2: dotenv module (read a .env file)
// npm install dotenv
require('dotenv').config();
const port = process.env.PORT || 9000;

// Method 3: JSON configuration
const config = JSON.parse(
  require('fs').readFileSync('./config.json', 'utf8')
);

// Method 4: nconf or config packages
// npm install config
const config = require('config');
const port = config.get('server.port');

Example .env (never commit in production):

PORT=9876
NODE_ENV=development
DEBUG=true
DATABASE_URL=postgres://user:password@localhost:5432/mydb

Initializing Containers: CMD and ENTRYPOINT

# CMD alone: default command, replaceable
CMD ["node", "src/index.js"]
# docker run myapp              → runs node src/index.js
# docker run myapp npm test     → runs npm test (replaces CMD)

# ENTRYPOINT: always executed
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "src/index.js"]
# docker run myapp              → entrypoint.sh node src/index.js
# docker run myapp npm test     → entrypoint.sh npm test

Entrypoint script to wait for a database:

#!/bin/sh
# docker-entrypoint.sh

set -e

# Wait for Postgres to be available
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER"; do
  echo "Waiting for PostgreSQL..."
  sleep 2
done

echo "PostgreSQL is ready!"

# Run the command passed as argument
exec "$@"
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "src/index.js"]

4. Debugging Containers

Logging in Containers

Docker containers capture stdout and stderr as logs. Everything logged to the console is accessible via docker logs.

# View logs of a container
docker logs workout-gateway

# Follow logs in real time
docker logs -f workout-gateway

# Logs from a timestamp
docker logs --since 2024-01-01T00:00:00 workout-gateway

# Last N lines only
docker logs --tail 50 workout-gateway

# With timestamps
docker logs -t workout-gateway

Logging with Express and Winston

Winston log levels (lowest to highest): sillydebugverbosehttpinfowarnerror

// npm install winston morgan
const winston = require('winston');
const morgan = require('morgan');
const express = require('express');

const app = express();

// Configure the Winston logger
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  defaultMeta: { service: 'workout-gateway' },
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console({
      format: winston.format.simple()
    })
  ]
});

// If in debug mode, lower the log level
if (process.env.DEBUG === 'true') {
  logger.level = 'debug';
}

// Helper for Morgan → Winston
const stream = {
  write: (message) => logger.http(message.trim())
};

// Morgan middleware to log all HTTP requests
app.use(morgan('combined', { stream }));

// Use the logger in code
logger.info('Server starting...');
logger.error('Connection failed', { error: err.message });
logger.debug('Request payload', { body: req.body });

Alternatives to Winston:

LibraryAdvantagesUsage
console.logNo configQuick debugging only
debugNamespace filteringnpm libraries
winstonMulti-transport, levels, formatProduction applications
pinoVery performant, native JSONHigh performance
bunyanStructured JSONMicroservices

Inspecting and Executing in a Container

# Inspect the complete configuration of a container
docker inspect workout-gateway

# Filter with --format (Go template)
docker inspect --format='{{.NetworkSettings.IPAddress}}' workout-gateway
docker inspect --format='{{.Config.Env}}' workout-gateway

# Open a shell in a running container
docker exec -ti workout-gateway sh

# Execute a single command in a container
docker exec workout-gateway env
docker exec workout-gateway curl http://run-controller:8000/health

# View running processes in a container
docker top workout-gateway

# Real-time statistics
docker stats workout-gateway

Node.js Debugger via CLI

Two Node.js debugging modes:

CommandModeDescription
node inspect index.jsInteractive CLIBuilt-in terminal debugger
node --inspect index.jsRemote (Chrome DevTools / IDE)Exposes port 9229
node --inspect-brk index.jsRemote + pausePauses before the first line
# Launch with remote debugger enabled
docker run -ti --rm \
  -p 9000:9000 \
  -p 9229:9229 \
  myapp:dev \
  node --inspect=0.0.0.0:9229 src/index.js

# Access via Chrome: chrome://inspect
# Access via VS Code: attach to port 9229

5. Interactive Debugging with IDEs

WebStorm

WebStorm is part of the IntelliJ family (JetBrains). It offers native Node.js and Docker support.

Docker configuration in WebStorm:

  1. FileSettingsBuild, Execution, DeploymentDocker
  2. Add a Docker environment (Docker for Windows, Docker Machine, or TCP Socket)
  3. Create a Run Configuration of type Dockerfile
  4. Create a Node.js configuration with Docker as the Remote Target
  5. Set breakpoints and start debugging

IDEs compatible with the Node.js debugger:

IDEMinimum version
VS Code1.10+
Visual Studio2017+
WebStorm2017.1+
Eclipse+ Wild Web Developer extension

VS Code

VS Code offers a better Node.js/Docker debugging experience than WebStorm according to the course.

Automatically generate a Dockerfile in VS Code:

  1. Open the command palette (Ctrl+Shift+P)
  2. Search for “Add Docker Files to Workspace”
  3. Select Node.js, the port, and whether docker-compose is wanted
  4. VS Code generates Dockerfile, .dockerignore, and a debug configuration

Debug configuration .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Docker: Attach to Node",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "address": "localhost",
      "localRoot": "${workspaceFolder}",
      "remoteRoot": "/app",
      "restart": true,
      "protocol": "inspector"
    },
    {
      "name": "Docker Node.js Launch",
      "type": "docker",
      "request": "launch",
      "preLaunchTask": "docker-run: debug",
      "platform": "node",
      "node": {
        "package": "${workspaceFolder}/package.json"
      }
    }
  ]
}

Tasks configuration .vscode/tasks.json:

{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "docker-build",
      "label": "docker-build",
      "platform": "node",
      "dockerBuild": {
        "tag": "myapp:latest",
        "dockerfile": "${workspaceFolder}/Dockerfile",
        "context": "${workspaceFolder}"
      }
    },
    {
      "type": "docker-run",
      "label": "docker-run: debug",
      "dependsOn": ["docker-build"],
      "dockerRun": {
        "ports": [
          { "hostPort": 9000, "containerPort": 9000 },
          { "hostPort": 9229, "containerPort": 9229 }
        ]
      },
      "node": {
        "enableDebugging": true
      }
    }
  ]
}

6. Running Multi-tier Applications with Docker Compose

Docker Networking

Docker offers several network modes:

ModeDescriptionUsage
Bridge (default)Shared bridge network docker0Isolated containers, little service discovery
User-defined BridgeCustom bridge with auto DNSRecommended for multi-containers
HostShares the host’s networkMaximum performance, less isolation
NoneNo networkCompletely isolated containers
OverlayMulti-host (Docker Swarm)Distributed clusters

Network commands:

# Create a user-defined network
docker network create frontend-network
docker network create backend-network

# Connect a container to a network
docker network connect backend-network run-controller

# Disconnect
docker network disconnect frontend-network run-controller

# Inspect a network
docker network inspect frontend-network

# List networks
docker network ls

Advantages of User-defined Bridge vs Default Bridge:

  • Automatic DNS: containers find each other by name
  • Better isolation: containers on different networks can’t see each other
  • Attach/detach on the fly without restarting
  • Multiple networks possible per container

Port Forwarding

# Simple forwarding: host:container
docker run -p 3000:3000 myapp

# Random port on the host
docker run -p 3000 myapp

# Bind to a specific interface
docker run -p 127.0.0.1:3000:3000 myapp

# All EXPOSE ports from the Dockerfile
docker run -P myapp
# Document ports in the Dockerfile (does not actually publish)
EXPOSE 9000/tcp
EXPOSE 9229/tcp

Docker Compose: YAML Syntax

Structure of a docker-compose.yml file:

version: "3.9"

services:
  # ─── React Front-end ──────────────────────────────────────────
  front:
    image: carvedrockfitness/front:v1.3
    build:
      context: ./front
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - REACT_APP_BACKEND_URL=http://localhost:9000
    depends_on:
      - workout-gateway
    networks:
      - frontend

  # ─── Node.js API Gateway ──────────────────────────────────────
  workout-gateway:
    image: carvedrockfitness/workout-gateway:v1.3
    build:
      context: ./workout-gateway
    ports:
      - "9000:9000"
    environment:
      - NODE_ENV=production
      - PORT=9000
      - RUN_CONTROLLER_URL=http://run-controller:8000
    depends_on:
      - run-controller
    networks:
      - frontend
      - backend
    volumes:
      - gateway-logs:/app/logs

  # ─── Node.js Backend ──────────────────────────────────────────
  run-controller:
    build:
      context: ./run-controller
    environment:
      - NODE_ENV=production
      - PORT=8000
      - DATABASE_URL=postgres://user:password@db:5432/fitness
    depends_on:
      db:
        condition: service_healthy
    networks:
      - backend

  # ─── PostgreSQL Database ──────────────────────────────────────
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=fitness
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 10s
      timeout: 5s
      retries: 5

# ─── Named Volumes ──────────────────────────────────────────────
volumes:
  pgdata:
  gateway-logs:

# ─── Networks ───────────────────────────────────────────────────
networks:
  frontend:
  backend:

Docker Compose for development (override):

# docker-compose.override.yml (loaded automatically with docker-compose up)
version: "3.9"

services:
  workout-gateway:
    build:
      target: development
    volumes:
      - ./workout-gateway/src:/app/src   # hot-reload
    environment:
      - NODE_ENV=development
      - DEBUG=true
    ports:
      - "9229:9229"  # Node.js debugger port
    command: ["node", "--inspect=0.0.0.0:9229", "src/index.js"]

  run-controller:
    volumes:
      - ./run-controller/src:/app/src
    environment:
      - NODE_ENV=development

Diagram: Docker Compose Multi-services

graph TB
    subgraph DockerCompose["docker-compose.yml"]
        subgraph FrontendNet["Network: frontend"]
            Front["front\nReact :3000"]
            Gateway["workout-gateway\nNode.js :9000"]
        end
        
        subgraph BackendNet["Network: backend"]
            Gateway2["workout-gateway"]
            RunCtrl["run-controller\nNode.js :8000"]
            DB["db\nPostgreSQL :5432"]
        end
        
        Front -->|"depends_on"| Gateway
        Gateway -->|"RUN_CONTROLLER_URL\nhttp://run-controller:8000"| RunCtrl
        RunCtrl -->|"DATABASE_URL"| DB
        
        DB --> PGVol[("pgdata\nvolume")]
    end
    
    Browser["Browser"]
    Browser -->|"http://localhost:3000"| Front
    Browser -->|"http://localhost:9000"| Gateway
    
    style FrontendNet fill:#e3f2fd,stroke:#1565C0
    style BackendNet fill:#f3e5f5,stroke:#6A1B9A
    style DockerCompose fill:#f5f5f5,stroke:#616161

Automatic DNS: In a user-defined bridge, workout-gateway can reach run-controller simply by its service name. Docker automatically resolves run-controller → container IP.

Essential Docker Compose Commands

# Start all services (build if necessary)
docker-compose up

# In the background
docker-compose up -d

# Forced image rebuild
docker-compose up --build

# Stop and remove containers, networks, volumes
docker-compose down

# Also remove named volumes
docker-compose down -v

# Stop without removing
docker-compose stop

# Restart a service
docker-compose restart workout-gateway

# Build images without starting
docker-compose build

# Logs for all services
docker-compose logs -f

# Logs for a specific service
docker-compose logs -f workout-gateway

# Execute a command in a service
docker-compose exec workout-gateway sh

# Run a one-shot container
docker-compose run --rm workout-gateway npm test

# View service status
docker-compose ps

# Scale a service
docker-compose up --scale run-controller=3

Testing multiple Node.js versions with Docker Compose:

# docker-compose.test.yml
version: "3.9"
services:
  test-node18:
    build:
      args:
        NODE_VERSION: "18"
    command: npm test
  test-node20:
    build:
      args:
        NODE_VERSION: "20"
    command: npm test
docker-compose -f docker-compose.test.yml up --abort-on-container-exit

7. Reference Tables

Official Node.js Images

ImageApproximate SizeRecommended Usage
node:20~950 MBBuild, development
node:20-slim~240 MBGeneral production
node:20-alpine~60 MBOptimized production
node:20-bullseye~950 MBMaximum compatibility (Debian)
node:20-bullseye-slim~240 MBProduction on Debian
node:lts~950 MBAlways the latest LTS
node:lts-alpine~60 MBLTS + Alpine
node:current-alpine~60 MBLatest version + Alpine

Alpine Linux: very lightweight images based on musl libc and BusyBox. Sometimes problematic with native npm modules that expect glibc. Prefer -slim (Debian) in case of issues.

Docker Optimizations for Node.js

OptimizationDescriptionImpact
Separate COPY package.json from codeCaches npm install as long as dependencies don’t change⬆️ Build speed
npm ci instead of npm installInstalls exactly what is in package-lock.json✅ Reproducibility
--only=productionDoes not install devDependencies⬇️ Image size
Multi-stage buildSeparates build and runtime⬇️⬇️ Image size
Alpine base imageMinimal base image⬇️ Image size
.dockerignoreExcludes node_modules, .git, etc.⬆️ Build speed
Non-root userUSER node or dedicated user✅ Security
COPY before RUNMinimize invalidated layers⬆️ Cache
--no-cache for Alpine packagesapk add --no-cache⬇️ Image size
LabelsMetadata for traceability✅ Maintenance

Complete optimized example:

ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS base

LABEL maintainer="team@company.com"
LABEL org.opencontainers.image.source="https://github.com/org/repo"

# ── Dependencies ──────────────────────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# ── Builder ───────────────────────────────────────────────────────
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── Production ────────────────────────────────────────────────────
FROM base AS production
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=9000

# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --chown=appuser:appgroup package.json ./

USER appuser

EXPOSE 9000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:9000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"

CMD ["node", "dist/index.js"]

Volumes vs Bind Mounts Comparison

CriterionNamed VolumeBind Mounttmpfs Mount
Managed by Docker
Persists after container stop❌ (memory)
Shareable between containers
Backupable with docker volume
Performance (Linux)⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Performance (macOS/Windows)⭐⭐⭐⭐⭐ (virtualization)N/A
Typical usageDB, persistent dataDevelopment, hot-reloadCache, temporary sensitive data
Syntax-v pgdata:/var/lib/pgsql-v $(pwd)/src:/app/src--tmpfs /tmp

Best practices summary:

  1. Always use a specific base image (not :latest)
  2. Run containers with a non-root user
  3. Separate package.json from source code to optimize cache
  4. Use multi-stage builds for production images
  5. Always have a .dockerignore with at least node_modules
  6. Use user-defined bridges for inter-container communication
  7. Use depends_on with healthcheck for critical dependencies (databases)
  8. Never store secrets in images — use environment variables or secrets managers

Search Terms

node.js · developing · apps · docker · containerization · containers · kubernetes · compose · diagram · volumes · bind · commands · debugging · dockerfile · essential · images · logging · mounts · running

Interested in this course?

Contact us to book it or get a custom training plan for your team.