What is Docker? Containers, Images, and Why Every Developer Needs to Know This

Docker packages your application and everything it needs to run into a container that behaves identically on your laptop, your teammate's machine, and the production server. "It works on my machine" stops being a problem. Here is how it actually works and what production usage looks like.

"It works on my machine" is one of the oldest and most persistent problems in software development. You write code on macOS. Your teammate runs Linux. Your CI server runs Ubuntu 22.04. Production runs Amazon Linux. The Node.js version is 18.12 on your machine, 20.3 on your teammate's, and nobody wrote that down anywhere. A library that works fine on macOS fails on Linux because of a native dependency compiled against the wrong libc version. The timezone settings differ. The directory structure differs. The available memory differs.

Docker solves this by packaging your application and its entire environment into a container. The container includes the operating system base, runtime, libraries, dependencies, configuration, and your application code. You build the container once. It runs identically everywhere that Docker is installed. The same container your CI pipeline tests is the same container that runs in production. There is no "it works on my machine" because everyone's machine is running the same container.

This is not a small convenience. It is the foundation of modern deployment infrastructure. Kubernetes orchestrates Docker containers. Every major cloud provider's container service runs Docker images. CI/CD pipelines build Docker images and push them to registries. The ability to containerize an application is table stakes for backend development in 2025.

What Containers Actually Are

Before Docker, the way to isolate applications on a server was virtual machines. A VM runs a complete operating system, including its own kernel, on top of a hypervisor that emulates hardware. Each VM carries gigabytes of OS overhead. VMs take minutes to start. Running twenty VMs on a single server requires substantial RAM just for the OS layers.

Containers are different. A container shares the host operating system's kernel. It does not run its own kernel. What it does have is an isolated process namespace, an isolated filesystem namespace, isolated network interfaces, and resource limits (CPU, memory, disk I/O). From inside the container, it looks and feels like a complete Linux environment. From the host's perspective, it is a process with restricted visibility and resource access.

Linux kernels have had the underlying primitives for containers — namespaces and cgroups — since around 2006 and 2008 respectively. Docker, first released in 2013, made those primitives usable. It packaged them with a layered image format, a registry system, and a developer-friendly command-line interface. That combination is what drove adoption.

The practical difference from VMs: containers start in seconds (often under a second). They use megabytes of overhead, not gigabytes. A server running 200 containers is not unusual. A server running 200 VMs would require thousands of gigabytes of RAM just for the OS layers.

Docker Images and Layers

An image is a read-only template that contains everything needed to run a container. The container is a running instance of an image — the image plus a writable layer on top.

Images are built in layers. Each instruction in a Dockerfile creates a new layer. Layers are cached: if you rebuild an image and only the last two layers changed, Docker reuses every layer before the change from cache. This makes rebuilds fast.

FROM node:22-alpine
 
WORKDIR /app
 
COPY package*.json ./
 
RUN npm ci --only=production
 
COPY . .
 
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001 -G nodejs
 
USER nextjs
 
EXPOSE 3000
 
ENV NODE_ENV=production
 
CMD ["node", "dist/index.js"]

FROM node:22-alpine starts from the official Node.js 22 image built on Alpine Linux. Alpine is a minimal Linux distribution (under 10MB base) used for Docker images because it produces smaller final images than Debian or Ubuntu bases.

COPY package*.json ./ then RUN npm ci --only=production is an important ordering decision. Docker caches each layer. If you copy your source code and then install dependencies, any change to any source file invalidates the dependency cache and reinstalls all packages. By copying only package.json first, installing dependencies, and then copying source code, changes to source files only invalidate the layers after the copy. Dependency installation stays cached.

addgroup and adduser create a non-root user. Running processes inside containers as root is a security risk: if the container process is compromised, the attacker has root inside the container, making privilege escalation easier. Always run application processes as non-root.

USER nextjs switches all subsequent commands to the non-root user. EXPOSE 3000 documents which port the application listens on (it does not actually open the port; docker run -p does that). CMD is the default command to run when the container starts.

Build it:

docker build -t my-api:latest .
docker build -t my-api:1.2.0 .
docker build --platform linux/amd64 -t my-api:latest .

--platform linux/amd64 matters if you are building on Apple Silicon (ARM) for deployment on x86 servers. Without it, you build an ARM image that will not run on an x86 production server.

Multi-Stage Builds

A multi-stage build compiles or builds your application in one stage and copies only the final artifacts into a minimal production image. The build tools (TypeScript compiler, Go compiler, build dependencies) stay in the build stage and never appear in the production image.

FROM node:22-alpine AS builder
 
WORKDIR /app
 
COPY package*.json tsconfig*.json ./
RUN npm ci
 
COPY src/ src/
 
RUN npm run build
RUN npm prune --production
 
FROM node:22-alpine AS production
 
RUN apk add --no-cache tini
 
WORKDIR /app
 
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 -G nodejs
 
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
 
USER nodejs
 
EXPOSE 3000
 
ENV NODE_ENV=production \
    PORT=3000
 
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD wget -qO- http://localhost:3000/health/live || exit 1
 
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

The builder stage has Node.js, TypeScript, all dev dependencies, and the compiled output. The production stage starts fresh from node:22-alpine and copies only dist/, node_modules/ (production only after npm prune), and package.json. The TypeScript compiler, type definitions, test files, and dev dependencies are not in the production image.

tini is a minimal init system for containers. Docker containers running without an init system have PID 1 be your application process. PID 1 is expected by Linux to handle orphaned child processes and forward signals. Node.js does not do this correctly. tini acts as a proper PID 1, reaps zombies, and forwards signals to your application. This prevents the "docker stop takes 10 seconds" problem where the container does not stop gracefully because signals were not forwarded.

HEALTHCHECK lets Docker know when the container is ready to receive traffic and whether it is still healthy. A container that fails health checks repeatedly gets marked unhealthy. Orchestration systems like Docker Swarm and Kubernetes use health check status to decide whether to route traffic to a container.

For a Go application, multi-stage builds are even more dramatic:

FROM golang:1.23-alpine AS builder
 
WORKDIR /app
 
COPY go.mod go.sum ./
RUN go mod download
 
COPY . .
 
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s -X main.version=$(git describe --tags --always)" \
    -trimpath \
    -o /bin/server \
    ./cmd/server
 
FROM scratch
 
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/server /bin/server
 
EXPOSE 8080
 
ENTRYPOINT ["/bin/server"]

FROM scratch starts from an empty image. Nothing. No OS, no shell, no utilities. The only thing in the final image is the SSL certificates (needed for HTTPS connections) and the Go binary. CGO_ENABLED=0 produces a statically linked binary with no libc dependencies. A Go API server in a 10MB image.

Running Containers

docker run --name my-api \
    -p 3000:3000 \
    -e DATABASE_URL="postgresql://user:pass@db:5432/myapp" \
    -e JWT_ACCESS_SECRET="your-secret-here" \
    --restart unless-stopped \
    my-api:latest
 
docker run -d \
    --name postgres \
    -e POSTGRES_PASSWORD=mysecretpassword \
    -e POSTGRES_DB=myapp \
    -v postgres_data:/var/lib/postgresql/data \
    -p 5432:5432 \
    postgres:16-alpine
 
docker logs -f my-api
docker exec -it my-api sh
docker stats my-api
docker stop my-api
docker rm my-api

-p 3000:3000 maps port 3000 on the host to port 3000 in the container. -e passes environment variables. -v postgres_data:/var/lib/postgresql/data mounts a named volume. Without a volume, all data inside the PostgreSQL container is lost when the container stops. Volumes persist data on the host filesystem outside the container's lifecycle.

--restart unless-stopped restarts the container automatically if it crashes or if Docker restarts, but not if you explicitly stopped it. unless-stopped is appropriate for production services. always restarts even if you manually stop the container.

Docker Compose

Running multiple containers with individual docker run commands is manageable for one or two containers and painful for five. Docker Compose defines your entire application stack in a YAML file and brings it up with one command.

version: "3.9"
 
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    container_name: my_api
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      PORT: 3000
      DATABASE_URL: postgresql://myapp:${DB_PASSWORD}@postgres:5432/myapp
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
      JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET}
      JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
  postgres:
    image: postgres:16-alpine
    container_name: my_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
  redis:
    image: redis:7-alpine
    container_name: my_redis
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
  nginx:
    image: nginx:alpine
    container_name: my_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      - api
    networks:
      - backend
      - frontend
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
 
networks:
  backend:
    driver: bridge
  frontend:
    driver: bridge

depends_on with condition: service_healthy means the API container waits for PostgreSQL and Redis to pass their health checks before starting. Without this, the API starts immediately, tries to connect to the database before it is ready, fails, and crashes. Kubernetes has the same problem solved differently (init containers or readiness probes).

The .env file holds secrets:

DB_PASSWORD=your_secure_database_password
REDIS_PASSWORD=your_secure_redis_password
JWT_ACCESS_SECRET=at_least_32_characters_of_random_secret
JWT_REFRESH_SECRET=different_32_character_refresh_secret

${DB_PASSWORD} in the Compose file interpolates from .env or the shell environment. Never hardcode secrets in docker-compose.yml. Commit .env.example with placeholder values to version control. Keep .env in .gitignore.

Start the whole stack:

docker compose up -d
docker compose logs -f api
docker compose ps
docker compose exec api sh
docker compose down
docker compose down -v

docker compose up -d starts everything in the background. -v in docker compose down also removes volumes. Do not use -v in production unless you intend to destroy the database.

Container Registries and Image Management

Images need to live somewhere that your production servers can pull from. Docker Hub is the default public registry. For private images, options include AWS ECR, Google Artifact Registry, GitHub Container Registry, and self-hosted registries.

docker tag my-api:latest ghcr.io/yourusername/my-api:latest
docker tag my-api:latest ghcr.io/yourusername/my-api:1.2.0
 
echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin
 
docker push ghcr.io/yourusername/my-api:latest
docker push ghcr.io/yourusername/my-api:1.2.0
 
docker pull ghcr.io/yourusername/my-api:1.2.0

Tag images with both latest and a version tag. latest is what you deploy to production right now. The version tag is what you roll back to. On production servers, pull the specific version tag, not latest, so deployments are repeatable and rollbacks are straightforward.

CI/CD with Docker

The canonical CI/CD Docker workflow: build the image in CI, push to a registry, pull and run on production.

name: Build and Deploy
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern={{version}}
 
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64
 
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
 
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /app
            export IMAGE_TAG=sha-${{ github.sha }}
            docker compose pull api
            docker compose up -d --no-deps api
            docker compose exec -T api wget -qO- http://localhost:3000/health/ready || exit 1
            docker image prune -f

cache-from: type=gha and cache-to: type=gha,mode=max use GitHub Actions cache for Docker layer caching. Builds that only changed a few source files complete in under a minute instead of several minutes because unchanged layers are pulled from cache.

--no-deps api in docker compose up only recreates the API service without touching PostgreSQL or Redis. docker image prune -f removes old, untagged images to prevent disk space accumulation.

Volumes and Data Persistence

Containers are ephemeral. When a container stops, its writable layer is discarded. Anything written inside the container disappears. Volumes solve this by mounting storage from outside the container lifecycle.

docker volume create postgres_data
docker volume ls
docker volume inspect postgres_data
docker volume rm postgres_data
 
docker run -v postgres_data:/var/lib/postgresql/data postgres:16-alpine
docker run -v /host/path:/container/path:ro nginx:alpine
docker run --mount type=bind,source=$(pwd)/nginx.conf,target=/etc/nginx/nginx.conf,readonly nginx:alpine

Named volumes (-v postgres_data:/var/lib/postgresql/data) are managed by Docker. Docker stores the data on the host in /var/lib/docker/volumes/. Named volumes persist across container restarts and removals. They survive docker rm my-container — only docker volume rm removes them.

Bind mounts (-v /host/path:/container/path) mount a specific path from the host into the container. Changes in the container are reflected on the host and vice versa. Bind mounts are useful for development (mount source code so changes appear inside the container without rebuilding) and for config files you manage outside of Docker.

:ro makes the mount read-only inside the container. Nginx serving static files, configuration files that the container should read but not modify: read-only mounts.

For development, bind-mount your source code directory so the container sees live changes:

services:
  api:
    build:
      context: .
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    command: npm run dev

The second volume /app/node_modules is a trick: it mounts an anonymous volume at /app/node_modules, which takes precedence over the bind-mounted node_modules from your host. This prevents your host's node_modules (possibly built for macOS) from being used inside the Linux container.

Networking

By default, containers on the same Docker network can communicate by service name. The backend network in your Compose file means api can reach postgres at the hostname postgres and redis at the hostname redis. No IP addresses needed. Docker's internal DNS resolves service names.

docker network create myapp-net
docker network ls
docker network inspect myapp-net
 
docker run --network myapp-net --name postgres postgres:16-alpine
docker run --network myapp-net --name api my-api:latest

Containers on different networks cannot communicate. This isolation is a security feature. Your API service does not need to communicate with your monitoring service directly. Put them on separate networks.

Three network modes matter in practice:

bridge (default): creates a virtual network. Containers on the same bridge network communicate. The host can reach containers through published ports (-p 3000:3000). Containers cannot directly reach the host's localhost without host.docker.internal.

host: removes network isolation. The container uses the host's network stack directly. No port publishing needed. Trades isolation for performance. Used for applications with tight networking requirements.

none: no network. Complete isolation. For batch processing jobs that need filesystem access but no network.

Environment Variables and Secrets Management

Environment variables are the standard mechanism for configuring containers. The twelve-factor app methodology specifies config in the environment as one of its core principles.

docker run -e NODE_ENV=production -e PORT=3000 my-api:latest
 
docker run --env-file .env.production my-api:latest

Secrets are a different concern. An .env file on the host is fine for development. For production, you need secrets that do not live in plain text on disk.

Docker Secrets (Swarm mode):

echo "supersecretpassword" | docker secret create db_password -
docker service create \
    --name api \
    --secret db_password \
    my-api:latest

Inside the container, the secret is available at /run/secrets/db_password. The application reads the file instead of an environment variable. The secret is never in the container's environment, never in docker inspect output, and never in logs.

External secret management: AWS Secrets Manager, HashiCorp Vault, and Azure Key Vault are production-grade solutions. Your container startup script fetches the secret from the vault at runtime and injects it into the application. The vault handles rotation, auditing, and access control.

Never bake secrets into images. ARG MY_SECRET in a Dockerfile stores the value in the image layer history, visible to anyone who pulls the image. Pass secrets at runtime, not at build time.

Dockerfile Optimization

Layer caching is the biggest lever for build speed. Dockerfile instructions that change frequently must come after instructions that change rarely.

FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache curl
 
FROM base AS deps
COPY package*.json ./
RUN npm ci
 
FROM base AS dev-deps
COPY package*.json ./
RUN npm ci
 
FROM dev-deps AS builder
COPY . .
RUN npm run build && npm prune --production
 
FROM base AS production
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 -G nodejs
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json .
USER nodejs
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD curl -f http://localhost:3000/health/live || exit 1
CMD ["node", "dist/index.js"]

The deps stage installs dependencies in isolation. When only source files change (not package.json), Docker reuses the cached deps layer. The full npm ci only reruns when package.json or package-lock.json changes. On a team where source changes are frequent but dependency changes are rare, this saves minutes per CI build.

.dockerignore prevents the build context from ballooning. Without it, docker build copies everything in the directory to the Docker daemon before building:

node_modules
dist
.git
.gitignore
*.md
.env
.env.*
coverage
.nyc_output
*.log
.DS_Store
Thumbs.db

Particularly node_modules: if you do not ignore it, Docker copies potentially hundreds of megabytes of packages to the build context on every build, even though npm ci inside the container will replace them anyway.

Debugging Running Containers

docker exec -it my-api sh
docker exec my-api cat /app/dist/index.js
 
docker logs my-api
docker logs -f my-api --tail 100
docker logs --since="2024-01-15T10:00:00" my-api
 
docker inspect my-api
docker inspect my-api | jq '.[0].NetworkSettings'
docker inspect my-api | jq '.[0].Mounts'
 
docker stats
docker stats --no-stream my-api postgres redis
 
docker top my-api
docker diff my-api
 
docker cp my-api:/app/logs/error.log ./error.log
docker cp ./config.json my-api:/app/config.json

docker exec -it container sh gives you an interactive shell inside a running container. For Alpine-based images, sh works. For Debian/Ubuntu-based images, bash is available. For FROM scratch images (statically compiled binaries with nothing else), exec is not possible because there is no shell.

docker stats shows real-time resource usage for all containers: CPU percentage, memory usage and limit, network I/O, block I/O. When a container is unexpectedly slow or consuming excessive resources, docker stats is the first diagnostic step.

docker diff my-api shows files that have changed inside the container relative to its image. Useful for understanding what a container has modified during its lifetime.

Docker in CI/CD Pipelines

name: CI/CD
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
 
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
 
      - name: Install dependencies
        run: npm ci
 
      - name: Run tests
        env:
          DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
        run: npm test
 
  build-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }},ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

GitHub Actions services spin up Docker containers alongside the test runner. Your test code connects to localhost:5432 and localhost:6379. The containers are health-checked before tests run. When tests finish, the service containers are stopped and removed. This is a clean, reproducible test environment that every CI run starts fresh.

Production Deployment Patterns

On a single server with Docker Compose: appropriate for small to medium applications. The full stack runs on one machine. Simple to operate, limited to one machine's resources. Add a second machine and the complexity jumps significantly.

Docker Swarm: Docker's built-in orchestration for multi-machine clusters. Simpler than Kubernetes. Services defined similarly to Compose files. Built-in service discovery, load balancing, and rolling updates. A reasonable choice for teams that need multi-machine deployment without Kubernetes complexity.

Kubernetes: the dominant container orchestration platform for production at scale. Kubernetes handles scheduling containers across a cluster of machines, health checking, rolling deployments, auto-scaling, secrets management, and service networking. More complex to operate than Swarm, more powerful and more widely supported. If you are deploying to AWS, GCP, or Azure, managed Kubernetes (EKS, GKE, AKE) removes the control plane management burden.

A Kubernetes deployment is covered in the What is Kubernetes article. For now, understanding Docker is the prerequisite. Everything Kubernetes does is built on top of the container images you build with Docker.

Security Practices

Never run as root. USER nonroot in your Dockerfile. Every container running as root inside the container has root access to the host filesystem if a container escape vulnerability is exploited.

Use minimal base images. node:22-alpine instead of node:22. Alpine base images have fewer installed packages and smaller attack surfaces. FROM scratch is the extreme case for statically compiled binaries.

Scan images for vulnerabilities. docker scout cves my-api:latest (Docker's built-in tool) or trivy image my-api:latest (Aqua Security's open-source scanner) identify known CVEs in your image layers. Add a scan step to CI and fail the build if high-severity vulnerabilities are found.

Never put secrets in Dockerfile or images. Build args (ARG) passed to RUN commands appear in the image layer history. Use environment variables at runtime, not at build time, for secrets. Docker Secrets (for Swarm) and Kubernetes Secrets for mounted secret files are the production approaches.

Keep images updated. The base image node:22-alpine has security patches released regularly. Pin to a specific digest for reproducibility but automate base image updates through a tool like Renovate or Dependabot.

Docker's value compounds with every developer and environment added to a project. The second developer on a project never has the "it doesn't work on my machine" conversation when the project runs in a container. The CI environment matches local development. The staging environment matches production. The hours recovered from environment debugging across a team are significant.

Image Size Optimization

Image size affects pull time, cold start speed, storage costs, and attack surface. A 50MB image pulls faster than a 500MB image. A smaller image has fewer packages with potential CVEs.

Practical size reduction techniques:

Use Alpine or distroless bases. node:22 is ~1GB. node:22-alpine is ~180MB. node:22-slim is ~240MB. For production images where you do not need development tools, Alpine or distroless saves hundreds of megabytes.

Combine RUN commands. Each RUN creates a layer. Layers add overhead and accumulate removed files (deleted files in a layer are hidden but still present in the image). Chain commands with && to minimize layers:

# Creates 3 layers, apt cache persists in layer 1 even after layer 3 removes it
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
 
# Creates 1 layer, apt cache is never in the final image
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

Multi-stage builds eliminate build tools. The TypeScript compiler, Webpack, test libraries, and all other build-time dependencies stay in the builder stage. The production image contains only what the running application needs.

Compress and squash. docker build --squash merges all layers into one, removing intermediate layer overhead. Not available in all environments and cannot be undone, but can reduce final image size significantly for images that write and delete many files during build.

Check image layers with docker image history my-api:latest. The CREATED BY column shows each layer's command and SIZE shows how much each adds.

Running Docker in Production Without Kubernetes

Not every project needs Kubernetes. For a single-server deployment, Docker Compose with a process manager is production-ready.

apt-get install docker.io docker-compose-plugin
 
systemctl enable docker
systemctl start docker
 
mkdir /app && cd /app
git clone https://github.com/company/myapp .
cp .env.example .env
nano .env
 
docker compose pull
docker compose up -d
 
docker compose ps
docker compose logs -f --tail=100

Automatic container restart with restart: unless-stopped handles the "container crashes and restarts" case. The missing piece is "the entire server restarts" — but Docker is configured to start on boot with systemctl enable docker, and restart: unless-stopped makes containers restart when Docker starts. Your application comes back automatically after a server reboot.

For rolling deployments without downtime:

docker compose pull api
docker compose up -d --no-deps --scale api=2 api
sleep 30
docker compose up -d --no-deps --scale api=1 api
docker image prune -f

Scale to 2 API instances (old and new run simultaneously), wait for the new one to pass health checks, scale back to 1 (Docker removes the old one). Zero-downtime deployment on a single server without Kubernetes. Nginx handles load balancing between the two instances during the transition.

What Docker Is Not

Docker is not a security boundary. A container is process isolation, not hardware isolation. A malicious container that exploits a kernel vulnerability can escape to the host. For strong isolation requirements, use VMs for the outer boundary and containers inside them. AWS Fargate and Google Cloud Run run containers in isolated VMs for this reason.

Docker is not a replacement for understanding your application's resource usage. Running docker stats and watching memory climb is a symptom of a memory leak, not a Docker problem. Containers expose the problem clearly; they do not create it.

Docker is not required for local development. It adds a layer of abstraction that is valuable for environment consistency but can complicate debugging when you need to step through code in a process directly. Many developers run their database in Docker and their application locally without containerizing everything. That is a completely valid approach.

For production infrastructure beyond a single server — multiple servers, auto-scaling, zero-downtime deployments across a fleet — the next step is What is Kubernetes, which manages Docker containers across a cluster.

Docker Networking Deep Dive

Understanding how Docker assigns IP addresses and how containers communicate prevents hours of debugging "why can't container A reach container B."

Every Docker installation creates three default networks: bridge (the default for containers without --network specified), host, and none. When you run docker run nginx, it joins the bridge network and gets an IP in the 172.17.0.0/16 range. The host can reach it through the published port. Other containers on the same bridge network can reach it by IP address, but not by name — name resolution only works on user-created networks.

docker network create --driver bridge --subnet 172.20.0.0/16 myapp-net
 
docker run --network myapp-net --name postgres postgres:16-alpine
docker run --network myapp-net --name redis redis:7-alpine
docker run --network myapp-net --name api \
    -e DATABASE_URL=postgresql://postgres:5432/myapp \
    my-api:latest
 
docker network connect myapp-net existing-container
docker network disconnect myapp-net existing-container
 
docker network ls
docker network inspect myapp-net | jq '.[0].Containers'

On user-created networks, Docker's internal DNS resolves container names. api can reach postgres by hostname postgres. api can reach redis by hostname redis. The IP addresses are irrelevant — names work reliably even when containers restart with new IPs.

Separate networks for separate concerns. Your API and database share a backend network. Your API and Nginx share a frontend network. Your database and Nginx are on different networks — Nginx never needs to talk directly to the database. This network segmentation limits blast radius: a compromised API container can reach the database (which it needs), but cannot reach monitoring services or other internal systems on isolated networks.

Inter-host networking for multi-server Docker Swarm setups uses overlay networks. Overlay networks create a virtual network spanning multiple Docker hosts, allowing containers on different physical machines to communicate as if on the same LAN.

Container Logging and Observability

A process running inside a container that logs to stdout and stderr is the correct pattern. Docker captures those streams and routes them through a logging driver. The default json-file driver writes logs to /var/lib/docker/containers/<id>/<id>-json.log. The logging configuration in your Compose file controls rotation:

logging:
  driver: "json-file"
  options:
    max-size: "50m"
    max-file: "5"

Without log rotation, a busy container fills the host disk. Five 50MB files give you 250MB of log history per container before rotation removes the oldest.

For centralized logging, redirect to an external system:

logging:
  driver: "fluentd"
  options:
    fluentd-address: "localhost:24224"
    tag: "myapp.{{.Name}}"

AWS CloudWatch, Datadog, Loki/Grafana, and ELK stacks all have Docker log drivers or sidecar containers that ship logs to a centralized aggregator. In production, centralized logging is not optional — you need logs from all containers across all hosts in one searchable place.

The other half of observability is metrics. Container CPU, memory, network, and disk I/O are available through the Docker stats API. Prometheus with cAdvisor (Container Advisor) scrapes container metrics and exposes them to Grafana dashboards. Every production Docker deployment worth the name has this instrumentation.