Understand containers from scratch -- what they are, why they exist, how to use them, and how Docker Compose lets you run entire application stacks with a single command. No prior knowledge needed.
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.
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.
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.
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.
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.
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 |
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.
There are three things you need to understand: images, containers, and registries.
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.
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.
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.
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:
# 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"]
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.
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"]
| 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 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.
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.
# 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
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.
# 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
# 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
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.
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 (-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.
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
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.
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.
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.
# 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:
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.
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
# 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
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).
Here's a complete setup for a typical full-stack app with a React frontend, Node.js API, PostgreSQL database, and Redis cache.
my-app/
frontend/
Dockerfile
package.json
src/
backend/
Dockerfile
package.json
src/
docker-compose.yml
.env
# 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
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
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:
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.
-slim or -alpine base images -- node:20-slim is ~200MB vs node:20 at ~1GB. Smaller images build faster and have fewer security vulnerabilities.npm install step. Only re-install when package.json changes..dockerignore -- At minimum, exclude node_modules, .git, and .env.depends_on with health checks -- Ensures services actually start in the right order and are ready.postgres:16, not postgres:latest. latest changes without warning and can break your setup.USER node (or a non-root user) in your Dockerfile for production..env files (and add them to .gitignore) or Docker secrets.docker compose down -v casually -- The -v flag deletes your volumes. That means your database data is gone.# 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"]
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%.