Docker is the industry-standard tool for packaging, shipping, and running applications in containers. It runs on every major cloud provider, powers most CI/CD pipelines, and is expected knowledge for any backend, DevOps, or full-stack role. This tutorial takes you from zero to running real containerised applications, step by step, with no prior Docker experience required.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Install Docker and run your first container |
| Images & Containers | Understand the difference and use both |
| Dockerfile | Build your own images from scratch |
| Volumes | Persist data between container restarts |
| Networking | Connect containers together |
| Docker Compose | Run multi-container apps with one command |
| Docker Hub | Push and pull images from the registry |
| Real projects | Containerise a Node.js app, a PostgreSQL database, and a full stack |
Docker version used: Docker 25+ (Desktop or Engine)
Part 1 — Why Docker?
Before Docker, deploying an app meant: "It works on my machine." Docker solves this by packaging an app and all its dependencies — runtime, libraries, config — into a single portable unit called a container.
Docker vs traditional deployment
| Problem | Without Docker | With Docker |
|---|---|---|
| Works on my machine | Dev/prod environments differ | Same container runs everywhere |
| Dependency conflicts | Python 3.8 vs 3.11 per project | Each container has its own runtime |
| Slow server setup | Manual install scripts | docker pull + docker run |
| Scaling | Add servers, install everything again | Copy containers horizontally |
| Rollback | Painful: reinstall old version | docker run app:1.2 instantly |
Docker use cases
| Use case | Example |
|---|---|
| Local development | Run PostgreSQL without installing it |
| CI/CD pipelines | Build, test, push in isolated environments |
| Microservices | Each service in its own container |
| Cloud deployment | AWS ECS, Google Cloud Run, Kubernetes |
| Legacy app isolation | Run old apps without polluting the host |
Part 2 — Installing Docker
macOS / Windows
Download Docker Desktop from docker.com. It includes the Docker Engine, CLI, and Docker Compose.
Linux (Ubuntu/Debian)
# Remove old versions
sudo apt remove docker docker-engine docker.io containerd runc
# Install via official script
curl -fsSL https://get.docker.com | sh
# Add your user to the docker group (no sudo required)
sudo usermod -aG docker $USER
newgrp docker
# Verify
docker --version
# Docker version 25.0.3, build 4debf41
Verify installation
docker run hello-world
# Hello from Docker!
# This message shows your installation appears to be working correctly.
Part 3 — Core concepts
Images vs Containers
| Concept | What it is | Analogy |
|---|---|---|
| Image | Read-only blueprint (filesystem + metadata) | Recipe / class |
| Container | Running instance of an image | Dish / object |
| Registry | Storage for images (Docker Hub, ECR, GCR) | GitHub for images |
| Dockerfile | Script that builds an image | Source code of the image |
Dockerfile → docker build → Image → docker run → Container
The container lifecycle
docker run # create + start
docker stop # send SIGTERM, wait, SIGKILL
docker start # restart a stopped container
docker rm # delete a stopped container
docker kill # send SIGKILL immediately
Part 4 — Your first containers
Run a container
docker run nginx
# Pulls nginx image from Docker Hub and starts a container
# Ctrl+C to stop
Run in detached mode (background)
docker run -d nginx
# Returns container ID: a3f9e2b1c4d8...
Map a port
# -p host_port:container_port
docker run -d -p 8080:80 nginx
# Visit http://localhost:8080 in your browser
Name a container
docker run -d -p 8080:80 --name my-nginx nginx
List running containers
docker ps
# CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
# a3f9e2b1c4d8 nginx ... 5s ago Up 5s 0.0.0.0:8080->80/tcp my-nginx
List all containers (including stopped)
docker ps -a
Stop and remove
docker stop my-nginx
docker rm my-nginx
# Stop + remove in one step
docker rm -f my-nginx
Run an interactive shell
docker run -it ubuntu bash
# root@a3f9e2b1:/#
# You're inside the container — explore, install, exit
exit # leaves the container
Part 5 — Working with images
Pull an image
docker pull node:20-alpine
# alpine = tiny Linux image (~5MB), full = ~200MB
List local images
docker images
# REPOSITORY TAG IMAGE ID CREATED SIZE
# node 20-alpine e27f7e4e1d8b 2 weeks ago 130MB
# nginx latest b690f5f0a2d5 3 weeks ago 142MB
Remove an image
docker rmi nginx
# Cannot remove if a container (even stopped) uses it
docker rmi -f nginx # force remove
Image tags
docker pull postgres:16 # specific version
docker pull postgres:16-alpine # version + variant
docker pull postgres:latest # (not recommended in production)
Inspect an image
docker inspect nginx
# JSON with layers, environment variables, entrypoint, etc.
Clean up unused resources
docker system prune # remove stopped containers + dangling images
docker system prune -a # also remove unused images
docker system prune -a --volumes # also remove volumes
Part 6 — Dockerfile
A Dockerfile is a script of instructions that Docker executes to build an image layer by layer.
Dockerfile instructions
| Instruction | Purpose | Example |
|---|---|---|
FROM |
Base image (required first) | FROM node:20-alpine |
WORKDIR |
Set working directory | WORKDIR /app |
COPY |
Copy files from host to image | COPY . . |
ADD |
Like COPY + can extract archives/URLs | ADD archive.tar.gz /app |
RUN |
Execute command during build | RUN npm install |
ENV |
Set environment variable | ENV NODE_ENV=production |
ARG |
Build-time variable | ARG VERSION=1.0 |
EXPOSE |
Document which port the app listens on | EXPOSE 3000 |
CMD |
Default command when container starts | CMD ["node", "server.js"] |
ENTRYPOINT |
Fixed command (CMD provides args) | ENTRYPOINT ["node"] |
VOLUME |
Create a mount point | VOLUME /data |
USER |
Set user for subsequent commands | USER node |
HEALTHCHECK |
Check container health | HEALTHCHECK CMD curl -f http://localhost/ |
LABEL |
Add metadata | LABEL maintainer="you@example.com" |
CMD vs ENTRYPOINT
| CMD | ENTRYPOINT | |
|---|---|---|
| Purpose | Default command, easily overridden | Fixed executable |
| Override via CLI | Yes: docker run image cmd |
Partially: with --entrypoint |
| Use together | ENTRYPOINT = exec, CMD = default args | ENTRYPOINT ["node"] CMD ["server.js"] |
Your first Dockerfile — Node.js app
Project structure:
my-app/
├── Dockerfile
├── package.json
├── package-lock.json
└── server.js
server.js:
const http = require('http');
const port = process.env.PORT || 3000;
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from Docker!\n');
});
server.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Dockerfile:
# 1. Base image
FROM node:20-alpine
# 2. Set working directory inside the container
WORKDIR /app
# 3. Copy dependency files first (layer caching trick)
COPY package*.json ./
# 4. Install dependencies
RUN npm ci --only=production
# 5. Copy the rest of the app
COPY . .
# 6. Document the port
EXPOSE 3000
# 7. Run as non-root for security
USER node
# 8. Start the app
CMD ["node", "server.js"]
Build and run:
# Build image, tag it as my-app:1.0
docker build -t my-app:1.0 .
# Run it
docker run -d -p 3000:3000 --name my-app my-app:1.0
# Test
curl http://localhost:3000
# Hello from Docker!
Layer caching
Docker caches each instruction as a layer. If a layer hasn't changed, Docker reuses the cache. Copy package.json before source code so the expensive npm install layer only rebuilds when dependencies change.
# FAST REBUILD (dependencies cached unless package.json changes)
COPY package*.json ./
RUN npm ci
COPY . .
# SLOW (npm install runs on every code change)
COPY . .
RUN npm ci
.dockerignore
Like .gitignore but for Docker. Prevents copying unnecessary files into the image.
node_modules
.git
.env
.env.*
*.log
dist
coverage
.DS_Store
Part 7 — Volumes: persisting data
Containers are ephemeral — when you remove a container, all its data is gone. Volumes solve this.
Volume types
| Type | Syntax | Best for |
|---|---|---|
| Named volume | -v my-volume:/data |
Databases, persistent app data |
| Bind mount | -v /host/path:/container/path |
Development (hot reload) |
| tmpfs mount | --tmpfs /tmp |
Temporary in-memory storage |
Named volumes
# Create a volume
docker volume create my-data
# Run postgres with a named volume
docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=secret \
-v my-data:/var/lib/postgresql/data \
postgres:16
# The database persists even if you remove + recreate the container
docker rm -f postgres
docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=secret \
-v my-data:/var/lib/postgresql/data \
postgres:16 # data is still there
# List volumes
docker volume ls
# Remove a volume
docker volume rm my-data
Bind mounts (development)
# Mount current directory into the container
# Changes to local files are immediately reflected inside
docker run -d \
-p 3000:3000 \
-v $(pwd):/app \
-v /app/node_modules \
--name dev-server \
my-app:1.0
Part 8 — Networking
Containers can talk to each other via Docker networks.
Network types
| Driver | Purpose |
|---|---|
bridge |
Default — isolated network on the host |
host |
Container uses host network stack directly |
none |
No network (fully isolated) |
overlay |
Multi-host networking (Docker Swarm) |
Container-to-container communication
# Create a network
docker network create my-network
# Run postgres on the network
docker run -d \
--name db \
--network my-network \
-e POSTGRES_PASSWORD=secret \
postgres:16
# Run app on the same network — can reach "db" by name
docker run -d \
--name app \
--network my-network \
-p 3000:3000 \
-e DATABASE_URL=postgres://postgres:secret@db:5432/mydb \
my-app:1.0
# Containers on the same network resolve each other by --name
# app can connect to postgres at host "db"
Inspect a network
docker network inspect my-network
# Lists containers, their IPs, and network config
Part 9 — Docker Compose
Docker Compose lets you define and run multi-container applications with a single docker-compose.yml file.
Install Docker Compose
Docker Desktop includes Compose. For Linux:
sudo apt install docker-compose-plugin
docker compose version
docker-compose.yml syntax
version: "3.9"
services: # containers to run
app: # service name (also DNS hostname on the network)
build: . # build from Dockerfile in current directory
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://postgres:secret@db:5432/mydb
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
volumes:
- ./logs:/app/logs
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes: # named volumes
db-data:
redis-data:
Docker Compose commands
# Start all services (build if needed)
docker compose up
# Start in background
docker compose up -d
# Rebuild images before starting
docker compose up -d --build
# Stop all services
docker compose down
# Stop + remove volumes
docker compose down -v
# View logs
docker compose logs
docker compose logs -f app # follow app service logs
# Run a command in a service
docker compose exec app sh
docker compose exec db psql -U postgres
# Scale a service
docker compose up -d --scale app=3
# List running services
docker compose ps
# Pull latest images
docker compose pull
Real-world Compose: Node.js + PostgreSQL + Redis
docker-compose.yml:
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db-data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis-data:/data
restart: unless-stopped
volumes:
db-data:
redis-data:
.env:
POSTGRES_PASSWORD=supersecretpassword
POSTGRES_DB=myapp
docker compose up -d
# Starts all 3 services. App is available at http://localhost:3000
Part 10 — Docker Hub and registries
Push an image to Docker Hub
# 1. Create account at hub.docker.com
# 2. Login
docker login
# 3. Tag your image with your username
docker tag my-app:1.0 yourusername/my-app:1.0
# 4. Push
docker push yourusername/my-app:1.0
# 5. Pull on any machine
docker pull yourusername/my-app:1.0
Private registries
| Registry | Provider | Use case |
|---|---|---|
| Docker Hub | Docker | Public images, free private (limited) |
| Amazon ECR | AWS | Production apps on AWS |
| Google Artifact Registry | GCP | Production apps on GCP |
| Azure Container Registry | Microsoft | Production apps on Azure |
| GitHub Container Registry | GitHub | Open source projects |
| Self-hosted | GitLab, Nexus, Harbor | On-premise / cost control |
# Login to AWS ECR
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
123456789.dkr.ecr.us-east-1.amazonaws.com
# Tag and push
docker tag my-app:1.0 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:1.0
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:1.0
Part 11 — Multi-stage builds
Multi-stage builds produce small production images by separating the build environment from the runtime environment.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # compiles TypeScript, bundles, etc.
# Stage 2: Production runtime
# Only the compiled output is copied — no source, no devDependencies, no build tools
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
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"]
Result: Image goes from ~800MB (with devDependencies + build tools) to ~120MB.
docker build -t my-app:prod .
docker images my-app
# REPOSITORY TAG IMAGE ID SIZE
# my-app prod f2a3b4c5d6e7 120MB
Part 12 — Environment variables and secrets
Pass environment variables
# Inline
docker run -e NODE_ENV=production -e PORT=8080 my-app
# From a file
docker run --env-file .env my-app
Docker secrets (Swarm mode)
For production, avoid passing secrets as plain environment variables. Use Docker secrets:
# Create a secret from a file
echo "supersecret" | docker secret create db_password -
# Reference in docker-compose.yml (Swarm mode)
# services:
# db:
# secrets:
# - db_password
# secrets:
# db_password:
# external: true
For non-Swarm production, use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) and inject at runtime via your platform.
Part 13 — Useful Docker commands reference
Container management
docker run -d -p 8080:80 --name web nginx # run detached
docker ps # list running
docker ps -a # list all
docker stop web # stop gracefully
docker start web # restart stopped
docker restart web # stop + start
docker rm web # remove stopped
docker rm -f web # force remove running
docker logs web # view logs
docker logs -f web # follow logs
docker logs --tail 100 web # last 100 lines
docker exec -it web sh # interactive shell
docker exec web cat /etc/nginx/nginx.conf # run one command
docker cp web:/etc/nginx/nginx.conf ./ # copy file out
docker cp ./nginx.conf web:/etc/nginx/ # copy file in
docker top web # processes inside
docker stats # live resource usage
docker inspect web # full JSON metadata
docker port web # port mappings
Image management
docker build -t app:1.0 . # build from Dockerfile
docker build -t app:1.0 -f Dockerfile.prod . # custom Dockerfile
docker pull nginx:alpine # pull from registry
docker push yourusername/app:1.0 # push to registry
docker tag app:1.0 app:latest # add a tag
docker images # list images
docker rmi nginx # remove image
docker image prune # remove dangling
docker history app:1.0 # view layers
docker save app:1.0 | gzip > app.tar.gz # export to file
docker load < app.tar.gz # import from file
System cleanup
docker system df # disk usage
docker system prune # cleanup stopped containers + dangling images
docker system prune -a # also remove unused images
docker container prune # remove all stopped containers
docker image prune -a # remove all unused images
docker volume prune # remove unused volumes
docker network prune # remove unused networks
Part 14 — Debugging containers
Container won't start
# Check logs immediately after crash
docker logs my-app
# Check exit code
docker inspect my-app --format='{{.State.ExitCode}}'
# Run with interactive shell to debug
docker run -it --entrypoint sh my-app
Container starts but app doesn't work
# Get a shell in the running container
docker exec -it my-app sh
# Check processes
docker exec my-app ps aux
# Check network
docker exec my-app wget -O- http://localhost:3000
# Check environment variables
docker exec my-app env
Permission issues
# Run as root temporarily to investigate
docker exec -it --user root my-app sh
Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
port is already allocated |
Port taken on host | Change -p mapping or stop conflicting process |
no space left on device |
Docker disk full | docker system prune -a |
cannot connect to the Docker daemon |
Docker not running | Start Docker Desktop or sudo systemctl start docker |
permission denied |
Running as wrong user | Check USER in Dockerfile, check volume permissions |
exec format error |
Wrong CPU architecture | Use --platform linux/amd64 or build for correct arch |
image not found |
Typo or not pulled | docker pull first, check tag name |
network not found |
Network doesn't exist | docker network create first |
health check failing |
App not ready | Increase --start-period, fix the healthcheck command |
Part 15 — Three real projects
Project 1: Containerise a static website
FROM nginx:alpine
COPY ./dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
docker build -t my-site .
docker run -d -p 80:80 my-site
Project 2: PostgreSQL development environment
# No Dockerfile needed — use the official image
docker run -d \
--name postgres-dev \
-e POSTGRES_USER=dev \
-e POSTGRES_PASSWORD=devpassword \
-e POSTGRES_DB=myapp \
-p 5432:5432 \
-v postgres-dev-data:/var/lib/postgresql/data \
postgres:16-alpine
# Connect with psql
docker exec -it postgres-dev psql -U dev -d myapp
# Or connect from host
psql -h localhost -U dev -d myapp
Project 3: Full-stack dev environment with Compose
# docker-compose.yml
version: "3.9"
services:
frontend:
build:
context: ./frontend
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_URL=http://localhost:4000
command: npm run dev
backend:
build:
context: ./backend
ports:
- "4000:4000"
volumes:
- ./backend:/app
- /app/node_modules
environment:
- DATABASE_URL=postgres://dev:devpassword@db:5432/myapp
- PORT=4000
depends_on:
db:
condition: service_healthy
command: npm run dev
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
retries: 10
volumes:
db-data:
docker compose up -d
# Frontend: http://localhost:5173
# Backend: http://localhost:4000
# PostgreSQL: localhost:5432
# Live reload works — edit files, changes appear instantly
Docker best practices
| Practice | Why |
|---|---|
Use specific tags, not latest |
Reproducible builds |
Use .dockerignore |
Smaller images, faster builds |
| Use multi-stage builds | Small production images |
| Run as non-root user | Security |
Use COPY over ADD |
Predictable behaviour |
| One process per container | Easier scaling and debugging |
Use CMD for defaults, ENTRYPOINT for fixed commands |
Flexibility |
Copy package.json before source code |
Layer cache efficiency |
| Use health checks | Orchestrators know when app is ready |
| Never store secrets in images | Security — images can be inspected |
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
COPY . . before npm install |
Cache miss on every code change | Copy package*.json first |
No .dockerignore |
node_modules copied in, huge image |
Add .dockerignore |
Using latest tag |
Unpredictable builds | Pin versions: node:20.11.0-alpine |
| Running as root | Security risk | Add USER node (or your app user) |
| Storing secrets in ENV at build time | Secret baked into image layer | Pass secrets at runtime only |
CMD ["npm", "start"] in production |
npm adds overhead, poor signal handling | CMD ["node", "server.js"] directly |
| No health check | Orchestrator restarts app too early | Add HEALTHCHECK to Dockerfile |
| Ignoring exit codes | Silent failures | Use set -e in shell scripts, check $? |
Docker vs alternatives
| Tool | What it is | When to use it |
|---|---|---|
| Docker | Container runtime + tooling | General-purpose containers |
| Podman | Docker-compatible, daemonless | Linux servers, rootless containers |
| containerd | Low-level container runtime | Used by Kubernetes under the hood |
| LXC/LXD | Linux containers (OS-level) | Full OS containers, homelab |
| Vagrant | Virtual machines (not containers) | Full VM isolation, legacy apps |
| nix | Reproducible package management | Build-time reproducibility |
What to learn next
| Topic | Description |
|---|---|
| Kubernetes | Orchestrate containers at scale |
| Docker Swarm | Simple built-in orchestration |
| CI/CD with Docker | GitHub Actions, GitLab CI pipelines |
| Docker networking deep dive | Overlay networks, macvlan |
| Security scanning | docker scout, Trivy, Snyk |
| Distroless images | Even smaller, more secure base images |
| BuildKit | Advanced build features (cache mounts, secrets) |
FAQ
Do I need to know Linux to use Docker? Basic Linux command-line skills help (navigating directories, editing files), but you can start Docker on Windows or macOS via Docker Desktop without deep Linux knowledge.
What's the difference between Docker and a virtual machine? VMs virtualise hardware — each VM has its own OS kernel (~GBs of overhead). Docker containers share the host OS kernel — they're isolated processes (~MBs of overhead). Containers start in milliseconds; VMs take minutes.
Can Docker run on Windows without WSL 2? Docker Desktop on Windows uses WSL 2 (Windows Subsystem for Linux) under the hood. You don't configure WSL 2 yourself — Docker Desktop handles it — but it does need to be enabled.
Is Docker free? Docker Engine (Linux) is free and open source. Docker Desktop (macOS/Windows) is free for personal use and small businesses but requires a paid subscription for larger organisations.
Should I use Docker in production or just development? Both. Docker shines in production (consistent environments, easy scaling, cloud deployment). In development, it's invaluable for running databases and third-party services locally without installing them.
What's the difference between Docker Compose and Kubernetes? Docker Compose is for running multi-container apps on a single host — perfect for development and small deployments. Kubernetes is for orchestrating containers across a cluster of servers — necessary for large-scale, high-availability production systems. Start with Compose; move to Kubernetes when you need multi-host scaling or advanced traffic management.