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:
- docker-compose.yml contains settings shared across all environments
- docker-compose.override.yml contains development-specific settings (automatically loaded)
- 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:
- Consistent environments - Same container everywhere
- Quick onboarding -
docker compose upand running - Isolated projects - No dependency conflicts
- Production parity - Development matches production
- 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:
- CI/CD Pipeline Example - Integrate Docker with your pipeline
- Kubernetes for Small Teams - When you outgrow Docker Compose
Need help containerizing your applications or setting up Docker-based development environments? Let's talk about your infrastructure needs.