Docker for Developers: A Practical Introduction

Docker changed how applications are built, shipped, and run. Containers solve the "it works on my machine" problem and form the foundation of modern deployment pipelines. Here's what you need to know as a developer.

Containers vs Virtual Machines

"It works on my machine" is a punchline, but it's also a real productivity killer. Docker solves it by packaging your application with everything it needs to run — the runtime, the dependencies, the config — so the environment is identical whether you're on a MacBook, a Linux CI server, or a production VM. That consistency alone is worth the learning curve.

Containers take a different approach. Instead of virtualizing hardware, they virtualize the operating system. Containers share the host kernel but have isolated filesystems, processes, networks, and resource limits. A container starts in milliseconds, uses megabytes of memory, and shares common base layers between images.

The trade-off: containers are less isolated than VMs (they share the kernel), but for most development and deployment use cases, the isolation is more than sufficient.

Core Docker Concepts

  • Image: A read-only blueprint for a container. Built from a Dockerfile. Stored in registries like Docker Hub or AWS ECR.
  • Container: A running instance of an image. Ephemeral — when it stops, any changes to its filesystem are lost (unless using volumes).
  • Dockerfile: A text file with instructions for building an image. Each instruction creates a new layer in the image.
  • Registry: A server for storing and distributing images. Docker Hub is the default public registry.
  • Volume: A mechanism for persisting data beyond a container's lifetime, or sharing data between containers.

Writing a Dockerfile

A Dockerfile defines the environment your application runs in. Here's a well-structured example for a Node.js application:

# Use an official base image (specific version for reproducibility)
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy dependency files first (layer caching optimization)
COPY package.json package-lock.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"]

The order of instructions matters for build performance. Docker caches each layer — if a layer's inputs haven't changed, Docker reuses the cached version. By copying package.json before the source code, dependencies are only reinstalled when package.json changes, not on every code change.

Essential Docker Commands

# Build an image from current directory
docker build -t myapp:1.0 .

# Run a container
docker run -p 3000:3000 myapp:1.0

# Run in detached (background) mode
docker run -d -p 3000:3000 --name myapp myapp:1.0

# List running containers
docker ps

# View container logs
docker logs myapp

# Execute a command inside a running container
docker exec -it myapp sh

# Stop and remove a container
docker stop myapp && docker rm myapp

# List images
docker images

# Remove an image
docker rmi myapp:1.0

Docker Compose for Multi-Container Apps

Real applications usually need multiple services: a web server, a database, a cache. Docker Compose lets you define and run multi-container applications with a single YAML file:

# 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:
      - ./src:/app/src  # mount source for hot reload

  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:
# Start all services
docker compose up

# Start in background
docker compose up -d

# Stop all services
docker compose down

# View logs for all services
docker compose logs -f

# Rebuild and restart
docker compose up --build

Multi-Stage Builds

Multi-stage builds reduce final image size by separating the build environment from the runtime environment. This is especially useful for compiled languages:

# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production image (much smaller)
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"]

The final image only contains the production runtime — not the build tools, dev dependencies, or source files. A Node.js app might shrink from 1.5GB to 150MB using this pattern.

The .dockerignore File

Like .gitignore, .dockerignore tells Docker which files to exclude from the build context. This speeds up builds and prevents accidentally copying secrets:

node_modules
.git
.env
.env.local
*.log
dist
coverage
.DS_Store
README.md
docker-compose*.yml

Dockerfile Best Practices

  • Use specific base image tags: Pin to node:20.11-alpine not node:latest to ensure reproducible builds.
  • Run as non-root: Add a user to run the application process — root inside a container can still be a security risk.
  • Minimize layers: Chain RUN commands with && to reduce layer count and clean up in the same layer.
  • Never store secrets in images: Pass secrets as environment variables at runtime, not in the Dockerfile or image layers.
  • Use alpine variants: Alpine-based images are significantly smaller, reducing pull times and attack surface.
# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Minimize layers and clean up in one RUN
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*
Docker Desktop vs Docker Engine

On Mac and Windows, Docker Desktop provides a graphical interface and runs Docker in a lightweight VM. On Linux servers, Docker Engine runs natively. For production deployments, Docker alone is usually not enough — container orchestration platforms like Kubernetes or managed services like AWS ECS, Google Cloud Run, or Railway handle scheduling, scaling, health checks, and rolling deployments.

Conclusion

Docker solves real problems: consistent environments across development, staging, and production; fast, reproducible builds; and simplified local development of complex multi-service applications. Starting with Docker might feel like added complexity, but the payoff — eliminated environment issues, easy onboarding for new developers, and deployment confidence — is significant. Start by containerizing one service, learn Docker Compose for local development, and build from there. The patterns covered here apply to any language or framework.