We’ve all heard it — “but it works on my machine!” The app runs perfectly on our laptop, but the moment we ship it to a coworker or a server, everything breaks. Different Node.js version, missing system library, wrong OS. Docker solves this by packaging our app and everything it needs into a single unit called a container.
A container is like a lightweight, isolated box that has its own filesystem, its own dependencies, its own runtime — completely independent of the host machine. If it runs in the container, it runs everywhere.
Images vs Containers
This is the first thing to nail down. An image is a blueprint. A container is a running instance of that blueprint.
Think of it like a class vs an object in OOP. The class defines the structure, but we can create many objects from it. Same thing — one image, many containers.
Docker Hub
Docker Hub is like npm but for Docker images. Instead of npm install express, we docker pull node:20-alpine. There are official images for almost everything — Node.js, Python, PostgreSQL, Redis, Nginx. We can also push our own images there (or to other registries like GitHub Container Registry).
Essential Commands
These are the commands we’ll use every day.
# Pull an image from Docker Hub
docker pull node:20-alpine
# Run a container from an image
docker run -d \ # -d = detached mode (runs in background)
--name my-app \ # give it a name instead of a random one
-p 3000:3000 \ # map host port 3000 → container port 3000
node:20-alpine
# List running containers
docker ps # only running containers
docker ps -a # all containers (including stopped ones)
# Stop and remove a container
docker stop my-app # gracefully stop
docker rm my-app # remove the container
# Jump inside a running container
docker exec -it my-app sh # open a shell inside the container
# View container logs
docker logs my-app # show all logs
docker logs -f my-app # follow logs in real-time (like tail -f)
Port Mapping
Containers are isolated. By default, nothing inside a container is accessible from the outside. Port mapping creates a tunnel.
The syntax is -p HOST_PORT:CONTAINER_PORT. So -p 8080:3000 means “when someone hits port 8080 on my machine, forward it to port 3000 inside the container.”
# Our Node.js app listens on port 3000 inside the container
# We want to access it on port 8080 on our machine
docker run -d -p 8080:3000 --name api my-api-image
# Now http://localhost:8080 hits the container's port 3000
Volumes — Persistent Data
Here’s the catch with containers — when a container is removed, all its data is gone. Poof. If we’re running a database inside a container (like PostgreSQL), we’d lose all our data every time we restart.
Volumes solve this. They mount a directory from the host machine into the container. Data written to that directory persists even if the container dies.
# Mount a host directory into the container
docker run -d \
--name postgres \
-v /my/local/data:/var/lib/postgresql/data \ # host:container
-e POSTGRES_PASSWORD=secret \
postgres:15
# The database files are stored on our machine at /my/local/data
# Even if we docker rm postgres, the data survives
There are two types of volumes:
- Bind mounts — map a specific host path (
-v /host/path:/container/path) - Named volumes — Docker manages the storage location (
-v pgdata:/var/lib/postgresql/data)
Named volumes are preferred for production because Docker handles the path and cleanup.
Images We’ll Use All the Time
Some images we pull regularly:
node:20-alpine— Node.js 20 on Alpine Linux (tiny, ~50MB)postgres:15— PostgreSQL databaseredis:7-alpine— Redis cachenginx:1.27-alpine— Nginx web serverpython:3.12-slim— Python on slim Debian
The -alpine and -slim tags are smaller variants. Alpine-based images are usually 5-10x smaller than the default ones. Always prefer them unless we need something specific from the full image.
In simple language, Docker packages our app and its entire environment into a portable container — if it runs in the container, it runs anywhere, on any machine.