Docker has fundamentally changed how developers build, ship, and run applications. By packaging your application and its dependencies into a portable container, you eliminate the dreaded "it works on my machine" problem for good. Whether you're a Docker newcomer or looking to sharpen your skills, this guide covers everything you need to be productive.
Containers vs. Virtual Machines
Before diving into Docker, it helps to understand what containers actually are. Unlike virtual machines that emulate an entire operating system, containers share the host OS kernel and isolate only the application layer. This makes them:
- Lightweight: Containers start in milliseconds and use far less RAM than VMs
- Portable: A container image runs identically on any machine with Docker installed
- Efficient: Run dozens of containers on hardware that might support only a handful of VMs
- Immutable: Images are versioned and reproducible, making rollbacks straightforward
Key Insight: A Docker image is a read-only blueprint; a container is a running instance of that image. You can run many containers from the same image simultaneously.
Installing Docker
Download Docker Desktop from docker.com for Windows or macOS. On Linux, install the Docker Engine via your distribution's package manager:
# Ubuntu / Debian
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io
# Verify installation
docker --version
docker run hello-world
Your First Dockerfile
A Dockerfile is a text file that contains the instructions for building a Docker image. Every line creates a new layer in the image, and layers are cached — meaning rebuilds only re-run changed layers.
Node.js Example
# Use an official Node.js runtime as the base image
FROM node:20-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy dependency manifests first (leverages layer caching)
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy the rest of the application code
COPY . .
# Expose the port the app listens on
EXPOSE 3000
# Define the command to run the app
CMD ["node", "server.js"]
Python Example
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Essential Docker Commands
These are the commands you'll use daily:
# Build an image from the Dockerfile in the current directory
docker build -t my-app:1.0 .
# Run a container (detached, with port mapping)
docker run -d -p 3000:3000 --name my-container my-app:1.0
# List running containers
docker ps
# View logs
docker logs my-container
# Open a shell inside a running container
docker exec -it my-container sh
# Stop and remove a container
docker stop my-container
docker rm my-container
# Remove an image
docker rmi my-app:1.0
# Pull an image from Docker Hub
docker pull postgres:16
Docker Compose for Multi-Container Apps
Real applications rarely run as a single container. Docker Compose lets you define and orchestrate multi-container environments in a single docker-compose.yml file. This is invaluable for local development stacks with databases, caches, and message queues.
# docker-compose.yml
version: "3.9"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/mydb
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
volumes:
- .:/app # mount source code for hot-reload
- /app/node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
cache:
image: redis:7-alpine
volumes:
postgres_data:
Manage your stack with a few simple commands:
# Start all services in the background
docker compose up -d
# View logs from all services
docker compose logs -f
# Stop all services
docker compose down
# Stop and remove volumes (wipes database data)
docker compose down -v
Managing Data with Volumes
Containers are ephemeral — any data written inside a container is lost when it's removed. Docker provides two mechanisms for persisting data:
- Named Volumes: Managed by Docker, stored in the Docker data directory. Ideal for databases and stateful services.
- Bind Mounts: A directory on the host is mounted directly into the container. Perfect for source code during development.
Development Tip: Use bind mounts for your source code so changes reflect immediately in the container without rebuilding the image. Use named volumes for database data so it survives container restarts.
Multi-Stage Builds for Production
Multi-stage builds let you use one stage to compile or build your application and a second, minimal stage for the production image. This dramatically reduces final image size.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production (only copies the compiled output)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
Docker Best Practices
Keep Images Small
Use slim or alpine base images, copy only what's needed, and leverage multi-stage builds. Smaller images pull faster, reduce attack surface, and cost less in registry storage.
Use a .dockerignore File
Similar to .gitignore, a .dockerignore file prevents unnecessary files from being sent to the Docker daemon during builds:
node_modules
.git
.env
*.log
dist
coverage
Never Store Secrets in Images
Avoid hardcoding credentials or API keys in Dockerfiles. Use environment variables, Docker secrets, or a secrets management tool like HashiCorp Vault at runtime.
Tag Images Properly
Use meaningful, versioned tags like my-app:2.1.0 or my-app:2.1.0-alpine rather than relying solely on latest. This makes rollbacks and debugging drastically easier in production.
- Pin base image versions (e.g.,
node:20.11-alpinenotnode:alpine) - Combine
RUNcommands to reduce layer count - Run containers as a non-root user for security
- Add health checks so orchestrators know when a container is ready
Docker in CI/CD
Docker integrates seamlessly into any CI/CD pipeline. A typical workflow builds the image, runs tests inside a container, and pushes to a registry on success:
# GitHub Actions example
- name: Build and push Docker image
run: |
docker build -t ghcr.io/my-org/my-app:${{ github.sha }} .
docker push ghcr.io/my-org/my-app:${{ github.sha }}
Next Steps
Once you're comfortable with Docker, explore these topics to level up:
- Kubernetes: Orchestrate containers at scale across clusters
- Docker Swarm: Simpler built-in orchestration for smaller deployments
- Container Registries: Docker Hub, GitHub Container Registry, AWS ECR
- Dive tool: Inspect image layers to find size optimization opportunities
Takeaway: Start by containerizing one service in your current project. The learning curve is shallow but the payoff — consistent environments, faster onboarding, and simpler deployments — is enormous.
Conclusion
Docker is no longer optional knowledge for modern developers. It's the lingua franca of software delivery. By mastering Dockerfiles, Docker Compose, and container best practices, you'll ship more reliably, onboard teammates in minutes rather than hours, and lay the groundwork for adopting Kubernetes and cloud-native architectures.