Docker is one of those tools that everyone insists you need, but nobody explains clearly. You’ve seen docker-compose up in a README and had no idea what it was doing.

This guide explains Docker without the cloud marketing speak.

The actual problem Docker solves

Have you ever heard “it works on my machine”? That’s the problem.

Your app depends on:

  • A specific Node version
  • Specific system libraries
  • Environment variables
  • A running database
  • OS-specific paths

All of these might differ between your laptop, your teammate’s machine, the CI server, and production. Docker solves this by packaging your app and its entire environment into one portable unit.

Images vs containers

These two terms confuse everyone at first:

An image is a recipe. It’s a read-only snapshot of a filesystem containing your OS, runtime, code, and dependencies. Like a class definition in code.

A container is a running instance of an image. Like an object instantiated from a class. You can run many containers from one image. When the container stops, it’s gone — unless you use volumes.

# Pull an image from Docker Hub
docker pull node:20-alpine

# Run a container from that image
docker run -it node:20-alpine node --version
# → v20.x.x

# Stop and the container is gone

The Dockerfile

A Dockerfile is a script that builds your image. Each instruction adds a layer:

# Start from the official Node 20 Alpine image (small, production-grade)
FROM node:20-alpine

# Set working directory inside the container
WORKDIR /app

# Copy package files first (for layer caching)
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy the rest of your source code
COPY . .

# Build the app
RUN npm run build

# Tell Docker which port the app will listen on
EXPOSE 3000

# Command to run when a container starts
CMD ["node", "dist/server.js"]

Build it:

docker build -t my-app:latest .

Run it:

docker run -p 3000:3000 my-app:latest

Ports: -p host:container

Containers are isolated. A service running inside a container on port 3000 is not visible outside the container unless you expose it.

The -p flag maps host_port:container_port:

# Map container port 3000 to your machine's port 8080
docker run -p 8080:3000 my-app

# Now visit http://localhost:8080 — it hits container port 3000

Volumes: persisting data

Containers are ephemeral — when they stop, everything inside is deleted. But you often need data to survive container restarts (databases, uploads, etc.). Volumes solve this.

# Mount a named volume
docker run -v mydata:/app/data my-app

# Mount a local directory into the container (great for dev)
docker run -v $(pwd):/app my-app

Now /app/data inside the container persists across restarts via the mydata volume.

Docker Compose: running multiple services

Real apps need multiple containers: your app, a database, maybe a cache. Docker Compose lets you define and run them together with a single file.

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:password@db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Then:

# Start everything
docker compose up

# Start in background
docker compose up -d

# Stop everything
docker compose down

# Stop and delete volumes
docker compose down -v

Useful Docker commands

# List running containers
docker ps

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

# List images
docker images

# View logs from a container
docker logs my-container

# Get a shell inside a running container
docker exec -it my-container sh

# Remove stopped containers
docker container prune

# Remove unused images
docker image prune

# Remove everything (use carefully)
docker system prune -a

Layer caching (why order matters)

Docker rebuilds from the first changed layer downward. This is why you copy package.json first and run npm install before copying source code:

# ✅ Smart: package.json rarely changes, so npm install is cached
COPY package*.json ./
RUN npm ci
COPY . .

# ❌ Slow: every code change triggers a full npm install
COPY . .
RUN npm ci

What Docker is NOT

  • Docker is not a VM — it shares the host OS kernel, so containers are much lighter
  • Docker is not just for production — it’s very useful in local dev
  • Docker does not make security automatic — you still need to think about permissions, secrets, and image updates

Once this mental model clicks, you can spin up any service from any README with confidence.