Docker: The Complete Developer's Guide for 2026
Docker transformed software development. Instead of debugging environment differences between your laptop, your colleague's machine, and the production server, you package everything into a container that runs identically everywhere. In 2026, Docker is not just relevant — it is foundational infrastructure for nearly every modern software team.
This guide covers Docker from first principles through advanced production patterns. Whether you are writing your first Dockerfile or optimizing a CI/CD pipeline that builds hundreds of images per day, every section is written with practical examples you can use immediately. If you are brand new to containers, start with our Docker Containers for Beginners guide first, then return here for the complete picture.
Table of Contents
- What is Docker and Why Use It
- Docker Architecture
- Essential Docker Commands
- Dockerfile Best Practices
- Docker Compose for Multi-Container Apps
- Docker Networking
- Docker Volumes and Data Persistence
- Docker Security Best Practices
- Docker in CI/CD Pipelines
- Docker vs Kubernetes
- Common Docker Mistakes
- Performance Optimization
- Frequently Asked Questions
1. What is Docker and Why Use It
Docker is a platform for building, shipping, and running applications inside containers. A container is a lightweight, isolated environment that packages an application with all of its dependencies — code, runtime, system libraries, and configuration files — so it runs consistently regardless of the underlying infrastructure.
Before Docker, deploying software meant carefully configuring servers, managing dependency conflicts, and troubleshooting differences between development and production environments. A Python application that worked perfectly on Ubuntu 22.04 might fail on Amazon Linux because of a missing system library, a different OpenSSL version, or a conflicting Python package. Docker eliminates this entire category of problems.
Containers vs Virtual Machines
Containers and virtual machines both provide isolation, but they work at different levels:
Virtual Machines Containers
+----------------------------+ +----------------------------+
| App A | App B | App C| | App A | App B | App C |
|----------|----------|------| |--------|--------|---------|
| Bins/Libs| Bins/Libs| Bins | | Bins | Bins | Bins |
|----------|----------|------| +----------------------------+
| Guest OS | Guest OS | Guest| | Container Runtime (Docker) |
+----------------------------+ +----------------------------+
| Hypervisor | | Host Operating System |
+----------------------------+ +----------------------------+
| Host Operating System | | Hardware |
+----------------------------+ +----------------------------+
| Hardware |
+----------------------------+
Virtual machines virtualize the hardware. Each VM runs a complete operating system — its own kernel, init system, and libraries. This makes VMs heavyweight: a typical VM image is several gigabytes, and booting takes 30-60 seconds.
Containers virtualize the operating system. They share the host kernel and isolate processes using Linux kernel features (namespaces and cgroups). This makes containers lightweight: a typical container image is tens to hundreds of megabytes, and starting takes under a second.
Why Docker in 2026
- Consistency across environments — what runs on your laptop runs identically in staging, production, and your colleague's machine
- Dependency isolation — each container has its own filesystem, so Python 3.9 and Python 3.12 applications coexist without conflict
- Rapid startup — containers start in milliseconds to seconds, compared to minutes for VMs
- Resource efficiency — a single server can run dozens of containers sharing the same OS kernel
- Reproducible builds — Dockerfiles codify the exact steps to build an environment, making builds deterministic
- Ecosystem maturity — Docker Hub hosts over 100,000 official and community images for every major language, database, and tool
- CI/CD integration — every major CI system (GitHub Actions, GitLab CI, Jenkins, CircleCI) has first-class Docker support
- Orchestration compatibility — Docker images are the deployment unit for Kubernetes, Docker Swarm, AWS ECS, Google Cloud Run, and Azure Container Instances
Docker is not a replacement for virtual machines. VMs are still the right choice when you need kernel-level isolation, must run different operating systems (Windows containers on Linux hosts), or are dealing with strict multi-tenancy security requirements. But for packaging and deploying applications, Docker is the industry standard.
2. Docker Architecture
Docker uses a client-server architecture. Understanding the four core components — the daemon, images, containers, and registries — is essential before you start building.
The Docker Daemon and CLI
The Docker daemon (dockerd) is a background process that manages Docker objects: images, containers, networks, and volumes. The Docker CLI (docker) is the command-line tool you use to interact with the daemon. When you type docker run nginx, the CLI sends the request to the daemon, which pulls the image, creates a container, and starts it.
# The flow when you run a command:
You (CLI) --REST API--> Docker Daemon ---> Container Runtime (containerd/runc)
|
+--> Pulls images from Registry (Docker Hub)
+--> Manages networks, volumes, containers
Images
A Docker image is a read-only template that contains a filesystem and metadata. Images are built in layers: each instruction in a Dockerfile creates a new layer on top of the previous one. Layers are cached and shared between images, so if ten images all start from ubuntu:22.04, the base layer is stored only once on disk.
# Image layers (each Dockerfile instruction = one layer):
┌─────────────────────────────────┐
│ Layer 4: CMD ["node", "app.js"] │ (metadata only, no filesystem change)
├─────────────────────────────────┤
│ Layer 3: COPY . /app │ (adds application source code)
├─────────────────────────────────┤
│ Layer 2: RUN npm install │ (adds node_modules)
├─────────────────────────────────┤
│ Layer 1: FROM node:20-alpine │ (base OS + Node.js runtime)
└─────────────────────────────────┘
Images are identified by a repository name and tag: nginx:1.25-alpine, python:3.12-slim, myapp:v2.3.1. The latest tag is the default when no tag is specified, but using it in production is considered an anti-pattern because it is mutable — what latest points to changes over time.
Containers
A container is a running (or stopped) instance of an image. When Docker creates a container, it adds a thin writable layer on top of the image's read-only layers. Any files you modify inside the container are written to this writable layer. When the container is removed, the writable layer is deleted — the underlying image is unchanged.
# One image, multiple containers:
Image: postgres:16-alpine
├── Container: db-production (running, port 5432)
├── Container: db-staging (running, port 5433)
└── Container: db-test (stopped)
Each container has its own filesystem, network interface, process tree, and resource limits. Containers are isolated from each other and from the host by default, though you can explicitly share resources (networks, volumes, ports) when needed.
Volumes
Since container filesystems are ephemeral, Docker provides volumes for persistent data. A volume is a directory on the host that is mounted into the container. When the container is removed, the volume survives. Volumes are the correct way to store database files, uploaded content, logs, and any data that must persist across container restarts.
Networks
Docker networks provide isolated communication channels between containers. By default, Docker creates a bridge network where containers can communicate using IP addresses. Custom networks allow containers to discover each other by name, which is essential for multi-container applications where a web server needs to connect to a database.
3. Essential Docker Commands
These are the commands you will use daily. For the complete reference with flags and options, see our Docker Commands Cheat Sheet.
Building Images
# Build an image from a Dockerfile in the current directory
docker build -t myapp:1.0 .
# Build with a specific Dockerfile
docker build -f Dockerfile.prod -t myapp:prod .
# Build with build arguments
docker build --build-arg NODE_ENV=production -t myapp:prod .
# Build for a specific platform (useful for M1/M2 Macs building for Linux servers)
docker build --platform linux/amd64 -t myapp:prod .
# Build with no cache (force fresh build)
docker build --no-cache -t myapp:latest .
Running Containers
# Run a container in the background (detached mode)
docker run -d --name web -p 8080:80 nginx
# Run interactively with a shell
docker run -it --name dev ubuntu:22.04 /bin/bash
# Run with environment variables
docker run -d --name api \
-e DATABASE_URL=postgres://user:pass@db:5432/myapp \
-e NODE_ENV=production \
-p 3000:3000 \
myapp:latest
# Run with a volume mount
docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16-alpine
# Run with resource limits
docker run -d --name worker \
--memory=512m \
--cpus=1.5 \
myapp:worker
# Run with automatic removal when the container stops
docker run --rm -it python:3.12 python -c "print('hello')"
# Explanation of common flags:
# -d detached mode (runs in background)
# -it interactive + pseudo-TTY (for shell access)
# -p H:C map host port H to container port C
# -v H:C mount host path/volume H to container path C
# -e KEY=VAL set environment variable
# --name assign a name to the container
# --rm automatically remove container when it exits
# --restart restart policy (no, on-failure, always, unless-stopped)
Executing Commands in Running Containers
# Open a shell inside a running container
docker exec -it web /bin/bash
# Run a one-off command
docker exec web cat /etc/nginx/nginx.conf
# Run as a specific user
docker exec -u root web apt-get update
# Run with environment variables
docker exec -e DEBUG=true api node debug-script.js
Viewing Logs
# View all logs from a container
docker logs web
# Follow logs in real time (like tail -f)
docker logs -f web
# Show only the last 100 lines
docker logs --tail 100 web
# Show logs since a specific time
docker logs --since 2026-02-11T10:00:00 web
# Show logs with timestamps
docker logs -t web
Listing and Inspecting
# List running containers
docker ps
# List all containers (including stopped)
docker ps -a
# List containers with custom format
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# Detailed container inspection (JSON output)
docker inspect web
# Get a specific value from inspect output
docker inspect --format='{{.NetworkSettings.IPAddress}}' web
# View real-time resource usage
docker stats
# View resource usage for specific containers
docker stats web api db
Managing Images
# List local images
docker images
# Pull an image from a registry
docker pull nginx:1.25-alpine
# Push an image to a registry
docker push myregistry.com/myapp:1.0
# Tag an image
docker tag myapp:latest myregistry.com/myapp:1.0
# Remove an image
docker rmi nginx:1.25-alpine
# Remove all dangling images (untagged, unused)
docker image prune
# View image layer history
docker history myapp:latest
Stopping and Removing
# Stop a running container (sends SIGTERM, then SIGKILL after 10s)
docker stop web
# Stop with a custom timeout
docker stop -t 30 web
# Kill a container immediately (sends SIGKILL)
docker kill web
# Remove a stopped container
docker rm web
# Force remove a running container
docker rm -f web
# Remove all stopped containers
docker container prune
# Nuclear option: remove everything unused
docker system prune -a --volumes
docker compose up.
4. Dockerfile Best Practices
A Dockerfile defines how your image is built. Writing efficient Dockerfiles directly impacts build speed, image size, security, and cacheability. These best practices are the difference between a 50MB image that builds in 10 seconds and a 2GB image that takes 5 minutes.
Multi-Stage Builds
Multi-stage builds are the single most impactful optimization. They separate the build environment (compilers, dev dependencies, source code) from the runtime environment (only the compiled output). This routinely reduces image sizes by 80-95%.
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production image (only the built output)
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Result:
# Build stage: ~400MB (Node.js + node_modules + source)
# Final image: ~25MB (nginx + static files only)
Multi-stage builds work for every language:
# Go: compile to a static binary, run from scratch
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Final image: ~8MB (just the binary, no OS)
# Python: install dependencies in builder, copy to slim runtime
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
# Final image: ~180MB (slim base + deps only, no compiler toolchain)
Layer Caching
Docker caches each layer. If a layer has not changed since the last build, Docker reuses the cached version. But once a layer changes, all subsequent layers are rebuilt. This means the order of your Dockerfile instructions matters enormously.
# GOOD: Dependencies change rarely, source code changes frequently
COPY package.json package-lock.json ./ # Layer 1: rarely changes
RUN npm ci # Layer 2: cached unless package.json changed
COPY . . # Layer 3: changes with every code edit
# BAD: Cache is invalidated on every code change
COPY . . # Layer 1: changes with every code edit
RUN npm ci # Layer 2: ALWAYS rebuilt (previous layer changed)
The principle: order instructions from least frequently changed to most frequently changed. System packages first, then language dependencies, then application code.
The .dockerignore File
A .dockerignore file prevents unnecessary files from being sent to the Docker daemon during builds. Without it, Docker sends your entire project directory (including node_modules, .git, test data, and IDE files) as the build context.
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
Dockerfile*
docker-compose*.yml
README.md
LICENSE
.vscode
.idea
coverage
.nyc_output
__pycache__
*.pyc
.pytest_cache
dist
build
*.md
tests
test
A good .dockerignore can reduce build context from hundreds of megabytes to a few megabytes, dramatically speeding up builds — especially in CI/CD environments where the build context is transferred over a network.
Minimize Layers and Image Size
# BAD: 3 separate layers, apt cache left behind
RUN apt-get update
RUN apt-get install -y curl wget
RUN apt-get clean
# GOOD: single layer, cache cleaned
RUN apt-get update && \
apt-get install -y --no-install-recommends curl wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
Additional size optimization strategies:
- Use Alpine or slim base images —
python:3.12-alpine(50MB) vspython:3.12(900MB) - Use
--no-install-recommendswith apt-get to skip optional packages - Use
--no-cache-dirwith pip to avoid caching downloaded packages - Remove build tools after compilation — or better yet, use multi-stage builds
- Use
COPYwith specific paths instead ofCOPY . .when possible
Pin Image Versions
# BAD: non-deterministic, changes over time
FROM node:latest
FROM python
FROM ubuntu
# GOOD: reproducible, predictable
FROM node:20.11-alpine3.19
FROM python:3.12.2-slim-bookworm
FROM ubuntu:22.04
Pinning versions ensures that your builds are reproducible. The latest tag today might point to Node 20, but next month it could point to Node 22, breaking your application. Pin to a specific major.minor version at minimum, and ideally to a specific patch version for production images.
5. Docker Compose for Multi-Container Apps
Real-world applications rarely run as a single container. A typical web application needs a web server, an application server, a database, a cache, and possibly a message queue. Docker Compose lets you define all these services in a single YAML file and manage them together. Docker Compose files are YAML — use our JSON to YAML converter if you are working with configuration that starts in JSON format.
Basic docker-compose.yml
# docker-compose.yml
services:
web:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
Essential Compose Commands
# Start all services in the background
docker compose up -d
# Start and force rebuild of images
docker compose up -d --build
# View running services
docker compose ps
# View logs from all services
docker compose logs
# Follow logs from a specific service
docker compose logs -f web
# Stop all services
docker compose down
# Stop services and remove volumes (destroys data!)
docker compose down -v
# Restart a single service
docker compose restart web
# Scale a service
docker compose up -d --scale worker=5
# Run a one-off command in a service
docker compose exec web sh
docker compose run --rm web npm test
Production-Grade Compose File
A production Compose file includes health checks, resource limits, restart policies, and proper dependency ordering:
services:
# Reverse proxy
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
api:
condition: service_healthy
restart: always
deploy:
resources:
limits:
memory: 128M
cpus: '0.5'
# Application server
api:
build:
context: .
dockerfile: Dockerfile
target: production
environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
- REDIS_URL=redis://cache:6379
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
# Database
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
restart: always
deploy:
resources:
limits:
memory: 1G
cpus: '2.0'
# Cache
cache:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
restart: always
deploy:
resources:
limits:
memory: 384M
cpus: '0.5'
# Background worker
worker:
build:
context: .
dockerfile: Dockerfile
target: production
command: node worker.js
environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: always
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
volumes:
pgdata:
redis-data:
Environment Variables in Compose
Docker Compose supports .env files for variable substitution. Create a .env file next to your docker-compose.yml:
# .env
DB_USER=appuser
DB_PASS=secretpassword123
DB_NAME=myapp
NODE_ENV=production
Variables defined in .env are automatically substituted into ${VARIABLE} references in the Compose file. You can also use different env files for different environments:
# Use a specific env file
docker compose --env-file .env.production up -d
docker compose --env-file .env.staging up -d
6. Docker Networking
Docker networking determines how containers communicate with each other and with the outside world. Understanding the network drivers is essential for designing multi-container applications.
Network Drivers
Bridge (default) — Creates an isolated network on the host. Containers on the same bridge network can communicate by IP address. Custom bridge networks also enable DNS-based discovery by container name. This is the most common driver for standalone containers.
# Default bridge: containers communicate by IP only
docker run -d --name web nginx
docker run -d --name api myapp
docker inspect web | grep IPAddress # e.g., 172.17.0.2
# Custom bridge: containers communicate by name
docker network create mynet
docker run -d --name web --network mynet nginx
docker run -d --name api --network mynet myapp
# From api container: curl http://web:80 (works!)
Host — Removes network isolation between the container and the host. The container shares the host's network stack directly. No port mapping is needed or possible. Use this for maximum network performance when isolation is not a concern.
# Container uses host's network directly
docker run -d --network host nginx
# nginx is now directly accessible on host port 80
# No -p flag needed (or allowed)
Overlay — Connects containers across multiple Docker hosts. Used in Docker Swarm mode and Kubernetes. Overlay networks enable services running on different physical servers to communicate as if they were on the same LAN.
# Create an overlay network (requires Swarm mode)
docker network create --driver overlay my-overlay
# Services across different nodes can now communicate
None — Disables networking entirely. The container has only a loopback interface. Use this for containers that should be completely isolated from the network.
docker run -d --network none my-batch-processor
Macvlan — Assigns a MAC address to each container, making it appear as a physical device on the network. Use this when containers need to appear as physical hosts on the network, such as when running legacy applications that expect to bind to a specific network interface.
Container-to-Container Communication
# Best practice: use custom bridge networks
docker network create app-network
docker run -d --name db \
--network app-network \
-e POSTGRES_PASSWORD=secret \
postgres:16-alpine
docker run -d --name api \
--network app-network \
-e DATABASE_URL=postgres://postgres:secret@db:5432/postgres \
-p 3000:3000 \
myapp
# The api container reaches the database at hostname "db"
# The database port is NOT exposed to the host (more secure)
# Only the api port (3000) is exposed to the host
Port Mapping Deep Dive
# Standard mapping: all interfaces
docker run -p 8080:80 nginx
# Accessible at http://localhost:8080 AND from other machines
# Bind to localhost only (more secure for development)
docker run -p 127.0.0.1:8080:80 nginx
# Only accessible from the host machine itself
# Bind to a specific interface
docker run -p 192.168.1.100:8080:80 nginx
# Map UDP port
docker run -p 53:53/udp dns-server
# Let Docker choose the host port
docker run -p 80 nginx
docker port <container-id> # see which port was assigned
# Map multiple ports
docker run -p 80:80 -p 443:443 nginx
Network Troubleshooting
# List all networks
docker network ls
# Inspect a network (shows connected containers)
docker network inspect app-network
# Connect a running container to a network
docker network connect app-network my-container
# Disconnect from a network
docker network disconnect app-network my-container
# DNS debugging from inside a container
docker exec -it api nslookup db
docker exec -it api ping db
docker exec -it api curl http://web:80
7. Docker Volumes and Data Persistence
Containers are ephemeral by design. When a container is removed, its writable layer — and all data in it — is gone. Volumes decouple data from the container lifecycle, ensuring that database files, uploads, logs, and configuration persist across container restarts and replacements.
Three Types of Mounts
# 1. Named Volumes (recommended for persistence)
# Docker manages the storage location on the host
docker run -d -v pgdata:/var/lib/postgresql/data postgres:16
# 2. Bind Mounts (recommended for development)
# Maps a specific host directory into the container
docker run -d -v /home/user/project/src:/app/src myapp
# 3. tmpfs Mounts (in-memory, for temporary/sensitive data)
# Data exists only in memory, never written to disk
docker run -d --tmpfs /app/temp:rw,size=100m myapp
Named Volumes in Depth
# Create a volume
docker volume create my-data
# List volumes
docker volume ls
# Inspect a volume (shows mount point on host)
docker volume inspect my-data
# Output includes: "Mountpoint": "/var/lib/docker/volumes/my-data/_data"
# Use a volume with a container
docker run -d \
--name db \
-v my-data:/var/lib/postgresql/data \
postgres:16-alpine
# The same volume can be shared between containers
docker run -d \
--name db-backup \
-v my-data:/data:ro \
alpine \
tar czf /backup/db.tar.gz /data
# Remove a volume (only works if no containers are using it)
docker volume rm my-data
# Remove all unused volumes
docker volume prune
Bind Mounts for Development
Bind mounts are essential for development workflows where you want code changes on your host to be immediately reflected inside the container:
# Mount source code for live reloading
docker run -d \
--name dev-server \
-v $(pwd)/src:/app/src \
-v $(pwd)/public:/app/public \
-v /app/node_modules \
-p 3000:3000 \
myapp:dev
# Key pattern: mount source code BUT exclude node_modules
# -v /app/node_modules creates an anonymous volume that
# "masks" the host's node_modules, keeping the container's
# own installed dependencies intact
Volume Backup and Restore
# Backup a volume to a tar file
docker run --rm \
-v pgdata:/source:ro \
-v $(pwd):/backup \
alpine \
tar czf /backup/pgdata-backup.tar.gz -C /source .
# Restore from backup
docker run --rm \
-v pgdata:/target \
-v $(pwd):/backup:ro \
alpine \
sh -c "cd /target && tar xzf /backup/pgdata-backup.tar.gz"
# Copy files between host and container
docker cp mycontainer:/app/data/export.csv ./export.csv
docker cp ./config.yml mycontainer:/app/config.yml
Volume Best Practices
- Always use named volumes for databases — never rely on the container's writable layer for data you care about
- Use bind mounts only in development — they create a dependency on the host's directory structure
- Use
:ro(read-only) when the container should not modify the data — this prevents accidental corruption - Back up volumes before upgrades — especially database volumes before upgrading the database image version
- Use
tmpfsfor secrets and temporary files — data in tmpfs is never written to disk and disappears when the container stops - Do not store logs in volumes — use Docker's logging drivers to send logs to stdout/stderr, then configure log rotation
8. Docker Security Best Practices
Docker containers provide process isolation, not security isolation. By default, a container runs as root, has access to all Linux capabilities, and shares the host kernel. Securing Docker requires deliberate choices at every layer: the image, the Dockerfile, the runtime configuration, and the host system.
Run as Non-Root
Running containers as root is the most common and most dangerous Docker mistake. If an attacker escapes the container, they have root access to the host.
# Create a non-root user in your Dockerfile
FROM node:20-alpine
# Create app user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root user BEFORE CMD
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
# Or enforce non-root at runtime
docker run --user 1000:1000 myapp
# Verify: should show "appuser" or "1000", not "root"
docker exec mycontainer whoami
Use Minimal Base Images
Fewer packages mean fewer potential vulnerabilities. Choose the smallest base image that supports your application:
# Attack surface comparison:
# ubuntu:22.04 ~ 77MB, ~200 packages, ~50 known CVEs
# python:3.12 ~ 900MB, ~400 packages, ~80 known CVEs
# python:3.12-slim ~ 130MB, ~100 packages, ~20 known CVEs
# python:3.12-alpine ~ 50MB, ~30 packages, ~5 known CVEs
# distroless/python3 ~ 50MB, minimal packages, ~2 known CVEs
# scratch ~ 0MB, 0 packages, 0 known CVEs (for static binaries)
Scan Images for Vulnerabilities
# Docker Scout (built into Docker Desktop and CLI)
docker scout cves myapp:latest
docker scout quickview myapp:latest
# Trivy (popular open-source scanner)
trivy image myapp:latest
# Snyk
snyk container test myapp:latest
# Scan during CI/CD and fail the build if critical CVEs are found
trivy image --exit-code 1 --severity CRITICAL myapp:latest
Manage Secrets Properly
# NEVER do this:
ENV DATABASE_PASSWORD=my-secret-password
# Secrets in ENV are visible in docker inspect and image history
# NEVER do this:
COPY .env /app/.env
# Secrets are baked into the image layer permanently
# GOOD: Use runtime environment variables
docker run -e DATABASE_PASSWORD=secret myapp
# BETTER: Use Docker secrets (Swarm mode)
echo "my-secret-password" | docker secret create db_password -
docker service create --secret db_password myapp
# BEST: Use a secrets manager (Vault, AWS Secrets Manager, etc.)
# Application fetches secrets at runtime from the secrets manager
Limit Container Capabilities
# Drop all capabilities and add only what's needed
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
# Read-only filesystem (prevents malware from writing files)
docker run --read-only myapp
# Read-only with a writable tmp directory
docker run --read-only --tmpfs /tmp myapp
# Prevent privilege escalation
docker run --security-opt=no-new-privileges myapp
# Limit system calls with seccomp profiles
docker run --security-opt seccomp=custom-profile.json myapp
Network Security
# Only expose ports that need external access
# BAD: exposes database to the world
docker run -p 5432:5432 postgres
# GOOD: database only accessible within Docker network
docker run --network app-net postgres
# GOOD: bind to localhost only if you need host access
docker run -p 127.0.0.1:5432:5432 postgres
# Use separate networks to isolate services
docker network create frontend-net
docker network create backend-net
# Web server: connected to both frontend and backend
docker run --network frontend-net --network backend-net nginx
# Database: connected only to backend (not accessible from frontend)
docker run --network backend-net postgres
Security Checklist
- Run containers as non-root users
- Use minimal base images (Alpine, slim, distroless)
- Pin image versions and rebuild regularly
- Scan images for vulnerabilities in CI/CD
- Never put secrets in Dockerfiles or images
- Use read-only filesystems where possible
- Drop unnecessary Linux capabilities
- Bind ports to localhost unless external access is needed
- Use separate Docker networks to isolate services
- Enable Docker Content Trust for image signing
- Keep Docker Engine updated
- Use
--no-new-privilegesto prevent privilege escalation
9. Docker in CI/CD Pipelines
Docker is the backbone of modern CI/CD. Every major CI platform runs jobs inside containers, and Docker images are the standard artifact format for deploying applications. Here is how to integrate Docker into your build and deployment pipeline.
GitHub Actions
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: |
myorg/myapp:latest
myorg/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myorg/myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
GitLab CI
# .gitlab-ci.yml
stages:
- build
- test
- deploy
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $IMAGE -t $CI_REGISTRY_IMAGE:latest .
- docker push $IMAGE
- docker push $CI_REGISTRY_IMAGE:latest
test:
stage: test
image: $IMAGE
script:
- npm test
deploy:
stage: deploy
image: docker:24
only:
- main
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker pull $IMAGE
- docker tag $IMAGE $CI_REGISTRY_IMAGE:production
- docker push $CI_REGISTRY_IMAGE:production
CI/CD Best Practices
- Use BuildKit cache —
DOCKER_BUILDKIT=1enables parallel builds and better caching. Most CI platforms now default to BuildKit. - Cache Docker layers — Use registry-based caching (
--cache-from) or CI-specific caching (GitHub Actionstype=gha) to avoid rebuilding unchanged layers. - Tag with commit SHA — Always tag images with the Git commit SHA for traceability. Use
latestas an additional convenience tag, not as the sole identifier. - Scan in CI — Integrate vulnerability scanning (Trivy, Snyk, Docker Scout) and fail the build on critical findings.
- Use multi-platform builds — Build for both
linux/amd64andlinux/arm64to support diverse deployment targets (including ARM-based cloud instances and Apple Silicon development machines). - Minimize build context — A comprehensive
.dockerignorereduces context transfer time, which is especially impactful in CI where builds run on remote machines. - Use build arguments for build-time variables —
ARGfor things like version numbers and build metadata. Never useARGfor secrets (they appear in image history).
Multi-Platform Builds
# Build for multiple architectures simultaneously
docker buildx create --name multiplatform --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myorg/myapp:latest \
--push \
.
10. Docker vs Kubernetes: When to Use Which
Docker and Kubernetes are complementary, not competing technologies. Docker packages applications into containers. Kubernetes orchestrates containers across a cluster of machines. The question is not "Docker or Kubernetes" but "do I need Kubernetes in addition to Docker?"
When Docker Compose is Enough
- Local development — Compose is the standard tool for running multi-service applications on a developer's machine
- Small projects — Personal projects, MVPs, and applications with a few hundred users
- Single-server deployments — When your application fits on one server and you do not need horizontal scaling
- Simple deployment models — SSH to server, pull new images, restart services
- Hobby projects and side projects — Where operational complexity should be minimized
When You Need Kubernetes
- Multi-server deployments — Your application needs to run across multiple machines for capacity or redundancy
- Automatic scaling — You need to scale services up or down based on load (Horizontal Pod Autoscaler)
- Self-healing — Containers should be automatically restarted, rescheduled, or replaced when they fail
- Zero-downtime deployments — Rolling updates that gradually replace old containers with new ones
- Service discovery — Automatic DNS-based discovery for dozens or hundreds of services
- Multi-team environments — Namespace-based isolation so multiple teams share a cluster safely
- Complex deployment patterns — Canary deployments, blue-green deployments, A/B testing
Comparison Table
Feature Docker Compose Kubernetes
─────────────────────────────────────────────────────────────
Scope Single host Multi-host cluster
Learning curve Low (hours) High (weeks/months)
Config format docker-compose.yml Multiple YAML manifests
Scaling Manual (--scale N) Automatic (HPA)
Self-healing restart: always Full (reschedule, replace)
Rolling updates Recreate only Rolling, canary, blue-green
Service discovery Docker DNS CoreDNS + Service objects
Load balancing None built-in Built-in (Service, Ingress)
Secrets management .env files Kubernetes Secrets + Vault
Storage Docker volumes PersistentVolumes + CSI
Monitoring docker stats Prometheus + Grafana
Best for Dev, small prod Large-scale production
The Practical Path
Most teams follow this progression:
- Start with Docker — Containerize your application and use Docker Compose for local development
- Deploy with Compose — For the first production deployment, Compose on a single server is simple and sufficient
- Add orchestration when needed — When you outgrow a single server, need auto-scaling, or require zero-downtime deployments, adopt Kubernetes (or a managed service like AWS ECS/Fargate, Google Cloud Run, or Fly.io)
Do not adopt Kubernetes prematurely. The operational overhead is significant: cluster management, networking (CNI), ingress controllers, certificate management, monitoring, and logging all need to be configured. For many applications, a single server with Docker Compose and a reverse proxy handles thousands of concurrent users.
11. Common Docker Mistakes and How to Avoid Them
These are the mistakes that cost teams hours of debugging. Each one is preventable with the right practices.
Mistake 1: Using latest Tag in Production
# BAD: What does "latest" mean in 6 months?
FROM node:latest
docker pull myapp:latest
# GOOD: Explicit, reproducible versions
FROM node:20.11-alpine3.19
docker pull myapp:v2.3.1-abc1234
The latest tag is mutable. It changes every time a new image is pushed without a specific tag. Your production deployment today and your production deployment tomorrow might run completely different code. Always tag with semantic versions and/or Git commit SHAs.
Mistake 2: Running as Root
# BAD: Default is root
FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
# Container runs as root - security risk
# GOOD: Explicit non-root user
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["node", "server.js"]
Mistake 3: Storing Data Inside Containers
# BAD: Data is lost when container is removed
docker run -d postgres:16
docker rm -f <container-id> # All database data is gone!
# GOOD: Use a named volume for persistent data
docker run -d -v pgdata:/var/lib/postgresql/data postgres:16
docker rm -f <container-id> # Container gone, data safe in pgdata volume
Mistake 4: Not Using .dockerignore
# Without .dockerignore, "docker build ." sends EVERYTHING:
# - node_modules (hundreds of MB)
# - .git directory (can be huge)
# - .env files (secrets!)
# - test data, coverage reports, build artifacts
# Build context is sent over a network socket to the daemon
# Large contexts = slow builds
# Secrets in context = secrets in your image
Mistake 5: Installing Debug Tools in Production Images
# BAD: Production image has compilers, debuggers, editors
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
build-essential vim curl wget strace gdb python3 python3-pip
COPY . /app
CMD ["python3", "app.py"]
# GOOD: Minimal production image, debug in a separate container
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
# When you need to debug:
docker exec -it myapp /bin/sh
# Or attach a debug sidecar container that shares the same network/volume
Mistake 6: Not Using Health Checks
# BAD: Docker thinks the container is healthy if the process is running
# Even if the app has crashed internally or is in a broken state
# GOOD: Application-level health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD curl -f http://localhost:3000/health || exit 1
# In docker-compose.yml:
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
db:
condition: service_healthy
Mistake 7: Using docker compose down -v Carelessly
# docker compose down = stops containers and removes them
# docker compose down -v = stops containers AND DELETES ALL VOLUMES
# If your database data is in a Docker volume, "down -v" destroys it
# ALWAYS think twice before adding -v
# ALWAYS have backups before running down -v
Mistake 8: Fat Images from Bad Layer Ordering
# BAD: npm install runs on EVERY code change
COPY . .
RUN npm install
# GOOD: npm install only runs when package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Mistake 9: Ignoring Docker System Resource Usage
# Docker accumulates disk usage over time:
# - Stopped containers
# - Dangling images
# - Unused volumes
# - Build cache
# Check disk usage
docker system df
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 45 12 8.5GB 6.2GB (72%)
# Containers 30 5 1.2GB 900MB (75%)
# Build Cache 120 0 3.1GB 3.1GB (100%)
# Clean up periodically
docker system prune # removes stopped containers + dangling images
docker system prune -a # also removes unused images
docker builder prune # clears build cache
docker volume prune # removes unused volumes
Mistake 10: Exposing Database Ports to the Internet
# BAD: Database accessible from the entire internet
docker run -p 5432:5432 postgres:16
# GOOD: Database only accessible within Docker network
docker run --network app-net postgres:16
# ACCEPTABLE: Database accessible only from localhost
docker run -p 127.0.0.1:5432:5432 postgres:16
12. Performance Optimization
Docker adds minimal overhead to application performance — typically less than 1-2% for CPU-bound workloads. But poor configuration can create significant performance problems, especially around build times, image sizes, storage, and networking.
Build Performance
# Enable BuildKit (default in Docker 23+, explicit in older versions)
export DOCKER_BUILDKIT=1
# Parallel multi-stage builds
# BuildKit automatically parallelizes independent stages
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM python:3.12-slim AS backend-builder
WORKDIR /backend
COPY backend/requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Both stages build in parallel, final stage combines them
FROM python:3.12-slim
COPY --from=backend-builder /root/.local /root/.local
COPY --from=frontend-builder /frontend/dist /app/static
COPY backend/ /app/
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
# Use .dockerignore to minimize build context transfer
# Before: "Sending build context to Docker daemon 450MB"
# After: "Sending build context to Docker daemon 2.3MB"
# Mount cache directories for package managers
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
Runtime Performance
# Set appropriate resource limits
docker run -d \
--memory=512m \
--memory-swap=512m \
--cpus=2.0 \
--pids-limit=100 \
myapp
# In Docker Compose:
services:
api:
deploy:
resources:
limits:
memory: 512M
cpus: '2.0'
reservations:
memory: 256M
cpus: '0.5'
Storage Performance
# Use named volumes instead of bind mounts for databases
# Named volumes use Docker's storage driver which is optimized
# Bind mounts on macOS/Windows go through a virtualization layer
# For macOS (Docker Desktop): use synchronized file sharing
# In docker-compose.yml:
services:
web:
volumes:
- type: bind
source: ./src
target: /app/src
consistency: delegated # faster writes from container
# On Linux, bind mounts have native performance (no VM layer)
# Use tmpfs for temporary data that does not need persistence
docker run --tmpfs /app/cache:rw,size=256m myapp
Networking Performance
# Use host networking for maximum network performance
# (eliminates Docker's NAT and bridge overhead)
docker run --network host myapp
# For services that communicate heavily, put them on the same custom network
docker network create --driver bridge \
--opt com.docker.network.driver.mtu=9000 \
fast-net
# Use Docker's built-in DNS caching (custom bridge networks)
# Avoid direct IP references - use service names
Image Size Optimization
# Compare image sizes:
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
# Analyze layers to find what is consuming space:
docker history myapp:latest --no-trunc
# Use dive tool for interactive layer analysis:
# https://github.com/wagoodman/dive
dive myapp:latest
# Size optimization checklist:
# 1. Use multi-stage builds (typical: 80-95% reduction)
# 2. Use Alpine/slim/distroless bases (typical: 50-90% reduction)
# 3. Combine RUN commands (typical: 10-30% reduction)
# 4. Clean package caches in same RUN (typical: 5-20% reduction)
# 5. Use .dockerignore (build speed, not image size)
# 6. Remove unnecessary files in build (varies)
Monitoring and Profiling
# Real-time resource monitoring
docker stats
# CONTAINER CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
# web 2.5% 85MiB / 512MiB 16.6% 1.2GB / 850MB 50MB / 10MB
# api 12.3% 210MiB / 512MiB 41.0% 800MB / 1.1GB 100MB / 5MB
# db 5.1% 400MiB / 1GiB 39.1% 500MB / 800MB 2GB / 500MB
# View container processes
docker top mycontainer
# View container events
docker events --filter container=mycontainer
# Export container filesystem for analysis
docker export mycontainer | tar -tvf - | head -100
Frequently Asked Questions
What is the difference between Docker and a virtual machine?
Docker containers share the host operating system's kernel and run as isolated processes, making them lightweight (megabytes in size) and fast to start (seconds). Virtual machines run a complete guest operating system on top of a hypervisor, consuming significantly more resources (gigabytes) and taking minutes to boot. Docker is ideal for packaging and deploying applications consistently across environments. VMs are better when you need full OS isolation, need to run a different kernel version, or require a completely different operating system (such as Windows on a Linux host). In practice, many production environments use both: VMs for infrastructure isolation and Docker containers inside those VMs for application packaging.
How do I reduce Docker image size for production deployments?
The most effective technique is multi-stage builds, which separate the build environment (compilers, dev dependencies) from the runtime, typically reducing image size by 80-95%. Start with minimal base images like Alpine (5MB), slim variants (100-150MB), or distroless images. Combine RUN commands to reduce layers and clean package manager caches in the same instruction (apt-get clean && rm -rf /var/lib/apt/lists/* or pip install --no-cache-dir). Use a comprehensive .dockerignore to exclude files like node_modules, .git, and test directories from the build context. For compiled languages like Go and Rust, you can build from scratch (an empty image) to produce containers under 10MB. Pin specific image versions to ensure reproducible builds, and use tools like dive to analyze which layers consume the most space.
When should I use Docker Compose versus Kubernetes?
Use Docker Compose for local development, small projects, single-server deployments, and when your application has a handful of services that do not need auto-scaling or self-healing. Compose is simple to learn (a single YAML file), has minimal operational overhead, and is perfectly capable of running production workloads on a single server. Use Kubernetes when you need to orchestrate containers across multiple servers, require automatic horizontal scaling based on load, need zero-downtime rolling deployments, want self-healing (automatic container restart and rescheduling on node failures), or are managing dozens to thousands of services across multiple teams. Many teams successfully use Compose for development and Kubernetes for production. Managed Kubernetes services like GKE, EKS, and AKS reduce operational burden but still require significant expertise. For simpler orchestration needs, consider lightweight alternatives like Docker Swarm, Nomad, or managed platforms like AWS ECS, Google Cloud Run, or Fly.io.
Conclusion
Docker is foundational infrastructure for modern software development. In 2026, containerization is not optional — it is the baseline expectation for how applications are built, tested, and deployed. The concepts in this guide cover everything from your first docker run to production-grade CI/CD pipelines with multi-stage builds, security scanning, and performance optimization.
If you are just starting with Docker, focus on three things: write a Dockerfile for a project you already have, get comfortable with the core commands (build, run, logs, exec, stop), and use Docker Compose when you need more than one service. Everything else builds on this foundation.
If you are already using Docker in production, audit your Dockerfiles against the best practices in this guide. Are you using multi-stage builds? Running as non-root? Scanning for vulnerabilities in CI? Pinning image versions? Each improvement reduces your attack surface, speeds up your builds, and makes your deployments more reliable.
Docker is a tool you will use every day for the rest of your career. The time you invest in understanding it deeply pays dividends on every project you touch.
Learn More
- Docker Containers for Beginners: A Practical Guide — hands-on introduction if you are new to containers, covering installation through your first Compose project
- Docker Commands Cheat Sheet — one-page reference for every Docker command, flag, and common pattern
- Kubernetes Commands Cheat Sheet — kubectl commands reference for when you are ready to orchestrate at scale
- JSON vs YAML vs TOML: Choosing the Right Config Format — understand the configuration formats used in Docker Compose, Kubernetes manifests, and application config