devops 20 read

Docker for Developers: Practical Guide to Containerization

Learn Docker from a developer's perspective. Practical containerization guide covering Dockerfiles, compose, best practices, and real-world workflows.

By Dmytro Klymentiev
Docker for Developers: Practical Guide to Containerization

Docker changed how developers build, ship, and run applications. If you're still setting up development environments manually or hearing "works on my machine," Docker solves these problems.

I've been using Docker for all my projects since 2018. In my experience, it's one of those tools that pays for the learning investment many times over. This Docker for developers guide focuses on practical skills - not DevOps theory, just what you need to be productive with containers.

Why Docker for Developers Matters

Before Docker, developers faced constant friction:

Environment inconsistency: Your code works locally but breaks in production. Dependencies differ between machines.

Onboarding pain: New team members spend days setting up development environments.

Dependency hell: Project A needs Node 14, Project B needs Node 18. Installing databases, services, and tools conflicts.

Works on my machine: The phrase every developer dreads hearing.

Docker solves all of this by packaging your application with its entire environment. Docker for developers means consistency everywhere your code runs.

Docker Concepts for Developers

Let's clarify the essential concepts before diving into Docker for developers workflows.

Images vs Containers

Image: A blueprint. Contains your application code, runtime, libraries, and configuration. Read-only.

Container: A running instance of an image. You can have multiple containers from one image.

Think of it like this:

  • Image = Class definition
  • Container = Object instance

Dockerfile

A text file with instructions to build an image. Docker reads this file and creates an image layer by layer.

Docker Compose

A tool for defining multi-container applications. One YAML file describes all services, networks, and volumes your application needs.

Volumes

Persistent storage that survives container restarts. Essential for databases and any data you don't want to lose.

Networks

How containers communicate. Docker creates isolated networks for your services.

Installing Docker for Development

macOS

Download Docker Desktop from docker.com or use Homebrew:

brew install --cask docker

Start Docker Desktop. Verify installation:

docker --version
docker compose version

Windows

Download Docker Desktop for Windows. Requires WSL2 (Windows Subsystem for Linux).

Enable WSL2 first:

wsl --install

Then install Docker Desktop and enable WSL2 backend in settings.

Linux (Ubuntu/Debian)

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add user to docker group (avoids sudo)
sudo usermod -aG docker $USER

# Log out and back in, then verify
docker --version

Install Docker Compose:

sudo apt install docker-compose-plugin -y
docker compose version

Your First Dockerfile

Let's create a Dockerfile for developers starting with a Node.js application:

# Base image
FROM node:20-alpine

# Set working directory
WORKDIR /app

# Copy package files first (for better caching)
COPY package*.json ./

# Install dependencies
RUN npm ci

# Copy application code
COPY . .

# Expose port
EXPOSE 3000

# Start command
CMD ["npm", "start"]

Build and run:

# Build image
docker build -t my-app .

# Run container
docker run -p 3000:3000 my-app

Your app runs at http://localhost:3000.

Dockerfile Best Practices for Developers

Writing a Dockerfile is easy. Writing a good Dockerfile takes practice. These best practices will save you from common mistakes and make your containers more secure, faster to build, and easier to maintain.

1. Use Official Base Images

When choosing a base image, always prefer official images from Docker Hub. Why does this matter?

Third-party images can contain anything - outdated software, security vulnerabilities, or even malicious code. You have no control over when they're updated or whether the maintainer follows security practices.

Official images, on the other hand, are maintained by Docker and the software vendors themselves. They receive regular security patches, follow best practices, and are scanned for vulnerabilities.

# Good - official image maintained by Node.js team
FROM node:20-alpine

# Risky - who maintains this? When was it last updated?
FROM some-random-user/node-custom

Before using any third-party image, ask yourself: Do I trust this publisher? Is this image actively maintained? Can I verify what's inside?

2. Use Alpine for Smaller Images

Alpine Linux is a minimal distribution designed for security and small size. The difference is dramatic - a standard Node.js image is over 1GB, while the Alpine variant is under 200MB.

# Standard Debian-based: ~1.1GB
FROM node:20

# Alpine-based: ~180MB
FROM node:20-alpine

Why does image size matter for Docker for developers?

Faster builds: Less data to process and transfer.

Faster deployments: Smaller images push and pull from registries much faster. In CI/CD pipelines, this adds up quickly.

Reduced attack surface: Fewer packages installed means fewer potential vulnerabilities.

Lower storage costs: Especially relevant if you're paying for container registry storage.

The trade-off: Alpine uses musl instead of glibc, which can cause compatibility issues with some native modules. If you encounter strange errors, try the slim variant (e.g., node:20-slim) as a middle ground.

3. Layer Ordering Matters

Docker builds images in layers, and it caches each layer. When you change a file, Docker rebuilds that layer and all layers after it. Understanding this is crucial for fast builds.

Think about what changes frequently versus what stays stable:

  • Your application code changes constantly during development
  • Your dependencies (package.json) change occasionally
  • Your base image rarely changes

Structure your Dockerfile so stable layers come first:

# Good approach - dependencies cached separately from code
COPY package*.json ./
RUN npm ci
COPY . .

# Bad approach - any code change forces dependency reinstall
COPY . .
RUN npm ci

With the good approach, if you only change your application code, Docker reuses the cached dependency layer. This can reduce build times from minutes to seconds.

4. Use .dockerignore

When you run docker build, Docker sends your entire project directory to the daemon as "build context." Without a .dockerignore file, this includes everything - node_modules, git history, local environment files, and more.

Create a .dockerignore file to exclude files that shouldn't be in your image:

# Dependencies - we install fresh in container
node_modules

# Version control - not needed in production
.git
.gitignore

# Environment files - contain secrets!
.env
.env.local
.env.*.local

# Development artifacts
*.log
.DS_Store
coverage
.nyc_output

# Build output - if we're doing multi-stage build
dist
build

This serves two purposes: faster builds (less data to transfer) and security (sensitive files like .env don't accidentally end up in your image).

5. Multi-Stage Builds

Here's a common problem: your development image needs compilers, build tools, and dev dependencies to build your application. But your production image only needs to run the compiled code.

Multi-stage builds solve this elegantly. You use one stage to build, then copy only the artifacts to a clean production image:

# Stage 1: Build environment with all dev dependencies
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Clean production image
FROM node:20-alpine
WORKDIR /app

# Copy only what we need to run
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

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

The final image contains only your compiled code and production dependencies. No TypeScript compiler, no test frameworks, no source code. This means smaller images, faster deployments, and less exposure of your source code.

6. Run Containers as Non-Root User

By default, containers run as root. This is convenient but dangerous - if an attacker exploits a vulnerability in your application, they have root access inside the container. Combined with a container escape vulnerability, they could gain root access to your host system.

The solution is simple: create a non-root user and run your application as that user.

FROM node:20-alpine

# Create a dedicated user and group for our application
# Using specific IDs (1001) ensures consistent permissions
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -D appuser

WORKDIR /app

# Copy files and set ownership to our non-root user
COPY --chown=appuser:appgroup . .

# Switch to non-root user before running anything
USER appuser

CMD ["npm", "start"]

After the USER appuser instruction, all subsequent commands run as that user. Your application can't modify system files, install packages, or perform other privileged operations - which is exactly what you want in production.

Some official images (like Node.js) include a pre-created non-root user called node. You can use USER node instead of creating your own.

Docker Compose for Developers

Real applications don't run in isolation. Your web app needs a database. Maybe Redis for caching. Perhaps a message queue. Running each container manually with docker run becomes tedious and error-prone.

Docker Compose solves this by letting you define your entire application stack in a single YAML file. One command starts everything. Another stops it. This is where Docker for developers transforms your workflow.

Understanding docker-compose.yml

Let's build a typical development setup - a Node.js app with PostgreSQL:

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      - db

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

Let's break down what each section does:

services: Defines each container in your stack. Here we have app (our code) and db (PostgreSQL).

build: . tells Docker to build an image from the Dockerfile in the current directory.

ports: "3000:3000" maps port 3000 on your machine to port 3000 in the container. You access your app at localhost:3000.

environment: Sets environment variables inside the container. Notice we reference db in the DATABASE_URL - Docker Compose creates a network where services can reach each other by name.

volumes: The first volume (.:/app) mounts your current directory into the container, enabling hot reload. The second (/app/node_modules) is a trick - it creates an anonymous volume that preserves the container's node_modules, preventing your local node_modules from overwriting it.

depends_on: Tells Docker to start the database before the app. Note: this only waits for the container to start, not for PostgreSQL to be ready. For production, you'd add health checks.

postgres_data: A named volume that persists your database data. Without this, your data would vanish when you stop the container.

To start your entire stack:

docker compose up -d

The -d flag runs containers in the background (detached mode). Without it, logs from all containers stream to your terminal.

To stop everything:

docker compose down

Add -v to also remove volumes (careful - this deletes your database data).

Development vs Production Compose

Your development environment needs different settings than production. In development, you want hot reload, exposed ports for debugging, and verbose logging. In production, you want optimized builds, restart policies, and no exposed database ports.

Docker Compose handles this with file overrides. The pattern works like this:

  1. docker-compose.yml contains settings shared across all environments
  2. docker-compose.override.yml contains development-specific settings (automatically loaded)
  3. docker-compose.prod.yml contains production settings (explicitly loaded)

Here's how to structure this:

docker-compose.yml - the base configuration:

version: '3.8'

services:
  app:
    build: .
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=myapp

docker-compose.override.yml - development settings. Docker Compose automatically loads this file when you run docker compose up, so you don't need to specify it:

version: '3.8'

services:
  app:
    volumes:
      - .:/app                    # Mount code for hot reload
    ports:
      - "3000:3000"              # Expose app port
    environment:
      - NODE_ENV=development
    command: npm run dev          # Use dev server

  db:
    ports:
      - "5432:5432"              # Expose DB for local tools

docker-compose.prod.yml - production settings:

version: '3.8'

services:
  app:
    restart: always              # Restart if container crashes
    environment:
      - NODE_ENV=production

  db:
    restart: always
    # No exposed ports in production

Use production config:

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Docker for Developers: Common Workflows

Once you have Docker Compose set up, you'll find yourself doing the same tasks repeatedly. Here are the workflows you'll use daily, with explanations of when and why to use each.

Hot Reloading

During development, you want to see changes immediately without rebuilding the container. The key is mounting your source code as a volume:

services:
  app:
    volumes:
      - .:/app              # Your code syncs into container
      - /app/node_modules   # But keep container's dependencies
    command: npm run dev    # Use a dev server that watches files

The first volume mounts your project directory into /app in the container. Any file you edit on your machine instantly appears in the container. The second volume is a trick - it creates an anonymous volume for node_modules, preventing your local folder from overwriting the container's installed packages.

Combined with a dev server like Vite, nodemon, or webpack-dev-server, your changes appear in the browser within milliseconds.

Running Tests

You might be tempted to run tests locally, but running them in Docker ensures consistency with your CI environment. The run command creates a new container, executes your command, and removes the container when done:

docker compose run --rm app npm test

The --rm flag automatically removes the container after the command finishes. Without it, you'd accumulate stopped containers over time.

Need to run specific tests or pass additional arguments? Everything after -- goes to your test command:

docker compose run --rm app npm test -- --grep "auth" --watch

Database Migrations

Migrations should run inside a container that has access to your database network. Using docker compose run ensures the database connection works exactly as it does in your app:

docker compose run --rm app npm run migrate

This is safer than running migrations locally because it uses the same database connection string, the same network, and the same environment as your application.

For seeding test data:

docker compose run --rm app npm run seed

Debugging with Interactive Shell

Sometimes you need to poke around inside a container - check if files exist, test network connectivity, or run one-off commands. Docker provides two ways to do this:

To open a shell in an already running container:

docker compose exec app sh

This drops you into the container where your app is running. You can inspect the file system, check environment variables with env, or test database connections.

To spin up a new container just for debugging:

docker compose run --rm app sh

This is useful when you want a clean environment or when the main container isn't running.

Understanding Container Logs

When something goes wrong - or even when it doesn't - logs are your window into what's happening. Docker Compose aggregates logs from all your services:

docker compose logs -f

The -f flag follows the log output in real-time, like tail -f. You'll see interleaved logs from all services, color-coded by service name.

To focus on a single service:

docker compose logs -f app

When a container has been running for a while and you don't need ancient history:

docker compose logs --tail 100 app

This shows only the last 100 lines, then follows new output.

Starting Fresh

Docker caches aggressively, which is usually good. But sometimes you need a completely clean slate - maybe a dependency changed, or you're debugging a build issue.

To stop everything and remove all containers and volumes:

docker compose down -v

The -v flag removes named volumes too. Be careful - this deletes your database data!

To rebuild images without using any cached layers:

docker compose build --no-cache

Then start fresh:

docker compose up -d

This nuclear option takes longer but guarantees a clean environment.

Docker for Developers: Language-Specific Examples

Every language has its quirks when it comes to Docker. Here are production-ready examples for popular stacks, with explanations of language-specific considerations.

Python Flask Application

Python applications need special attention to dependency management. We use requirements.txt and install dependencies in a separate layer for better caching.

Dockerfile:

FROM python:3.11-slim

WORKDIR /app

# Install dependencies first - this layer gets cached
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Then copy application code
COPY . .

EXPOSE 5000

CMD ["python", "app.py"]

Key points for Python:

  • Use slim or alpine: Full Python images are large (~900MB vs ~150MB for slim)
  • --no-cache-dir: Prevents pip from caching packages, reducing image size
  • Copy requirements.txt first: Dependencies rarely change, so this layer stays cached

docker-compose.yml - A typical Flask setup with PostgreSQL and Redis for sessions/caching:

version: '3.8'

services:
  web:
    build: .
    ports:
      - "5000:5000"
    volumes:
      - .:/app
    environment:
      - FLASK_ENV=development
      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  postgres_data:

For production, consider using Gunicorn instead of Flask's dev server: CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]

PHP Laravel Application

PHP requires a bit more setup because Laravel typically runs behind Nginx. PHP-FPM handles PHP processing, while Nginx serves static files and proxies PHP requests.

Dockerfile:

FROM php:8.2-fpm-alpine

# Install PHP extensions required by Laravel
RUN docker-php-ext-install pdo pdo_mysql

# Composer from official image - this is the cleanest approach
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

COPY . .

# Install dependencies (use --no-dev for production)
RUN composer install --no-dev --optimize-autoloader

EXPOSE 9000

CMD ["php-fpm"]

docker-compose.yml - Notice how we need three services: PHP-FPM, Nginx, and MySQL:

version: '3.8'

services:
  app:
    build: .
    volumes:
      - .:/var/www/html
    depends_on:
      - mysql

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

  mysql:
    image: mysql:8
    environment:
      - MYSQL_ROOT_PASSWORD=secret
      - MYSQL_DATABASE=laravel
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

Why three containers? PHP-FPM only processes PHP code - it doesn't serve HTTP. Nginx handles HTTP requests, serves static files directly, and forwards PHP requests to the FPM container. This separation mirrors how production PHP applications typically run.

You'll also need an nginx.conf file that tells Nginx how to communicate with PHP-FPM:

server {
    listen 80;
    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Go Application

Go is arguably the best language for Docker. Go compiles to a single static binary with no external dependencies, meaning your production image can be incredibly minimal.

Dockerfile:

# Build stage - we need the full Go toolchain
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Download dependencies first for better caching
COPY go.* ./
RUN go mod download

# Build the binary
COPY . .
RUN CGO_ENABLED=0 go build -o main .

# Production stage - just the binary, nothing else
FROM alpine:latest

WORKDIR /app
COPY --from=builder /app/main .

EXPOSE 8080

CMD ["./main"]

Key points for Go:

  • CGO_ENABLED=0: Produces a statically linked binary that doesn't need libc
  • Multi-stage is essential: The Go toolchain is ~800MB, but your final image can be under 20MB
  • Alpine or scratch: For maximum minimalism, you can even use FROM scratch - literally an empty image with just your binary

The result? A production image that's often 10-20MB, starts in milliseconds, and has an incredibly small attack surface.

Docker Networking for Developers

Understanding Docker networking solves many "why can't my container connect?" problems. Here's what you need to know.

Default Bridge Network

Docker Compose automatically creates a network for your services. Within this network, containers can reach each other using their service names as hostnames:

services:
  app:
    environment:
      - DB_HOST=db    # Not localhost, not IP - just "db"
  db:
    image: postgres

When your app connects to db:5432, Docker's internal DNS resolves db to the container's IP address. This is why you never hardcode container IPs - they change every time containers restart.

This "service discovery" is one of Docker Compose's killer features. Your app doesn't need to know anything about the infrastructure - just the service names.

Custom Networks for Isolation

For more complex setups, you might want containers that can't talk to each other. A typical example: your frontend should talk to the backend, but never directly to the database.

services:
  frontend:
    networks:
      - frontend-net    # Can only reach backend

  backend:
    networks:
      - frontend-net    # Frontend can reach it
      - backend-net     # Can reach database

  db:
    networks:
      - backend-net     # Only backend can reach it

networks:
  frontend-net:
  backend-net:

Now the frontend container literally can't connect to the database - it's on a different network. This isn't just a convention; it's enforced by Docker. This reduces your attack surface if any container is compromised.

Backend can reach both frontend and db. Frontend can't reach db directly.

Accessing Host Machine

Use host.docker.internal (macOS/Windows) or 172.17.0.1 (Linux) to reach host services.

Docker Volumes for Developers

Named Volumes (Persistent Data)

volumes:
  postgres_data:

services:
  db:
    volumes:
      - postgres_data:/var/lib/postgresql/data

Data survives container removal. Use for databases.

Bind Mounts (Development)

services:
  app:
    volumes:
      - .:/app              # Current directory
      - ./config:/app/config # Specific folder

Maps host directories into container. Changes reflect immediately.

Anonymous Volumes (Preserve Container Data)

services:
  app:
    volumes:
      - .:/app
      - /app/node_modules   # Preserve container's modules

Prevents host mount from overwriting container's node_modules.

Docker for Developers: Debugging

Inspect Container

# View container details
docker inspect container_name

# View resource usage
docker stats

# View processes in container
docker top container_name

Debug Network Issues

# List networks
docker network ls

# Inspect network
docker network inspect bridge

# Test connectivity from container
docker compose exec app ping db

Common Issues

Port already in use:

# Find what's using port
lsof -i :3000

# Or change port in compose
ports:
  - "3001:3000"

Permission denied:

# Fix volume permissions
docker compose run --rm app chown -R node:node /app

Container keeps restarting:

# Check logs
docker compose logs app

# Check exit code
docker inspect --format='{{.State.ExitCode}}' container_name

Out of disk space:

# Remove unused resources
docker system prune -a

# Remove volumes too
docker system prune -a --volumes

Docker for Developers: CI/CD Integration

GitHub Actions Example

name: Build and Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: username/app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Build Caching in CI

Use BuildKit cache mounts for faster builds:

# syntax=docker/dockerfile:1
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .

Docker Security for Developers

Image Security

# Scan for vulnerabilities
docker scout cves my-image

# Use minimal base images
FROM node:20-alpine
# Not: FROM node:20

Secret Management

Never put secrets in Dockerfiles:

# Bad
ENV API_KEY=secret123

# Good - pass at runtime
docker run -e API_KEY=$API_KEY my-app

Or use Docker secrets:

secrets:
  api_key:
    file: ./api_key.txt

services:
  app:
    secrets:
      - api_key

Resource Limits

Prevent containers from consuming all resources:

services:
  app:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'

Docker for Developers Checklist

Development Setup

  • Docker Desktop installed
  • docker-compose.yml for local development
  • .dockerignore configured
  • Hot reload working
  • Database persisted with volume

Dockerfile Quality

  • Using official base image
  • Alpine variant when possible
  • Multi-stage build for production
  • Non-root user
  • Layers ordered for caching

Docker Compose

  • Environment variables externalized
  • Volumes for persistent data
  • Health checks configured
  • Depends_on for startup order

Operations

  • Logs accessible
  • Resource limits set
  • Cleanup commands documented
  • CI/CD pipeline using Docker

Summary

Docker for developers transforms your workflow:

  1. Consistent environments - Same container everywhere
  2. Quick onboarding - docker compose up and running
  3. Isolated projects - No dependency conflicts
  4. Production parity - Development matches production
  5. Easy collaboration - Share Dockerfile and compose

Start with a simple Dockerfile. Add docker-compose.yml for databases and services. Use volumes for persistent data and hot reloading. Build on this foundation as needs grow.

Related resources:


Need help containerizing your applications or setting up Docker-based development environments? Let's talk about your infrastructure needs.

Need help with devops?

Let's discuss your project

Get in touch
RELATED