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.

published: reading time: 18 min read

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

InstructionPurpose
FROMSet base image
RUNExecute commands during build
COPYCopy files into image
ADDLike COPY but can extract archives
WORKDIRSet working directory
ENVSet environment variables
EXPOSEDocument port number
USERSet user for subsequent commands
CMDDefault command when container starts
ENTRYPOINTConfigure 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

DriverUse Case
bridgeDefault for standalone containers
hostRemove network isolation, use host network directly
overlayConnect containers across multiple Docker hosts
macvlanAssign MAC address to containers for legacy applications
noneDisable 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
PolicyBehavior
noDo not restart (default)
on-failureRestart only if container exits with non-zero code
unless-stoppedRestart unless explicitly stopped
alwaysAlways 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.

FailureImpactMitigation
Application crashContainer exits with non-zero codeImplement restart policies, health checks, and logging
OOM killContainer terminated, potential data lossSet memory limits, monitor memory usage
Disk fullContainer cannot write logs or dataUse log rotation, monitor disk usage, mount tmpfs for temp data
Network partitionContainer cannot reach dependenciesImplement retry logic, circuit breakers, health checks
Image pull failurePod cannot start, app unavailableUse private registry, pre-pull images, pin exact versions
Port conflictsContainer fails to startConfigure port mapping carefully, use Docker Compose
Volume mount failureData inaccessible, potential crashVerify volume paths exist, use named volumes
Dependency outageApplication cannot serve trafficImplement graceful degradation, health checks

Common Container Exit Codes

Exit CodeMeaningResolution
0Application exited successfullyNormal termination
1Application exited with general errorCheck application logs
137SIGKILL (OOM or manual kill)Increase memory limit, check for memory leaks
139Segfault or SIGSEGVApplication bug, check core dump
143SIGTERM (graceful shutdown)Normal during restart or stop
255Exit status out of rangeApplication 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 latest tag 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.

#docker #containers #devops

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.

#docker #containers #devops

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.

#containers #docker #registry