Toolmingo
Guides18 min read

Docker Tutorial for Beginners (2025): Learn Docker Step by Step

Complete Docker tutorial for beginners. Learn containers, images, Dockerfile, Docker Compose, and deploy real apps. Free guide with examples.

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.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools