Docker Fundamentals: From Images to Production Containers
Master Docker containers, images, Dockerfiles, docker-compose, volumes, and networking. A comprehensive guide for developers getting started with containerization.
Docker Fundamentals: From Images to Production Containers
Docker has changed how we build, ship, and run applications. If you are still manually installing dependencies and wrestling with “works on my machine” problems, you are missing out. Containerization is not just a trend - it has become the standard deployment model for modern software.
This guide walks through everything you need to go from Docker beginner to someone who can containerize a real application and run it reliably.
What Docker Actually Is
Docker is a platform for packaging applications into self-contained units called containers. A container bundles your code, runtime, system tools, libraries, and settings everything the application needs to run, independent of the host system.
The key difference from virtual machines is that containers share the host kernel and do not emulate hardware. This makes them lightweight and fast to start. A VM needs to boot an entire operating system; a container starts in seconds.
Docker uses client-server architecture. The Docker client talks to the Docker daemon, which handles building, running, and distributing containers. You interact primarily with the CLI, but under the hood there is a restful API doing the work.
Understanding Docker Images
An image is a read-only template with instructions for creating a container. Think of it like a snapshot of a filesystem with some metadata about how to run the process.
Images are built in layers. Each instruction in a Dockerfile creates a new layer. When you change something, only that layer and its dependents rebuild. This caching mechanism is what makes Docker builds so fast after the first run.
Here is a simple Dockerfile for a Node.js application:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
The FROM instruction sets the base image. Using Alpine variants keeps your images small, around 5MB for the base OS layer versus 700MB+ for a full Ubuntu image.
Image Naming and Tags
Docker images use a naming convention: registry/repository:tag. If you do not specify a tag, Docker defaults to latest.
docker pull nginx:1.25-alpine
docker pull nginx:1.25
docker pull nginx
The three commands above pull different images. The first explicitly specifies version 1.25 of the Alpine variant. The second pulls the same version without the Alpine suffix. The third gets the latest tag.
For production, always pin exact versions. latest is a moving target that will bite you when it changes unexpectedly.
Working with Containers
A container is a runnable instance of an image. You can create, start, stop, and delete containers. Each container is isolated from other containers and the host system, but they can communicate through defined networking channels.
Running Your First Container
docker run nginx:latest
This pulls the nginx image if not present locally, creates a container from it, and starts it. By default, nginx runs in the foreground and binds to port 80 inside the container.
To run it in detached mode with port mapping:
docker run -d -p 8080:80 --name my-nginx nginx:latest
The -d flag runs the container detached (in the background). -p 8080:80 maps host port 8080 to container port 80. The --name flag gives your container a memorable name instead of a random one.
Managing Containers
docker ps # List running containers
docker ps -a # List all containers (including stopped)
docker stop my-nginx # Stop a running container
docker start my-nginx # Start a stopped container
docker restart my-nginx # Stop then start
docker rm my-nginx # Remove a container (must be stopped)
docker logs -f my-nginx # Follow logs in real-time
docker exec -it my-nginx sh # Get shell inside running container
The docker exec command is indispensable for debugging. You can jump into a running container and inspect its filesystem, check environment variables, or troubleshoot why something is not working.
Building Images with Dockerfiles
A Dockerfile is a script with instructions for building your custom image. Each instruction creates a new layer, and Docker caches layers when possible to speed up rebuilds.
Dockerfile Instructions Reference
| Instruction | Purpose |
|---|---|
FROM | Set base image |
RUN | Execute commands during build |
COPY | Copy files into image |
ADD | Like COPY but can extract archives |
WORKDIR | Set working directory |
ENV | Set environment variables |
EXPOSE | Document port number |
USER | Set user for subsequent commands |
CMD | Default command when container starts |
ENTRYPOINT | Configure container as executable |
Multi-Stage Builds
Multi-stage builds let you use multiple FROM statements to separate build-time and runtime environments. This keeps production images lean by excluding build tools.
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The final image only contains the production-ready artifact. The build dependencies never make it into the runtime image.
Docker Compose for Multi-Container Applications
Most real applications need multiple services: a web server, a database, a cache layer. Docker Compose manages these multi-container setups through a YAML configuration file.
docker-compose.yml Structure
version: "3.8"
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://db:5432/app
depends_on:
- db
- redis
restart: unless-stopped
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: app
POSTGRES_USER: user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
secrets:
db_password:
file: ./secrets/db_password.txt
Compose Commands
docker-compose up -d # Start all services
docker-compose down # Stop and remove containers
docker-compose down -v # Also remove volumes
docker-compose logs -f web # Follow logs for web service
docker-compose ps # List running services
docker-compose exec db psql # Run psql in db container
docker-compose restart web # Restart web service
The depends_on directive ensures services start in the right order. Note that it only waits for the container to start, not for the application inside to be ready. For databases and similar services, you often need a healthcheck or a startup script that waits for dependencies.
Data Persistence with Volumes
Containers are ephemeral by default. Any data written inside a container disappears when the container is removed. Volumes solve this by providing persistent storage that exists independent of containers.
Volume Types
Named volumes are the simplest approach:
services:
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Docker creates the volume if it does not exist. The data persists across container restarts and removals.
Bind mounts map a host directory into the container:
services:
app:
image: node:20-alpine
volumes:
- ./src:/app/src:ro
The :ro suffix makes the mount read-only. Bind mounts are useful for development, letting you edit code on your host and see changes immediately inside the container.
tmpfs mounts store data in memory only, useful for sensitive data you do not want persisted:
services:
cache:
image: redis:7-alpine
tmpfs:
- /data
Container Networking
Docker provides several networking modes. Understanding them helps you design proper communication between services.
Network Drivers
| Driver | Use Case |
|---|---|
| bridge | Default for standalone containers |
| host | Remove network isolation, use host network directly |
| overlay | Connect containers across multiple Docker hosts |
| macvlan | Assign MAC address to containers for legacy applications |
| none | Disable all networking |
Custom Bridge Networks
Creating a custom bridge network enables automatic DNS resolution between containers by name:
version: "3.8"
services:
web:
build: .
networks:
- frontend
api:
build: ./api
networks:
- frontend
- backend
db:
image: postgres:15-alpine
networks:
- backend
volumes:
- db_data:/var/lib/postgresql/data
networks:
frontend:
backend:
The web service can reach api by its service name, but cannot reach db directly because they are on separate networks. This network segmentation adds security by limiting what services can communicate.
Service Discovery
Within a custom bridge network, containers discover each other by the service name defined in compose. If you have a service named postgres, other containers can reach it at postgres:5432.
Docker embeds a DNS resolver that handles this resolution automatically. You do not need to hardcode IP addresses; they can change as containers restart.
Environment Variables and Configuration
Environment variables are the primary way to configure containerized applications at runtime. Docker provides several mechanisms for setting them.
Setting Environment Variables
services:
web:
environment:
- NODE_ENV=production
- API_KEY=${API_KEY}
- DEBUG=false
You can also use an .env file with Docker Compose:
# .env file
NODE_ENV=production
API_KEY=your-secret-key
environment:
- NODE_ENV=${NODE_ENV}
- API_KEY=${API_KEY}
For secrets in production, use Docker secrets or an external secrets manager. Never commit secrets to version control, even in private repositories.
Building for Production
A production Docker workflow differs from development in several ways.
Image Optimization Checklist
Use Alpine-based images to reduce attack surface and pull times. Pin exact versions for all images. Use multi-stage builds to exclude build artifacts from production. Run containers as non-root users. Remove unnecessary tools and shells from production images.
A hardened production Dockerfile might look like:
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
# Add labels for metadata
LABEL maintainer="dev@example.com"
LABEL version="1.0.0"
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY package*.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
The HEALTHCHECK instruction tells Docker how to verify the container is healthy. This enables proper health monitoring and ensures load balancers only send traffic to healthy instances.
Container Health and Monitoring
Containers can fail in several ways: the application can crash, hang, or run out of memory. Docker provides restart policies to handle these scenarios automatically.
Restart Policies
services:
web:
image: nginx:latest
restart: unless-stopped
worker:
image: my-worker:latest
restart: on-failure
| Policy | Behavior |
|---|---|
| no | Do not restart (default) |
| on-failure | Restart only if container exits with non-zero code |
| unless-stopped | Restart unless explicitly stopped |
| always | Always restart, including after Docker daemon restart |
For production services, unless-stopped or always are usually appropriate. Consider whether you want the service to restart after a code bug that causes repeated crashes, which could mask an underlying issue.
When to Use / When Not to Use
Docker containers work well in many scenarios but are not always the right tool.
When to Use Docker
Use Docker when:
- Packaging applications for consistent deployment across environments
- Microservices architectures where services need isolation
- CI/CD pipelines requiring reproducible build environments
- Scaling applications horizontally with container orchestration
- Running multiple versions of dependencies side by side
- Development environments needing parity with production
Use Docker Compose when:
- Local development with multiple coordinated services
- Running integration tests in isolated containers
- Small-scale deployments without Kubernetes
- Demonstrating application stacks to stakeholders
When Not to Use Docker
Consider alternatives when:
- Applications requiring real-time kernel access or hardware passthrough
- Desktop applications with complex GUI requirements (native packaging may be better)
- Very small scripts that have minimal dependencies
- Applications with extreme performance requirements where container overhead matters
- Windows-specific workloads (Docker on Windows has more limitations)
Containerization Decision Tree
graph TD
A[Need to deploy application?] --> B{Multiple environments?}
B -->|Yes| C[Use containers]
B -->|No| D{Scalability needed?}
D -->|Yes| C
D -->|No| E{Single host only?}
E -->|Yes| F[Consider Docker Compose]
E -->|No| C
C --> G[Use multi-stage builds]
C --> H[Configure health checks]
C --> I[Set resource limits]
Production Failure Scenarios
Containers fail in predictable ways. Understanding these helps you design resilient systems.
| Failure | Impact | Mitigation |
|---|---|---|
| Application crash | Container exits with non-zero code | Implement restart policies, health checks, and logging |
| OOM kill | Container terminated, potential data loss | Set memory limits, monitor memory usage |
| Disk full | Container cannot write logs or data | Use log rotation, monitor disk usage, mount tmpfs for temp data |
| Network partition | Container cannot reach dependencies | Implement retry logic, circuit breakers, health checks |
| Image pull failure | Pod cannot start, app unavailable | Use private registry, pre-pull images, pin exact versions |
| Port conflicts | Container fails to start | Configure port mapping carefully, use Docker Compose |
| Volume mount failure | Data inaccessible, potential crash | Verify volume paths exist, use named volumes |
| Dependency outage | Application cannot serve traffic | Implement graceful degradation, health checks |
Common Container Exit Codes
| Exit Code | Meaning | Resolution |
|---|---|---|
| 0 | Application exited successfully | Normal termination |
| 1 | Application exited with general error | Check application logs |
| 137 | SIGKILL (OOM or manual kill) | Increase memory limit, check for memory leaks |
| 139 | Segfault or SIGSEGV | Application bug, check core dump |
| 143 | SIGTERM (graceful shutdown) | Normal during restart or stop |
| 255 | Exit status out of range | Application error, check entrypoint |
Observability Checklist
Containerized applications need comprehensive monitoring to catch issues early.
Metrics to Collect
graph LR
A[Container Metrics] --> B[CPU Usage]
A --> C[Memory Usage]
A --> D[Network I/O]
A --> E[Block I/O]
F[Application Metrics] --> G[Request Rate]
F --> H[Error Rate]
F --> I[Latency]
F --> J[Active Connections]
Container-level metrics:
- CPU usage percentage vs limit
- Memory usage percentage vs limit
- Network bytes sent and received
- Block I/O read and write bytes
- Container restart count
Application-level metrics:
- Request throughput (requests per second)
- Error rate (4xx, 5xx responses)
- Response latency (p50, p95, p99)
- Active connections (database, Redis, HTTP)
- Queue depth for async processing
Logging Best Practices
graph TD
A[Container STDOUT STDERR] --> B[Log Driver]
B --> C[Centralized Logging]
C --> D[ELK Stack]
C --> E[Loki]
C --> F[CloudWatch]
G[Structured Logs] --> C
G --> H[JSON Format]
G --> I[Correlation ID]
- Use structured logging: JSON format enables easier parsing and querying
- Include correlation IDs: Trace requests across services
- Log to STDOUT/STDERR: Let Docker handle log routing, not files
- Implement log rotation: Prevent disk exhaustion
- Ship logs centrally: Aggregate logs from all containers
# Configure log rotation in daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Alerts to Configure
Critical (immediate action):
- Container restart count > 5 in 10 minutes
- Memory usage > 90% of limit for > 5 minutes
- Container exit (unexpected termination)
- Health check failure for > 2 minutes
Warning (investigate soon):
- CPU usage > 80% of limit for > 10 minutes
- Disk usage > 80% on volume
- Restart count > 2 in 30 minutes
- Health check degradation
Security Checklist
Container security requires defense in depth across multiple layers.
Image Security
Image selection:
- Use official images from trusted registries when possible
- Prefer Alpine or distroless images for smaller attack surface
- Never use
latesttag in production (pin exact versions) - Scan images for vulnerabilities before deployment
# Scan image locally
trivy image myapp:1.0.0
# In CI/CD pipeline
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:1.0.0
Dockerfile hardening:
# Use specific version, not latest
FROM node:20-alpine3.18
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy files with correct ownership
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
# Set explicit exposure
EXPOSE 3000
# Use exec form for CMD (proper signal handling)
CMD ["node", "server.js"]
Runtime Security
graph LR
A[Runtime Security] --> B[Resource Limits]
A --> C[Capability Drop]
A --> D[No Privileged]
A --> E[Read-only FS]
B --> F[Memory Limit]
B --> G[CPU Limit]
C --> H[DROP ALL]
C --> I[Add specific]
Security options for docker run:
# Run with security hardening
docker run \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--memory=512m \
--memory-swap=512m \
--cpus=1.0 \
--user=10001 \
--cap-drop=ALL \
--security-opt=no-new-privileges \
myapp:1.0.0
Security options for docker-compose.yml:
services:
web:
image: myapp:1.0.0
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
mem_limit: 512m
memswap_limit: 512m
cpus: 1.0
user: "10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
Secret Management
Never:
- Store secrets in environment variables
- Commit secrets to Dockerfiles or docker-compose files
- Use secrets in build arguments (they get baked into image layers)
- Use ConfigMaps for sensitive data
Always:
- Use Docker secrets for sensitive data in Compose
- Use external secrets managers (Vault, AWS Secrets Manager)
- Mount secrets as files or environment variables at runtime
- Rotate secrets regularly
# docker-compose.yml with secrets
services:
web:
image: myapp:1.0.0
secrets:
- db_password
environment:
- DATABASE_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Common Pitfalls / Anti-Patterns
Image Building Pitfalls
Not using multi-stage builds
# Anti-pattern: Build artifacts in production image
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# Better: Multi-stage build
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Not cleaning up in the same layer
# Anti-pattern: Build artifacts persist
RUN apt-get update && apt-get install build-essential
# Better: Clean in same layer
RUN apt-get update && apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
Copying too much
# Anti-pattern: Copies everything including .git, node_modules
COPY . .
# Better: Only copy necessary files
COPY package*.json ./
COPY src ./src
Container Execution Pitfalls
Running as root
# Anti-pattern: Running as root user
services:
web:
image: myapp:1.0.0
user: root
# Better: Run as non-root
services:
web:
image: myapp:1.0.0
user: "10001"
Not setting resource limits
# Anti-pattern: No limits means unbounded resource usage
services:
web:
image: myapp:1.0.0
# Better: Set appropriate limits
services:
web:
image: myapp:1.0.0
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
Missing health checks
# Anti-pattern: No health check, Docker does not know if app is healthy
services:
web:
image: myapp:1.0.0
# Better: Define health check
services:
web:
image: myapp:1.0.0
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
Networking Pitfalls
Exposing unnecessary ports
# Anti-pattern: Exposing debug ports
services:
web:
image: myapp:1.0.0
ports:
- "3000:3000"
- "9229:9229" # Debug port exposed
# Better: Only expose needed ports
services:
web:
image: myapp:1.0.0
ports:
- "3000:3000"
Not using custom networks
# Anti-pattern: Default bridge, no automatic DNS
services:
web:
image: myapp:1.0.0
db:
image: postgres:15
ports:
- "5432:5432" # Unnecessary exposure
# Better: Custom network with proper isolation
services:
web:
image: myapp:1.0.0
networks:
- backend
db:
image: postgres:15
networks:
- backend
networks:
backend:
Quick Recap
Key Takeaways
- Docker containers package applications with their dependencies for consistent deployment across environments
- Images are built in layers; multi-stage builds keep production images lean
- Named volumes persist data independent of container lifecycle
- Docker Compose manages multi-container applications with automatic service discovery
- Health checks enable proper monitoring and orchestrator integration
- Restart policies handle common failure scenarios automatically
- Security requires defense in depth: image scanning, non-root users, resource limits, and secret management
Production Readiness Checklist
# Image building
docker build -t myapp:1.0.0 --platform linux/amd64 .
docker scan myapp:1.0.0
docker run --rm -it myapp:1.0.0 --healthcheck
# Security hardening
docker run \
--read-only \
--user=10001 \
--cap-drop=ALL \
--memory=512m \
--security-opt=no-new-privileges \
myapp:1.0.0
# Compose validation
docker-compose config --quiet
docker-compose up -d
docker-compose ps
docker-compose logs -f
# Volume management
docker volume create myapp_data
docker inspect myapp_data
docker volume ls
Pre-Deployment Verification
# Check for vulnerabilities
trivy image myapp:1.0.0
# Verify resource limits
docker inspect myapp | grep -A 10 Memory
# Test health check
docker exec myapp wget -qO- http://localhost:3000/health
# Check logs
docker logs myapp --tail 100 --timestamps
# Monitor in real-time
docker stats myapp --no-stream
Conclusion
Docker simplifies application deployment by providing consistent packaging across environments. The core concepts images, containers, volumes, and networking form the foundation for any containerized architecture.
Start simple: containerize a single application, run it locally with Docker Compose, and gradually add complexity as you need it. Most teams find they outgrow manual Docker commands quickly and move to orchestration tools like Kubernetes, but the fundamentals covered here apply throughout.
If you want to go deeper into container orchestration, our Advanced Kubernetes guide covers custom controllers, operators, and production-grade cluster management. For packaging Kubernetes applications, Helm Charts provides a templating system that makes deploying complex applications manageable.
Category
Related Posts
Docker Fundamentals
Learn Docker containerization fundamentals: images, containers, volumes, networking, and best practices for building and deploying applications.
Container Images: Building, Optimizing, and Distributing
Learn how Docker container images work, layer caching strategies, image optimization techniques, and how to publish your own images to container registries.
Container Registry: Image Storage, Scanning, and Distribution
Set up and secure container registries for storing, scanning, and distributing container images across your CI/CD pipeline and clusters.