Table of Contents

1. The Problem Docker Solves 2. Containers vs Virtual Machines 3. Core Concepts (Images, Containers, Registries) 4. Writing a Dockerfile 5. Essential Docker Commands 6. Volumes & Networking 7. Docker Compose -- Multi-Container Apps 8. Docker Compose Commands 9. Real-World Example: Full Stack App 10. Best Practices & Common Mistakes

1. The Problem Docker Solves

You've definitely heard the phrase "it works on my machine". That's the entire reason Docker exists.

Here's the scenario: you build a Node.js app on your laptop. It uses Node 20, PostgreSQL 15, Redis, and a specific version of some npm package. Everything works perfectly. Then you send it to your teammate, and it crashes. Why? Because they have Node 18, a different PostgreSQL version, and their Redis isn't even running.

The Real-World Analogy

Think of it like cooking. You make an amazing meal in your kitchen. Now imagine you could shrink your entire kitchen -- the oven, the ingredients, the utensils, the recipe -- into a box and ship it to someone. When they open the box, they get the exact same kitchen and can make the exact same meal. That's what Docker does for software.

Instead of sending just the recipe (your code) and hoping they have the right ingredients (dependencies), you send everything packaged together.

Docker packages your application along with everything it needs to run -- the OS libraries, the runtime, the dependencies, the config files -- into a single portable unit called a container. This container runs the same way on your laptop, your teammate's laptop, a staging server, or AWS.

Why This Matters for Your Career

Almost every company uses Docker now. If you can't containerize your apps, you'll struggle in DevOps, backend engineering, and even many frontend roles. It's become as fundamental as knowing Git.

2. Containers vs Virtual Machines

Before Docker, we used Virtual Machines (VMs) to solve the "it works on my machine" problem. Both solve the same problem but in very different ways.

Virtual Machines

A VM is like renting an entire apartment. You get your own walls, your own plumbing, your own electricity -- a full copy of an operating system running on top of your host OS. This is heavy. Each VM might use 1-2 GB of RAM just to exist, and it takes minutes to start.

Containers

A container is like renting a room in a shared house. You have your own private space (your app, your files, your dependencies), but you share the walls, plumbing, and electricity (the host OS kernel) with other rooms. This is lightweight. Containers use megabytes of RAM and start in seconds.

Feature Virtual Machine Container
Boot time Minutes Seconds
Size GBs (full OS) MBs (just app + deps)
Isolation Full (separate kernel) Process-level (shared kernel)
Performance Overhead from hypervisor Near-native speed
Density ~10 per host ~100+ per host
Use case Running different OSes Running isolated apps
Key Insight

Containers aren't a replacement for VMs -- they solve different problems. VMs are for when you need full OS isolation (like running Windows on a Linux host). Containers are for when you need to package and run applications consistently.

3. Core Concepts

There are three things you need to understand: images, containers, and registries.

Images

An image is a blueprint -- a read-only template that contains your application code, runtime, libraries, and everything it needs. Think of it like a class in programming: it defines what something looks like, but it's not running yet.

Images are built in layers. Each instruction in your Dockerfile creates a new layer. Docker caches these layers, so if you change line 8 of your Dockerfile, only layers 8+ get rebuilt -- everything before is cached. This makes builds fast.

Containers

A container is a running instance of an image. If the image is the class, the container is the object. You can run multiple containers from the same image, just like you can create multiple objects from one class.

Each container gets its own isolated filesystem, networking, and process space. It thinks it's the only thing running on the machine.

Registries

A registry is like GitHub but for Docker images. The biggest public registry is Docker Hub. When you type docker pull node:20, Docker downloads the Node.js 20 image from Docker Hub. You can also push your own images there, or use private registries like AWS ECR or GitHub Container Registry.

The Relationship:
Dockerfile → (docker build) → Image → (docker run) → Container

Or in OOP terms:
Dockerfile = Source Code  |  Image = Class  |  Container = Object Instance

4. Writing a Dockerfile

A Dockerfile is a text file with instructions that tell Docker how to build your image. Each line is a command. Let's break down a real one:

Basic Node.js Dockerfile

# Start from the official Node.js 20 image (based on Debian Linux)
FROM node:20-slim

# Set the working directory inside the container
WORKDIR /app

# Copy package files first (for better caching)
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci

# Copy the rest of your source code
COPY . .

# Tell Docker this app listens on port 3000
EXPOSE 3000

# The command to run when the container starts
CMD ["node", "server.js"]
Line-by-Line Explanation

FROM node:20-slim -- Every Dockerfile starts with FROM. This says "start with the Node 20 image". The -slim variant is smaller because it doesn't include unnecessary tools. You're building on top of someone else's image.

WORKDIR /app -- Like doing cd /app inside the container. All subsequent commands run from here. If the directory doesn't exist, Docker creates it.

COPY package.json package-lock.json ./ -- Copy just the package files first. Why? Because Docker caches layers. If your package files haven't changed, Docker skips the npm install step on the next build. This saves minutes.

RUN npm ci -- Run a command during the build. npm ci is like npm install but stricter -- it uses the exact versions from your lock file. Good for reproducibility.

COPY . . -- Copy everything else. This is after npm install so changing your source code doesn't invalidate the dependency cache.

EXPOSE 3000 -- Documentation that says "this container uses port 3000". It doesn't actually open the port -- that happens at docker run time.

CMD ["node", "server.js"] -- The default command that runs when you start the container. There can only be one CMD.

Dockerfile Layer Caching Rules:

1. Each instruction creates a layer
2. Layers are cached and reused if unchanged
3. Cache is invalidated when the instruction OR any layer above it changes
4. Once cache is broken, ALL subsequent layers are rebuilt

Consequence: Put frequently-changing instructions (COPY source code) LAST. Put rarely-changing instructions (install dependencies) FIRST.

Python Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["python", "app.py"]

Common Dockerfile Instructions

Instruction What It Does Example
FROM Base image to build on FROM python:3.12
WORKDIR Set working directory WORKDIR /app
COPY Copy files from host to container COPY . .
RUN Execute command during build RUN apt-get update
CMD Default command at runtime CMD ["node", "index.js"]
EXPOSE Document which port the app uses EXPOSE 3000
ENV Set environment variables ENV NODE_ENV=production
ARG Build-time variables ARG VERSION=1.0
ENTRYPOINT Fixed command (CMD becomes args) ENTRYPOINT ["python"]
CMD vs ENTRYPOINT

CMD sets the default command but can be overridden: docker run myapp bash replaces the CMD with bash.

ENTRYPOINT sets a fixed command. If you use both, CMD becomes the default arguments to ENTRYPOINT. Example: ENTRYPOINT ["python"] + CMD ["app.py"] runs python app.py by default, but docker run myapp test.py runs python test.py.

.dockerignore

Just like .gitignore, a .dockerignore file tells Docker what NOT to copy. Always create one:

node_modules
.git
.env
*.log
dist
.DS_Store

Without this, COPY . . would copy your entire node_modules folder into the image (even though you're running npm install inside the container). That wastes time and bloats your image.

5. Essential Docker Commands

Building and Running

# Build an image from a Dockerfile in the current directory
# -t names/tags the image so you can reference it later
docker build -t myapp .

# Run a container from the image
# -p maps host port 3000 to container port 3000
docker run -p 3000:3000 myapp

# Run in detached mode (background) with a name
docker run -d --name my-server -p 3000:3000 myapp

# Run with environment variables
docker run -e DATABASE_URL=postgres://... -p 3000:3000 myapp

# Run interactively (useful for debugging)
docker run -it myapp bash
Understanding Port Mapping: -p 3000:3000

The format is -p HOST_PORT:CONTAINER_PORT. Your container's app listens on port 3000 inside the container. The -p 3000:3000 says "when someone hits port 3000 on my machine, forward it to port 3000 inside the container".

You can map to different ports: -p 8080:3000 means "hit localhost:8080 and it goes to port 3000 in the container". This is useful when running multiple containers that all use port 3000 internally.

Managing Containers

# List running containers
docker ps

# List ALL containers (including stopped)
docker ps -a

# Stop a running container
docker stop my-server

# Start a stopped container
docker start my-server

# Remove a stopped container
docker rm my-server

# Stop and remove in one step
docker rm -f my-server

# View container logs
docker logs my-server

# Follow logs in real-time (like tail -f)
docker logs -f my-server

# Execute a command in a running container
docker exec -it my-server bash

Managing Images

# List all images
docker images

# Pull an image from Docker Hub
docker pull postgres:16

# Remove an image
docker rmi myapp

# Remove all unused images, containers, networks
docker system prune

# See how much disk space Docker is using
docker system df

6. Volumes & Networking

The Problem: Containers Are Ephemeral

When a container is deleted, everything inside it is gone. If you're running a PostgreSQL container and you docker rm it, all your database data disappears. That's where volumes come in.

Volumes

A volume is a folder on your host machine that's mounted into the container. Data written to the volume persists even after the container is deleted.

# Named volume (Docker manages the location)
docker run -d --name db \
  -v pgdata:/var/lib/postgresql/data \
  -p 5432:5432 \
  postgres:16

# Bind mount (you choose the location)
# Maps your local ./src folder to /app/src in the container
docker run -d --name dev \
  -v $(pwd)/src:/app/src \
  -p 3000:3000 \
  myapp
Named Volumes vs Bind Mounts

Named volumes (-v pgdata:/data): Docker manages where the data lives on your host. Best for databases and persistent data. The name pgdata lets you reference it later.

Bind mounts (-v ./src:/app/src): You specify the exact host path. Best for development -- when you edit ./src/index.js on your laptop, the change is instantly reflected inside the container. No rebuild needed.

Networking

Containers are isolated by default -- they can't talk to each other or the outside world unless you set up networking.

# Create a network
docker network create mynet

# Run containers on the same network
docker run -d --name db --network mynet postgres:16
docker run -d --name api --network mynet -p 3000:3000 myapp

# Now "api" can connect to "db" using the container name as hostname
# Connection string: postgres://user:pass@db:5432/mydb
Container DNS

When containers are on the same Docker network, they can reach each other by container name. So instead of using an IP address, your Node.js app connects to db:5432 where db is the name of the PostgreSQL container. Docker resolves the name to the right IP automatically.

7. Docker Compose -- Multi-Container Apps

Running individual docker run commands with all those flags gets tedious fast, especially when you have multiple containers that need to talk to each other. That's what Docker Compose solves.

Docker Compose lets you define your entire application stack in a single YAML file (docker-compose.yml), then start everything with one command.

The Analogy

If a Dockerfile is the recipe for one dish, docker-compose.yml is the menu for the entire restaurant. It says "I need this web server, this database, this cache, and this queue -- here's how they're connected, here's their config, and here's what ports they use". Then docker compose up opens the restaurant.

Basic docker-compose.yml

# docker-compose.yml
services:
  # Your Node.js API
  api:
    build: .                    # Build from Dockerfile in current directory
    ports:
      - "3000:3000"             # Map port 3000
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/mydb
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
    volumes:
      - ./src:/app/src          # Bind mount for hot reload in dev

  # PostgreSQL Database
  db:
    image: postgres:16          # Use official image from Docker Hub
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data   # Persist data

  # Redis Cache
  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

# Define named volumes
volumes:
  pgdata:
Breaking This Down

services: -- Each service is a container. Here we have three: api, db, and cache.

build: . -- Build this service from the Dockerfile in the current directory. If you use image: postgres:16 instead, Docker pulls a pre-built image.

depends_on: -- Start db and cache before starting api. Note: this only controls startup order, not whether the database is actually ready to accept connections.

environment: -- Set environment variables. Notice the database URL uses db as the hostname -- that's the service name. Docker Compose automatically creates a network where services can reach each other by name.

volumes: -- At the service level, mount volumes. At the top level, declare named volumes that Docker manages.

Service Configuration Options

services:
  myservice:
    image: node:20              # Use a pre-built image
    # OR
    build:                      # Build from Dockerfile
      context: ./backend        # Directory containing Dockerfile
      dockerfile: Dockerfile.dev  # Custom Dockerfile name

    ports:
      - "3000:3000"             # HOST:CONTAINER

    environment:                # Inline env vars
      - NODE_ENV=development
    env_file:                   # Or load from file
      - .env

    volumes:
      - ./code:/app             # Bind mount
      - nodemodules:/app/node_modules  # Named volume

    restart: unless-stopped     # Restart policy

    command: npm run dev        # Override the CMD from Dockerfile

    depends_on:
      db:
        condition: service_healthy  # Wait for health check

    healthcheck:                # Define health check
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

8. Docker Compose Commands

# Start all services (build if needed)
docker compose up

# Start in detached mode (background)
docker compose up -d

# Rebuild images before starting
docker compose up --build

# Stop all services
docker compose down

# Stop and remove volumes (deletes database data!)
docker compose down -v

# View logs for all services
docker compose logs

# Follow logs for a specific service
docker compose logs -f api

# Run a one-off command in a service
docker compose exec api bash

# List running services
docker compose ps

# Restart a specific service
docker compose restart api

# Scale a service (run multiple instances)
docker compose up -d --scale api=3
docker compose vs docker-compose

The old command was docker-compose (with a hyphen) -- that's V1 and is deprecated. The new command is docker compose (with a space) -- that's V2 and is built into Docker. Always use docker compose (no hyphen).

9. Real-World Example: Full Stack App

Here's a complete setup for a typical full-stack app with a React frontend, Node.js API, PostgreSQL database, and Redis cache.

Project Structure

my-app/
  frontend/
    Dockerfile
    package.json
    src/
  backend/
    Dockerfile
    package.json
    src/
  docker-compose.yml
  .env

Backend Dockerfile

# backend/Dockerfile
FROM node:20-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

EXPOSE 4000
CMD ["node", "src/server.js"]

Frontend Dockerfile

# frontend/Dockerfile
FROM node:20-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

docker-compose.yml

services:
  frontend:
    build: ./frontend
    ports:
      - "5173:5173"
    volumes:
      - ./frontend/src:/app/src
    depends_on:
      - api

  api:
    build: ./backend
    ports:
      - "4000:4000"
    environment:
      - DATABASE_URL=postgres://dev:devpass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    volumes:
      - ./backend/src:/app/src
    depends_on:
      - db
      - cache

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=dev
      - POSTGRES_PASSWORD=devpass
      - POSTGRES_DB=myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:
One Command to Rule Them All

Run docker compose up and you've got your entire stack running -- frontend on :5173, API on :4000, PostgreSQL on :5432, Redis on :6379. A new developer on your team clones the repo, runs one command, and has a fully working development environment in seconds.

10. Best Practices & Common Mistakes

Do This

Don't Do This

Multi-Stage Build (Production Optimization)

# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production (only the built output)
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Why Multi-Stage?

The first stage installs everything and builds your app. The second stage copies only what's needed to run. Your final image doesn't contain source code, dev dependencies, or build tools -- just the compiled output. This can cut image size by 50-80%.