Introduction
Docker packages your application and its dependencies into a container โ a lightweight, portable unit that runs identically on any machine. This eliminates “works on my machine” problems and makes deployments reproducible.
Prerequisites: Docker installed (docs.docker.com/get-docker), basic command line knowledge.
Core Concepts
Image = a read-only template (like a class)
Container = a running instance of an image (like an object)
Registry = a storage service for images (Docker Hub, ECR, GCR)
Dockerfile = instructions to build an image
Your First Dockerfile
# Start from an official base image
FROM node:20-alpine
# Set working directory inside the container
WORKDIR /app
# Copy dependency files first (for layer caching)
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application code
COPY . .
# Document which port the app uses
EXPOSE 3000
# Command to run when container starts
CMD ["node", "src/index.js"]
Build and run:
# Build the image
docker build -t myapp:1.0 .
# Run a container from the image
docker run -p 3000:3000 myapp:1.0
# Run in background (detached)
docker run -d -p 3000:3000 --name myapp myapp:1.0
# Check running containers
docker ps
# View logs
docker logs myapp
docker logs -f myapp # follow
# Stop and remove
docker stop myapp
docker rm myapp
Multi-Stage Builds
Multi-stage builds produce smaller production images by separating build tools from runtime:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # install ALL deps (including devDeps)
COPY . .
RUN npm run build # compile TypeScript, bundle, etc.
# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
# Only copy what's needed to run
COPY package*.json ./
RUN npm ci --only=production # production deps only
COPY --from=builder /app/dist ./dist
# Run as non-root user (security)
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "dist/index.js"]
Size comparison:
Without multi-stage: ~1.2GB (includes build tools, devDependencies)
With multi-stage: ~180MB (only runtime + production deps)
.dockerignore
Always create .dockerignore to exclude unnecessary files:
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
dist
coverage
*.test.ts
*.spec.ts
README.md
docker-compose*.yml
.dockerignore
Dockerfile*
Without this, COPY . . copies node_modules (hundreds of MB) into the build context.
Essential Docker Commands
# Images
docker images # list local images
docker pull node:20-alpine # download image
docker rmi myapp:1.0 # remove image
docker image prune # remove unused images
# Containers
docker ps # running containers
docker ps -a # all containers (including stopped)
docker run -it node:20-alpine sh # interactive shell
docker exec -it myapp sh # shell in running container
docker inspect myapp # detailed container info
docker stats # live resource usage
# Build
docker build -t myapp:1.0 .
docker build -t myapp:1.0 -f Dockerfile.prod . # custom Dockerfile
docker build --no-cache -t myapp:1.0 . # ignore cache
# Registry
docker tag myapp:1.0 registry.example.com/myapp:1.0
docker push registry.example.com/myapp:1.0
docker pull registry.example.com/myapp:1.0
Environment Variables
# Set defaults in Dockerfile
ENV NODE_ENV=production
ENV PORT=3000
# Override at runtime
docker run -e NODE_ENV=staging -e PORT=8080 myapp:1.0
# Load from file
docker run --env-file .env.production myapp:1.0
Volumes: Persisting Data
# Named volume (managed by Docker)
docker run -v myapp-data:/app/data myapp:1.0
# Bind mount (maps host directory)
docker run -v $(pwd)/data:/app/data myapp:1.0
# Read-only bind mount
docker run -v $(pwd)/config:/app/config:ro myapp:1.0
Networking
# Create a network
docker network create myapp-network
# Run containers on the same network (they can reach each other by name)
docker run -d --name db --network myapp-network postgres:16
docker run -d --name app --network myapp-network -e DB_HOST=db myapp:1.0
# List networks
docker network ls
docker network inspect myapp-network
Dockerfile Best Practices
Layer Caching
Docker caches each layer. Put frequently-changing files last:
# GOOD: dependencies cached separately from code
COPY package*.json ./
RUN npm ci
COPY . . # code changes don't invalidate npm ci cache
# BAD: any code change invalidates npm ci
COPY . .
RUN npm ci
Minimize Layers
# BAD: 3 separate RUN layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# GOOD: single layer, smaller image
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
Use Specific Tags
# BAD: latest can change unexpectedly
FROM node:latest
# GOOD: pin to specific version
FROM node:20.11.0-alpine3.19
Non-Root User
# Create and use a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
Debugging Containers
# Get a shell in a running container
docker exec -it myapp sh
# Get a shell in a stopped container
docker run -it --entrypoint sh myapp:1.0
# Copy files out of a container
docker cp myapp:/app/logs/error.log ./error.log
# View filesystem changes
docker diff myapp
# Check resource usage
docker stats myapp
# Inspect image layers
docker history myapp:1.0
Complete Node.js Example
# Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
# Development
docker build --target development -t myapp:dev .
docker run -v $(pwd)/src:/app/src -p 3000:3000 myapp:dev
# Production
docker build --target production -t myapp:prod .
docker run -p 3000:3000 myapp:prod
Comments