Docker & Containerization for Developers#
Table of Contents#
- 1. Containers vs Virtual Machines (Mental Model)
- 2. Core Primitives: Images, Containers, Volumes & Registries
- 3. Anatomy of a Clean Dockerfile
- 4. Production Multi-Stage Builds (Shrink Image Size)
- 5. Layer Caching & .dockerignore Optimization
- 6. Container Security: Non-Root Users & Secrets
- 7. Docker Compose for Local Multi-Service Development
- 8. Container Networking & Embedded DNS
- 9. PID 1, Signals & Graceful Container Shutdown
- 10. Developer Debugging & CLI Toolkit
1. Containers vs Virtual Machines (Mental Model)#
Traditional Virtual Machines (VMs) virtualize physical hardware. Each VM runs a full guest operating system kernel (gigabytes in size, minutes to boot) on top of a hypervisor. In contrast, Docker containers virtualize the operating system. All containers share the host Linux kernel directly, isolated through two Linux kernel primitives:
Under the Hood of a Container
- Linux Namespaces (Visibility Boundary): Partitions system resources so each container sees its own isolated view of processes (PID namespace), network interfaces (NET namespace), mount points (MNT namespace), and user IDs (USER namespace).
- cgroups / Control Groups (Resource Boundary): Enforces hardware limits (maximum memory allocation, CPU quotas, disk I/O throughput) so a runaway memory leak in one container cannot starve the rest of the host machine.
- Union File System (Storage Efficiency): Combines immutable read-only image layers with a thin, ephemeral read-write container layer using copy-on-write (CoW).
Analogy: A Virtual Machine is like a standalone single-family house with its own plumbing, heating, and foundation. A Container is like a private apartment in a high-rise building: it has its own private rooms and locks, but shares the underlying infrastructure (kernel) with zero boot overhead.
2. Core Primitives: Images, Containers, Volumes & Registries#
Software developers interact with four foundational Docker concepts:
- Image (The Blueprint): An immutable, read-only snapshot containing your application binaries, runtime, system libraries, and default environment variables. (Analogy: a compiled class).
- Container (The Running Process): A live, runnable instance of an image with its own isolated file system and network stack. (Analogy: an instantiated object).
- Volume & Bind Mount (Persistent Storage): Containers are ephemeral; when deleted, data written inside them disappears. Volumes and bind mounts map host directories into the container to persist database data or enable live hot-reloading.
- Registry (The Distribution Center): A repository service (Docker Hub, GitHub Packages GHCR, AWS ECR) where images are pushed, version-tagged, and pulled across environments.
3. Anatomy of a Clean Dockerfile#
A Dockerfile contains sequential instructions to build an image. Here are the core
directives every developer needs to master:
# 1. Base image: Choose official minimal images (Debian slim, Alpine, or Distroless)
FROM node:20-slim
# 2. Working Directory: Sets working directory for all subsequent instructions
WORKDIR /app
# 3. Environment Variables: Available during build and runtime
ENV NODE_ENV=production \
PORT=3000
# 4. Copying Files: Copy dependency definitions first (for layer caching!)
COPY package*.json ./
# 5. Execute Build Commands: Run dependency installation in a single layer
RUN npm ci --only=production
# 6. Copy Application Source Code
COPY . .
# 7. Documentation: Informs human operators which port the app listens on
EXPOSE 3000
# 8. Entrypoint vs CMD:
# ENTRYPOINT defines the immutable executable
# CMD defines the default arguments (can be overridden on 'docker run')
CMD ["node", "src/server.js"]
CMD vs ENTRYPOINT: Exec Form vs Shell Form
- Always use the JSON Exec Form:
CMD ["node", "server.js"]executes the process directly as PID 1. This ensures Unix signals likeSIGTERMare delivered directly to your application for graceful shutdown! - Avoid the Shell Form:
CMD node server.jsspawns/bin/sh -cas PID 1, and your node process runs as a child. The shell ignoresSIGTERM, causing Docker to hang for 10 seconds before forcibly killing your container withSIGKILL.
4. Production Multi-Stage Builds (Shrink Image Size)#
In standard single-stage builds, your production container image carries heavy build tools (compilers, npm devDependencies, C++ header files, TypeScript compilers). Multi-Stage Builds solve this by compiling code in a heavy "builder" stage, then copying only the compiled artifacts into a lightweight runtime image.
# ==========================================
# Stage 1: Build & Compile Environment
# ==========================================
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # Compiles TypeScript into /app/dist
# ==========================================
# Stage 2: Minimal Production Runtime
# ==========================================
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Only install production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy ONLY compiled JavaScript output from builder stage
COPY --from=builder /app/dist ./dist
# Create and switch to non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Result: Image size drops from 1.4 GB down to 120 MB. A smaller image deploys 10x faster, consumes less network bandwidth, and dramatically shrinks your security attack surface.
5. Layer Caching & .dockerignore Optimization#
Docker executes instructions in order and caches each resulting layer. If a layer hasn't changed, Docker reuses the cached layer in seconds. However, as soon as one layer changes, all subsequent layers must be rebuilt from scratch.
The Golden Rule of Dockerfile Ordering
Put instructions that change least frequently at the top; put frequently changing source code at the bottom.
- Anti-Pattern:
COPY . .followed byRUN npm install. Every single code edit invalidates the cache, forcing an expensive 2-minute dependency re-install! - Best Practice:
COPY package*.json ./→RUN npm install→ THENCOPY . .. Changing your source code re-builds in under 1 second because the dependencies layer is cached!
Always create a comprehensive .dockerignore file in your project root to prevent copying
local bloated files into your build context:
# .dockerignore
node_modules
.git
.gitignore
.env
.env.*
dist
coverage
npm-debug.log
README.md
*.test.js
6. Container Security: Non-Root Users & Secrets#
By default, containers run as root (UID 0). If an attacker finds a Remote Code Execution
(RCE) vulnerability in your app or exploits a container breakout, they have root access to the
underlying host node. Production containers must always run as an unprivileged user.
# Python / Linux example of adding an unprivileged appuser
FROM python:3.12-slim
# Create dedicated non-root group and user
RUN groupadd -r appgroup && useradd -r -g appgroup -s /sbin/nologin -d /app appuser
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Change ownership to appuser
RUN chown -R appuser:appgroup /app
# Switch to the non-root user!
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Warning Regarding Secrets: Never write
ENV DATABASE_PASSWORD="secret"in a Dockerfile! Environment variables declared viaENVare permanently baked into image metadata and readable by anyone who runsdocker inspectordocker history. Inject secrets at runtime via Docker Compose or Kubernetes Secrets.
7. Docker Compose for Local Multi-Service Development#
Modern applications depend on databases, Redis caches, and message queues. Installing these directly on your host machine leads to version conflicts. Docker Compose orchestrates multi-container applications locally with full reproducibility:
version: '3.8'
services:
web:
build:
context: .
target: runner
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://app_user:app_password@db:5432/app_db
- REDIS_URL=redis://cache:6379
volumes:
# Live hot-reloading: bind-mount local directory into container
- .:/app
- /app/node_modules # Anonymous volume preserves container-built dependencies
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_password
POSTGRES_DB: app_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
interval: 3s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata: # Named volume persists database records across container restarts
8. Container Networking & Embedded DNS#
How do containers communicate with each other? Docker Compose creates a private user-defined bridge
network automatically. Docker runs an internal Embedded DNS Server at
127.0.0.11:
- Automatic Service Name Resolution: Inside the
webcontainer, you do not connect tolocalhost:5432(which resolves to the web container itself). You simply connect tohttp://db:5432orhttp://cache:6379. Docker DNS automatically resolves the service name to the container's private IP. - Port Mapping vs Expose:
ports: ["3000:3000"]maps the port to your host machine so you can visithttp://localhost:3000in your laptop's browser. Other containers on the same network can reach it on port 3000 even without host port mapping!
9. PID 1, Signals & Graceful Container Shutdown#
When you run docker stop <id>, Docker sends SIGTERM to PID 1 inside the
container, waits 10 seconds (default), and sends SIGKILL if the process hasn't exited.
// Node.js Express / Fastify Graceful Shutdown Handler
const server = app.listen(process.env.PORT, () => console.log('Server running'));
function gracefulShutdown(signal) {
console.log(`Received ${signal}. Draining active HTTP connections...`);
server.close(() => {
console.log('HTTP server closed. Disconnecting from database...');
db.pool.end(() => {
console.log('Database pool closed. Exiting process.');
process.exit(0);
});
});
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
10. Developer Debugging & CLI Toolkit#
The daily diagnostic commands every software developer uses:
# 1. Build and Run
docker build -t my-app:v1.0 .
docker run -d -p 8080:8080 --name my-app --env-file .env my-app:v1.0
# 2. View Realtime Logs
docker logs -f my-app
docker logs --tail 50 my-app
# 3. Interactive Shell inside Running Container
docker exec -it my-app /bin/sh
# (or /bin/bash if installed)
# 4. Inspect Container Resource Utilization (CPU, Memory, Network I/O)
docker stats
# 5. Inspect Layer History and Sizes
docker history my-app:v1.0
# 6. Docker Compose Commands
docker compose up -d # Start all services in background
docker compose ps # List running compose services & health status
docker compose logs -f web # Stream logs for specific service
docker compose down -v # Stop all services and wipe volumes (fresh state)
# 7. Reclaim Disk Space (Remove unused containers, dangling images, build cache)
docker system prune -af --volumes