Docker for Developers: A Complete Getting Started Guide (2026)

Docker is one of the most impactful tools in modern software development, yet it remains intimidating to developers who haven't used it before. This guide explains Docker from first principles, then walks through containerizing a real application — no prior DevOps experience required.

Why Docker Exists: The Problem It Solves

The classic developer complaint: "It works on my machine." Your app runs fine locally with Node.js 20 and PostgreSQL 15. Your teammate has Node.js 18 and PostgreSQL 14 and gets different behavior. The production server runs CentOS with a system-installed Node.js 16 and the app crashes on startup.

Docker solves this by packaging your application and everything it needs to run — the runtime, system libraries, configuration, and dependencies — into a single self-contained unit called a container. That container runs identically on any machine that has Docker installed, from a developer's MacBook to a Linux server in a data center.

Core Concepts: Images and Containers

Two terms trip up every Docker beginner:

  • Image: A read-only template — a blueprint that describes the filesystem, environment variables, and startup command for a container. Images are built from a Dockerfile.
  • Container: A running instance of an image. You can run many containers from the same image simultaneously. A container is disposable — you stop it, delete it, and start a fresh one with no state carried over (unless you use volumes).

The analogy: an image is a class definition; a container is an instance of that class.

Installing Docker

# macOS / Windows: Install Docker Desktop from docker.com
# Linux (Ubuntu/Debian):
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER   # allow running docker without sudo
newgrp docker

# Verify install
docker --version    # Docker version 26.x.x
docker run hello-world

Your First Dockerfile

A Dockerfile is a text file that defines how to build an image. Here's one for a Node.js application:

# Start from the official Node.js 22 LTS image (Alpine = minimal Linux)
FROM node:22-alpine

# Set working directory inside the container
WORKDIR /app

# Copy dependency manifests first (for layer caching)
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci --only=production

# Copy the rest of the application code
COPY . .

# Expose port 3000 (documentation only — doesn't publish it)
EXPOSE 3000

# Default command when the container starts
CMD ["node", "src/index.js"]

Build and run it:

# Build the image, tag it "my-app"
docker build -t my-app .

# Run a container, publish port 3000 to host port 3000
docker run -p 3000:3000 my-app

# Run in detached (background) mode
docker run -d -p 3000:3000 --name my-app-container my-app

# View running containers
docker ps

# View logs
docker logs my-app-container

# Stop and remove
docker stop my-app-container
docker rm my-app-container

Docker Layer Caching

Docker builds images in layers — each instruction in a Dockerfile is a layer. Layers are cached: if a layer hasn't changed, Docker reuses it from cache instead of rebuilding. This is why you should copy package.json and run npm install before copying your source code:

# Good: dependency layer cached until package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .                    # source changes here don't bust the npm cache

# Bad: any source change forces npm install to re-run
COPY . .
RUN npm ci

In a large project, this difference can mean rebuilds taking 30 seconds instead of 3 minutes.

Docker Compose: Running Multiple Services

Most applications need more than one container — a web server, a database, a cache. Docker Compose lets you define all of them in a single compose.yml (formerly docker-compose.yml) and start them with one command.

# compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DB_HOST=db
      - DB_PORT=5432
      - DB_NAME=myapp
      - DB_USER=postgres
      - DB_PASS=secret
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - .:/app                    # mount source code for hot reload in dev
      - /app/node_modules         # keep container's node_modules

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data   # persist DB data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
docker compose up           # start all services (attached)
docker compose up -d        # start in background
docker compose down         # stop and remove containers
docker compose down -v      # also remove volumes (wipes DB data)
docker compose logs -f app  # tail logs for the app service
docker compose exec app sh  # open a shell inside the app container

Volumes: Persisting Data

Containers are stateless — when you remove a container, its filesystem is gone. Volumes are the mechanism for persisting data outside the container:

# Named volume (managed by Docker — survives container removal)
docker volume create mydata
docker run -v mydata:/app/data my-app

# Bind mount (maps a host directory into the container)
docker run -v $(pwd)/data:/app/data my-app

# Read-only bind mount
docker run -v $(pwd)/config:/app/config:ro my-app

In development: use bind mounts so code changes on your host are reflected inside the container instantly. In production: use named volumes for database files; never bind-mount source code.

Multi-Stage Builds for Production

Production images should be small and contain no build tools. Multi-stage builds let you use a fat image for building and a minimal image for the final artifact:

# Stage 1: Build TypeScript
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build           # outputs to /app/dist

# Stage 2: Production runtime (minimal)
FROM node:22-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist  # only copy compiled output

EXPOSE 3000
CMD ["node", "dist/index.js"]

The production image only contains the compiled JavaScript and production dependencies — no TypeScript compiler, no devDependencies. This typically reduces image size by 40–60%.

Containerizing a PHP Application

FROM php:8.3-fpm-alpine

# Install PHP extensions
RUN apk add --no-cache \
        libpng-dev libjpeg-dev \
    && docker-php-ext-install pdo pdo_mysql gd

# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader

EXPOSE 9000
CMD ["php-fpm"]

PHP-FPM runs PHP processing; Nginx handles HTTP. In Compose, you'd pair them:

services:
  php:
    build: .
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - .:/var/www/html
    depends_on:
      - php

Useful Docker Commands Cheat Sheet

# Images
docker images                       # list local images
docker pull postgres:17             # download image
docker rmi my-app                   # remove image
docker image prune                  # remove dangling images

# Containers
docker ps                           # running containers
docker ps -a                        # all containers (including stopped)
docker stop <id>                    # graceful stop
docker kill <id>                    # force stop
docker rm <id>                      # remove container
docker container prune              # remove all stopped containers

# Debugging
docker exec -it <id> sh            # shell into running container
docker logs -f <id>                # tail logs
docker inspect <id>                # full container metadata
docker stats                        # live resource usage

# Cleanup
docker system prune -a              # remove everything unused (images, containers, volumes, networks)

Docker in CI/CD

Building and pushing Docker images in GitHub Actions:

# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Summary

Docker's core value is deterministic environments — the same container runs identically across development, staging, and production. Start with a simple Dockerfile for your app, add a compose.yml to wire up the database and cache, and use multi-stage builds for production images. Once you've containerized one project, the pattern becomes straightforward to apply everywhere.

Frequently Asked Questions

What's the difference between Docker and a virtual machine?

A virtual machine (VM) emulates an entire computer — CPU, RAM, storage, and a full OS kernel. Docker containers share the host machine's OS kernel, so they're far lighter: containers start in milliseconds (vs. minutes for VMs), use less memory (megabytes vs. gigabytes), and have near-native performance. The trade-off is isolation: VMs are fully isolated; containers share the kernel, which is a smaller security boundary (though Docker's namespacing is robust for most use cases).

Should I use Docker in development or only in production?

Both. In development, Docker ensures everyone on the team runs the same database version, same Redis, same system libraries — eliminating "works on my machine" issues. The bind-mount pattern (mounting your source code into the container) gives you hot reload without losing containerization benefits. In production, Docker enables consistent deployments, easy scaling, and rollbacks. Use it in both environments with different compose.yml files or Docker build targets.

What is Docker Hub?

Docker Hub (hub.docker.com) is the default public registry for Docker images. When you run docker pull postgres:17, Docker downloads the official PostgreSQL image from Docker Hub. You can also push your own images to Docker Hub (public or private), or use alternative registries like GitHub Container Registry (ghcr.io), AWS ECR, or Google Artifact Registry for private images in CI/CD pipelines.

What's the difference between Docker Compose and Kubernetes?

Docker Compose is a simple tool for running multi-container applications on a single machine. It's ideal for development environments and small deployments. Kubernetes (K8s) is an orchestration platform for running containers across a cluster of many machines — it handles automatic scaling, self-healing (restarting crashed containers), rolling deployments, load balancing, and service discovery. For most applications serving under a few million requests per month, Docker Compose or a managed container service (Fly.io, Railway, Render) is sufficient. Kubernetes is powerful but adds significant operational complexity.