Docker — Containers, Images, Commands & More

"It Works on My Machine"

Every developer has said these words. Your app runs flawlessly on your laptop. You push it to the server. It crashes. Dependency version mismatch. Wrong OS. Missing library. Different config. Classic.

Docker was built to kill this problem. Instead of shipping code and hoping the environment matches, you ship the environment itself — bundled with the code, dependencies, and config — as a single portable unit. That unit runs identically everywhere: your laptop, your teammate's laptop, CI, staging, production. Same behavior. Every time.

This guide covers everything you need to get going with Docker: what Docker actually is, containers vs. images (the most common source of confusion), the commands you'll use every day, running databases inside Docker, and what Docker Desktop shows you visually.


What Is Docker?

Docker is an open-source containerization platform. It lets you package an application with everything it needs to run — code, runtime, libraries, environment variables, config files — into a standardized unit called a container.

Containers are often compared to virtual machines (VMs), but they're fundamentally different:

Virtual MachineDocker Container
Includes full OS?YesNo
Boot timeMinutesSeconds
Resource usageHeavy (GBs of RAM)Lightweight (MBs)
IsolationStrong (separate kernel)Process-level (shared kernel)
PortabilityModerateExcellent

A VM runs its own entire operating system on top of your hardware. A container runs as an isolated process that shares the host OS kernel — meaning it starts in seconds and consumes far less memory. You can run dozens of containers on a machine where you'd struggle to run a handful of VMs.

The shipping container analogy: Docker's name is literal. Physical shipping containers transformed global trade by standardizing how cargo is transported — the same container fits on any ship, truck, or train, regardless of what's inside. Docker containers do the same for software.


Images vs. Containers — The Most Important Distinction

This is where most people get confused. Let's clear it up permanently.

Docker Images

An image is a read-only template — a complete snapshot of a filesystem at a point in time. It contains your application code, the runtime it needs (Node.js, Python, PHP, etc.), system libraries, and config. Images are built in layers: each instruction in a Dockerfile adds a new layer on top of the previous one, like adding slides to a stack.

Images are:

  • Static — they never change once built
  • Shareable — stored on Docker Hub and downloaded by anyone
  • Versioned — tagged like mysql:8.0 or node:20-alpine
  • The source of truth — containers are born from images

Docker Containers

A container is a running instance of an image. When you run an image, Docker takes that read-only image, adds a thin writable layer on top, allocates networking, and starts the specified process. That writable layer is where your app writes files, logs, and database records at runtime.

Containers are:

  • Alive — they run, pause, stop, restart
  • Isolated — each has its own filesystem, network, and process space
  • Ephemeral — you can kill and recreate them from the same image in seconds
  • Multiple from one image — spin up 10 containers from the same image simultaneously

The Best Analogies

  • Image = Recipe, Container = Cooked Meal. The recipe doesn't change; every meal made from it is a fresh instance.
  • Image = Class, Container = Object. In OOP terms: the class is the blueprint, an object is an instantiation of it.
  • Image = Blueprint, Container = Building. One blueprint. Many buildings. Each building is independent.

One image → many containers. When you stop a container, it persists. When you delete it, the writable layer is gone — but the image remains, ready to spawn another.


Installing Docker

Head to docker.com and download Docker Desktop for your OS (Windows, macOS, or Linux). Docker Desktop includes:

  • Docker Engine (the core daemon)
  • Docker CLI (the command-line tools)
  • Docker Compose
  • A graphical dashboard

Once installed, verify everything is working:

docker --version
# Docker version 27.x.x

docker run hello-world
# This pulls the hello-world image and runs it.
# If you see a success message, Docker is working.

Docker on Windows — What You Need to Know

Windows has a few quirks that trip people up. This section is for you if you're on Windows 10 or 11.

WSL2 is Required

Docker Desktop on Windows runs containers through WSL2 (Windows Subsystem for Linux 2) — a full Linux kernel embedded inside Windows. Without WSL2, Docker won't start. Here's the setup order:

Step 1: Enable WSL2

Open PowerShell as Administrator and run:

wsl --install

This installs WSL2 and Ubuntu by default. Restart your machine when prompted.

Step 2: Install Docker Desktop

Download Docker Desktop from docker.com. During installation, make sure "Use WSL 2 based engine" is checked (it usually is by default on Windows 11).

Step 3: Verify

Open PowerShell or Command Prompt:

docker --version
docker run hello-world

If you see the hello-world success message, you're ready.

Running a Dockerized Project Someone Shared With You

Say a teammate shares a project and says "just run docker-compose up". Here's exactly what to do:

Step 1: Clone the repo

git clone https://github.com/someone/their-project.git
cd their-project

Step 2: Check what's in the repo

Look for these files — they define the Docker setup:

docker-compose.yml     ← defines all services (web, database, cache, etc.)
Dockerfile             ← recipe for building the app image
.env.example           ← sample environment variables

Step 3: Set up environment variables

copy .env.example .env

Then open .env and fill in any required values (database passwords, API keys, etc.).

Step 4: Start everything

docker-compose up

Or run it in the background so you get your terminal back:

docker-compose up -d

Docker will pull all required images, build the app image from the Dockerfile, create the network, start all services. The first run can take a few minutes. Subsequent runs are instant (images are cached).

Step 5: Open the app

Check docker-compose.yml for port mappings:

ports:
  - "8080:3000"    # visit http://localhost:8080

The Line Ending Problem (CRLF vs LF)

Windows uses \r\n (CRLF) for line endings. Linux (inside Docker containers) uses \n (LF). If you clone a repo on Windows without Git configured correctly, shell scripts inside containers may fail with cryptic errors like bash: bad interpreter: No such file or directory.

Fix: Configure Git globally before cloning

git config --global core.autocrlf input

This tells Git: keep LF in the repo, don't convert on checkout. Works for Docker projects.

Repo-side fix: Projects should ship a .gitattributes file:

* text=auto eol=lf
*.sh text eol=lf
Dockerfile text eol=lf

This forces LF for everyone regardless of their OS.

Where to Put Your Files — WSL or Windows?

Short answer: put Docker projects inside the WSL filesystem.

If you clone a repo to C:\Users\you\projects\, Docker can still run it — but file access from the container goes through a translation layer (Windows → WSL → container) which is slow. Live reloading (e.g. Vite, webpack) may miss file changes.

Put projects inside WSL instead:

# Open WSL terminal (search "Ubuntu" or "WSL" in Start)
cd ~
mkdir projects && cd projects
git clone https://github.com/...

Then open that folder in VS Code from WSL:

code .    # VS Code opens with the WSL Remote extension automatically

Docker volume mounts from the WSL filesystem are fast and reliable.


The Core Docker Commands

These commands cover ~95% of day-to-day Docker usage. Learn them first.

Pulling Images

docker pull nginx
docker pull mysql:8.0
docker pull node:20-alpine

Downloads an image from Docker Hub to your local machine. You don't always need to pull first — docker run will pull automatically if the image isn't already local.

Running Containers

docker run nginx

That's the simplest form. But you'll almost always add options:

docker run -d -p 8080:80 --name my-web nginx

Breaking it down:

  • -d — detached mode (runs in background, returns control to your terminal)
  • -p 8080:80 — port mapping: host-port:container-port. Visit localhost:8080 on your machine to reach the nginx server listening on port 80 inside the container.
  • --name my-web — gives the container a memorable name instead of a random hash

More run flags you'll use:

# Set environment variables
docker run -e NODE_ENV=production my-app

# Mount a volume (explained below)
docker run -v my-data:/app/data my-app

# Put container on a custom network
docker run --network my-network my-app

# Combine everything
docker run -d \
  --name api \
  -p 3000:3000 \
  -e DATABASE_URL=postgres://... \
  --network app-network \
  my-api:1.0

Listing Containers

docker ps          # running containers only
docker ps -a       # all containers including stopped ones

Output shows: Container ID, Image, Command, Created, Status, Ports, Names.

Stopping and Removing Containers

docker stop my-web      # gracefully stops the container
docker rm my-web        # removes the stopped container

# One-liner: stop and remove
docker stop my-web && docker rm my-web

# Remove a running container forcefully
docker rm -f my-web

Listing and Removing Images

docker images                   # list local images
docker rmi nginx                # remove the nginx image
docker rmi nginx mysql:8.0      # remove multiple

You can't remove an image while a container (even a stopped one) is using it. Remove containers first.

Viewing Logs

docker logs my-web              # all logs since container started
docker logs -f my-web           # follow logs in real-time (Ctrl+C to stop)
docker logs --tail 100 my-web   # last 100 lines

This is the first place to look when a container isn't behaving.

Getting a Shell Inside a Container

docker exec -it my-web bash    # open bash shell
docker exec -it my-web sh      # use sh if bash isn't available (Alpine images)

-i keeps stdin open. -t allocates a pseudo-TTY. Together they give you an interactive terminal. You can explore the container's filesystem, check running processes, or debug issues directly.

Cleanup

Containers, images, networks, and build cache accumulate fast. Clean them up with:

docker system prune             # removes stopped containers, unused networks, dangling images
docker system prune -a          # also removes all unused images (not just dangling)

Writing a Dockerfile

A Dockerfile is a text file containing instructions for building your own custom image. It's committed to your repo alongside your code.

# Step 1: start from an official base image
FROM node:20-alpine

# Step 2: set working directory inside the container
WORKDIR /app

# Step 3: copy package files and install dependencies
# (doing this before copying source code exploits Docker's layer caching)
COPY package*.json ./
RUN npm ci --omit=dev

# Step 4: copy the rest of the application source
COPY . .

# Step 5: expose the port the app listens on (documentation only)
EXPOSE 3000

# Step 6: command to run when the container starts
CMD ["node", "server.js"]

Build and run:

docker build -t my-node-app:1.0 .
docker run -d -p 3000:3000 my-node-app:1.0

Layer caching tip: Docker caches each layer. If package.json hasn't changed, RUN npm ci is skipped on the next build — it reuses the cached layer. That's why you copy package.json and install before copying source code. Source files change all the time; dependencies don't.


Volumes — Making Data Persist

By default, any data written inside a container lives in its writable layer. When you delete the container, that data is gone. For a stateless web server, that's fine. For a database, it's a catastrophe.

Volumes solve this. A Docker volume lives outside the container — it's managed by Docker on the host filesystem and survives container deletion.

# Create a named volume
docker volume create my-db-data

# List volumes
docker volume ls

# Remove a volume
docker volume rm my-db-data

Mount a volume when running a container using -v volume-name:/path/in/container:

docker run -d \
  --name my-postgres \
  -e POSTGRES_PASSWORD=secret \
  -v my-db-data:/var/lib/postgresql/data \
  postgres:15

/var/lib/postgresql/data is where PostgreSQL stores all its database files. By mounting my-db-data there, every write goes to the volume — not the ephemeral container layer. Stop the container, delete it, recreate it with the same volume: your data is intact.

Bind mounts let you mount a directory from your host directly:

docker run -v /home/user/project:/app my-app

Useful in development — code changes on your host are instantly reflected inside the container, no rebuild needed.


Networks — Getting Containers to Talk

When you have multiple containers (a web app and a database, for example), they need to communicate. Docker networks handle this.

# Create a custom network
docker network create app-network

# Run containers on the same network
docker run -d --name database --network app-network postgres:15
docker run -d --name web-app --network app-network my-web-app

On a custom network, containers can reach each other by nameweb-app connects to database using the hostname database. Docker handles the DNS resolution. No IPs, no port-mapping between containers needed.

The default bridge network doesn't have this automatic DNS resolution — always create a custom network for multi-container setups.


Running Databases in Docker

This is where Docker shines for local development. No more installing MySQL or PostgreSQL globally on your machine, fighting version conflicts, or polluting system directories.

MySQL

docker run -d \
  --name my-mysql \
  -e MYSQL_ROOT_PASSWORD=rootsecret \
  -e MYSQL_DATABASE=myapp \
  -e MYSQL_USER=appuser \
  -e MYSQL_PASSWORD=apppass \
  -p 3306:3306 \
  -v mysql-data:/var/lib/mysql \
  mysql:8.0

Environment variables:

  • MYSQL_ROOT_PASSWORD — root password (required)
  • MYSQL_DATABASE — creates this database on first run
  • MYSQL_USER / MYSQL_PASSWORD — creates a non-root user with access to MYSQL_DATABASE

Connect from your host machine (using any MySQL client or CLI):

mysql -h 127.0.0.1 -P 3306 -u appuser -p

Open an interactive MySQL shell inside the container:

docker exec -it my-mysql mysql -u root -p

PostgreSQL

docker run -d \
  --name my-postgres \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_USER=myuser \
  -e POSTGRES_DB=mydb \
  -p 5432:5432 \
  -v postgres-data:/var/lib/postgresql/data \
  postgres:15

Connect from host:

psql -h localhost -U myuser -d mydb

Open a shell inside the container:

docker exec -it my-postgres psql -U myuser -d mydb

Running Multiple Databases Side-by-Side

If you need MySQL and PostgreSQL on the same machine, map them to different host ports:

docker run -d --name mysql-dev -p 3307:3306 mysql:8.0 -e MYSQL_ROOT_PASSWORD=secret
docker run -d --name pg-dev   -p 5433:5432 postgres:15 -e POSTGRES_PASSWORD=secret

No conflicts. localhost:3307 → MySQL. localhost:5433 → PostgreSQL. Clean separation.


Port Conflicts — When Local and Docker Clash

This is one of the most common sources of confusion: you already have PostgreSQL installed locally, you try to run a Docker container with -p 5432:5432, and Docker fails.

Here's why, and exactly how to fix it.

How Port Mapping Actually Works

The -p flag takes the format HOST_PORT:CONTAINER_PORT.

docker run -p 5432:5432 postgres:15
          ┌─────┘ └─────┐
          │              │
   your machine     inside the container
   (host) port       (always 5432 for Postgres)

The container always listens on the same port internally — Postgres always uses 5432 inside the container. You cannot change that. What you CAN change is which port on your host machine connects to it.

The Conflict Scenario

You have two things trying to use the same host port:

Your machine
├── Local PostgreSQL   →  already listening on 0.0.0.0:5432
└── Docker Postgres    →  wants to bind host port 5432 ← CONFLICT

Docker will refuse to start and you'll see this error:

Error response from daemon: driver failed programming external connectivity
on endpoint my-postgres: Error starting userland proxy:
listen tcp4 0.0.0.0:5432: bind: address already in use.

Map the container's internal 5432 to a different port on your host:

docker run -d \
  --name my-postgres \
  -e POSTGRES_PASSWORD=secret \
  -p 5433:5432 \
  postgres:15

Now:

  • Your local PostgreSQL → localhost:5432 (untouched)
  • Your Docker PostgreSQL → localhost:5433

Your connection string just changes the port:

# Connecting to local PostgreSQL
psql -h localhost -p 5432 -U myuser

# Connecting to Docker PostgreSQL
psql -h localhost -p 5433 -U myuser

The internal container port never changes (5432) — only your host mapping does.

Solution 2: Stop the Local PostgreSQL Service

If you want Docker on :5432 and don't need the local install running:

# Windows
net stop postgresql-x64-15

# macOS (Homebrew)
brew services stop postgresql@15

# Linux
sudo systemctl stop postgresql

Then start your Docker container normally with -p 5432:5432. You can restart the local service anytime.

Checking What's Using a Port

Before running a container, verify the port is free:

# Windows PowerShell
netstat -ano | findstr :5432

# macOS / Linux
lsof -i :5432
# or
ss -tulpn | grep 5432

If you get output, that port is taken. Use Solution 1 to pick a free host port.

The Golden Rule for Dev Machines

Adopt a simple convention so local installs and Docker containers never fight:

ServiceLocal portDocker host port
PostgreSQL54325433
MySQL33063307
Redis63796380
MongoDB2701727018

Local always uses the default port. Docker always uses default + 1. You never think about conflicts again.


Can You See Database Data in Docker Desktop?

Yes — and it's genuinely useful. Here's what Docker Desktop shows you:

Containers tab:

  • Every running and stopped container listed by name
  • Status indicator (green = running, grey = stopped)
  • CPU and memory usage per container
  • Mapped ports at a glance
  • Click a container to expand its detail view

Container detail view:

  • Logs tab — full stdout/stderr output, searchable and filterable, real-time follow mode
  • Inspect tab — JSON dump of the full container config: environment variables, volume mounts, network settings, port bindings
  • Terminal tab — an in-browser shell into the container (equivalent to docker exec -it ... bash)

Volumes tab:

  • Lists all named volumes and their disk usage
  • Shows which containers are using each volume
  • You can browse volume contents on some setups

Images tab:

  • All locally pulled images with sizes and tags
  • One-click pull from Docker Hub
  • Remove unused images

The GUI doesn't replace the CLI — in production, scripts and automation use commands. But while you're learning, Docker Desktop's visual feedback is invaluable for building your mental model of what's running and why.


Docker Compose — One File, Many Containers

Once you're running two or more containers together, you'll want Docker Compose. Instead of running multiple docker run commands with all their flags, you define everything in a single docker-compose.yml file.

version: '3.8'

services:
  web:
    image: my-web-app:1.0
    ports:
      - "8080:3000"
    environment:
      - DATABASE_URL=postgres://myuser:secret@db:5432/mydb
    depends_on:
      - db
    networks:
      - app-network

  db:
    image: postgres:15
    environment:
      - POSTGRES_PASSWORD=secret
      - POSTGRES_USER=myuser
      - POSTGRES_DB=mydb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - app-network

volumes:
  db-data:

networks:
  app-network:

Start everything:

docker-compose up -d          # start all services in background
docker-compose down           # stop and remove all containers
docker-compose logs -f web    # follow logs for a specific service
docker-compose ps             # list running services

Notice DATABASE_URL uses db as the hostname — that's the service name. Docker Compose creates the custom network automatically and wires up DNS between services.

One command. Your entire local dev environment — app server, database, maybe a Redis cache — up and running in seconds.


Common Mistakes to Avoid

1. Not using volumes for databases. Run a database without a volume, delete the container, lose everything. Always mount a volume to the database data directory.

2. Using :latest tags. mysql:latest will pull whatever MySQL version is newest today. Six months later a teammate runs the same command and gets a different version. Pin to specific versions: mysql:8.0.

3. Exposing passwords in Dockerfiles. Anything baked into an image layer is readable. Pass secrets at runtime via environment variables or .env files. Never COPY .env . in a Dockerfile.

4. Not using .dockerignore. Without it, docker build copies node_modules/, .git/, .env — everything — into the image. Create a .dockerignore:

node_modules/
.git/
.env
.DS_Store
*.log

5. Not creating custom networks. The default bridge network exists but doesn't give containers automatic DNS by name. For any multi-container setup, create a named network.

6. Running as root inside the container. By default containers often run as root. For production images, create a dedicated non-root user:

RUN adduser --disabled-password appuser
USER appuser

7. Ignoring docker system prune. Stopped containers, dangling images, and orphaned volumes accumulate. Run cleanup regularly or your disk will fill up.


Quick Reference Cheat Sheet

# Images
docker pull image:tag             # download image
docker images                     # list local images
docker rmi image:tag              # remove image
docker build -t name:tag .        # build from Dockerfile

# Containers
docker run -d -p host:ctr image   # run in background with port mapping
docker ps                         # running containers
docker ps -a                      # all containers
docker stop name                  # stop container
docker rm name                    # remove container
docker rm -f name                 # force remove running container

# Inspect & Debug
docker logs name                  # view logs
docker logs -f name               # follow logs
docker exec -it name bash         # shell inside container
docker inspect name               # full config dump

# Volumes & Networks
docker volume create vol-name     # create volume
docker volume ls                  # list volumes
docker network create net-name    # create network
docker network ls                 # list networks

# Cleanup
docker system prune               # remove unused everything
docker system prune -a            # also remove unused images

# Compose
docker-compose up -d              # start all services
docker-compose down               # stop and remove all services
docker-compose logs -f service    # follow logs for a service

Where to Go Next

You now have the foundation. Here's a logical path forward:

  1. Run something real. Pull nginx, map a port, visit localhost. Pull postgres, connect from your DB client. Hands-on beats reading.
  2. Write a Dockerfile for a project you already have. Build it. Run it. See what breaks and fix it.
  3. Add Docker Compose once you have two services to wire together.
  4. Explore Docker Hub — there are official, well-maintained images for almost every tool you'll need.
  5. Read the official docs at docs.docker.com — they're unusually good.

The learning curve flattens fast once you've gone from docker run to having a full local environment in one docker-compose up. At that point, you'll wonder how you developed without it.