Warming up the neural circuits...
By the end of this chapter you will:
"It works on my machine" is not a bug report — it's a symptom of an environment that only exists on one person's laptop. Docker makes your environment portable, reproducible, and identical everywhere.
Before standardized shipping containers in the 1950s, cargo was loaded piece by piece — barrels, crates, sacks — onto ships. Every port had different equipment, different sizing, different handling. A shipment that left London might arrive in New York damaged, delayed, or lost because nothing fit together.
Malcolm McLean invented the intermodal shipping container: a standard-sized metal box that fits on a truck, a train, or a ship without unpacking. The container doesn't care what's inside — electronics, grain, furniture — it handles them all the same way. The transportation industry exploded in efficiency overnight.
Docker containers are the same idea for software. Your app, its dependencies, its runtime, its config — all sealed in a standard unit that runs identically on your laptop, your teammate's MacBook, the CI server, and the production cluster. No more "but it works on my machine."
To understand Docker, understand what it's NOT: a virtual machine.
A VM virtualizes the hardware. You run a full guest OS (kernel, init system, libraries) on top of a hypervisor. Each VM has its own kernel. This is heavy — gigabytes of memory, minutes to boot.
A container virtualizes the operating system. All containers on a host share the same Linux kernel. Each container gets isolated userspace — its own filesystem, processes, network — but there's no guest OS to boot. Result: containers start in milliseconds and use megabytes of RAM.
┌─────────────────────────────────────────────────────┐
│ VIRTUAL MACHINES │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ App A │ │ App B │ │ App C │ │
│ │ Bins/Libs│ │ Bins/Libs│ │ Bins/Libs│ │
│ │ Guest OS │ │ Guest OS │ │ Guest OS │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ Hypervisor │
│ Host OS │
│ Hardware │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ CONTAINERS │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ App A │ │ App B │ │ App C │ │
│ │ Bins/Libs│ │ Bins/Libs│ │ Bins/Libs│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ Container Engine (Docker) │
│ Host OS (shared kernel) │
│ Hardware │
└─────────────────────────────────────────────────────┘Docker isn't magic — it's built on two Linux kernel features:
Namespaces provide isolation. Each container sees its own:
/etc is not the container's /etc.cgroups (control groups) provide resource limiting. They answer: "How much CPU, memory, and I/O can this container use?" Without cgroups, one container could starve all others.
# Run a container with memory and CPU limits
docker run -d \
--memory="512m" \
--cpus="1.5" \
--name my-api \
my-api:latestAn image is a read-only template — a snapshot of a filesystem with your app and all its dependencies. A container is a running instance of an image with a thin writable layer on top.
Think of an image like a class definition; a container is an object instantiated from it.
Images are built in layers. Each instruction in a Dockerfile (RUN, COPY, ADD) creates a new layer. Layers are cached: if nothing changed, Docker reuses the cached layer. This is why Docker builds are fast after the first one.
FROM node:22-alpine # Layer 1: Base OS + Node
WORKDIR /app # Layer 2: Set working directory
COPY package*.json ./ # Layer 3: Dependencies manifest
RUN npm ci --only=production # Layer 4: Install dependencies
COPY . . # Layer 5: Application codeDocker rebuilds from the first changed layer downward. That's why we COPY package*.json BEFORE COPY . . — if you only change source code, layers 1-4 are cached and only layer 5 rebuilds. Swap those two lines and you reinstall node_modules on every code change. This one ordering trick can turn a 2-minute build into a 5-second build.
Let's build a real Dockerfile for a Node.js — the right way.
# ---- Stage 1: Build ----
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies (including devDependencies for build)
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Prune devDependencies after build
RUN npm prune --production
What makes this production-grade:
Multi-stage build: The builder stage has , devDependencies, and source code. The production stage copies only the compiled output and production dependencies. The final image is 60-80% smaller.
Non-root user: By default, containers run as root. If an attacker escapes the container, they get root on the host. USER appuser mitigates this.
HEALTHCHECK: Docker periodically hits your health endpoint. If it fails, the container is marked "unhealthy" — orchestrators (Docker Swarm, ) can restart it automatically.
npm ci instead of npm install: ci uses the lockfile exactly (faster, deterministic). install can mutate package-lock.json.
Explicit base image tag: node:22-alpine — not node:latest. Latest changes over time; your build should be reproducible forever.
# Build an image
docker build -t my-api:latest .
docker build -t my-api:v1.2.3 . # Tag with version
# List images
docker images
# Run a container
docker run -d --name my-api -p 3000:3000 my-api:latest
# Run with environment variables
Containers are ephemeral — when you remove a container, its filesystem disappears. For data that must survive container restarts (database files, uploads, logs), use volumes.
Bind mounts link a host directory to a container directory:
docker run -v /home/user/data:/app/data my-apiNamed volumes are managed by Docker:
docker volume create app-data
docker run -v app-data:/app/data my-apiNamed volumes are preferred for production — Docker manages their location, they work across clusters, and they're easier to back up.
Containers have their own network namespace. Port 3000 inside the container is not automatically accessible from the host.
# Map host port 8080 to container port 3000
docker run -p 8080:3000 my-api
# Now http://localhost:8080 reaches the container's port 3000Containers can talk to each other through Docker networks:
# Create a network
docker network create my-net
# Run containers on the same network
docker run -d --name db --network my-net postgres:16
docker run -d --name api --network my-net -p 3000:3000 my-api
# Now 'api' can reach 'db' by container name:
# Inside the api container: psql -h db -U postgresDocker has built-in DNS — container names resolve to their internal IP addresses on user-defined networks.
A payment processing startup had a 3-year-old Node.js . Deployments were manual: SSH into the EC2 instance, git pull, npm install, pm2 restart. Problems:
npm install on a t3.small instance crawled. Sometimes it failed mid-install.They Dockerized in one week:
Wrote a multi-stage Dockerfile. Build stage compiled TypeScript. Production stage was a slim 180 MB image (down from 900 MB with the naive approach).
Moved config to environment variables. No more .env files scp'd to the server. Secrets went into AWS Secrets Manager, injected at runtime.
Added HEALTHCHECK. The now had a real health endpoint instead of just checking if port 3000 was open.
Built once, ran anywhere. The same image (tagged payment-api:v3.2.1) ran on the developer's laptop, the staging server, and the production server.
Result: Deployments went from 20 minutes to 30 seconds (docker pull && docker stop && docker run). Scaling to 3 servers was a matter of running docker run on each. The "works on my machine" problem vanished — the image IS the machine.
| Mistake | Why it's wrong | Fix |
|---|---|---|
| Running container as root | Container breakout = host root access | USER appuser with a non-root user (UID ≥ 1000) |
COPY . . before COPY package*.json | Every code change busts the npm install | Copy package files first, install deps, THEN copy source |
Using :latest tag in production | latest means "whatever was built last" — not reproducible | Use semantic version tags: my-api:1.4.2 |
Storing secrets in Dockerfile with ENV | Secrets are baked into image layers forever, visible via docker history | Pass secrets at runtime via -e or a secrets manager |
| Not using multi-stage builds | Final image includes compilers, devDependencies, source code — bloated and insecure | Use builder + production stages; COPY --from=builder |
| One container = multiple processes | Violates the "one concern per container" principle; hard to monitor and restart individually | One process per container; use Docker Compose or an orchestrator for multi-process apps |
| Not setting memory/CPU limits | One container can consume all host resources, starving others | Always set and in production |
alpine variants are 5-7 MB vs 70+ MB for Debian-based images. Smaller images = faster pulls, smaller attack surface.FROM node:22-alpine@sha256:abc123... guarantees you're getting the exact same base image every time, even if the tag is mutated upstream.docker logs or ship to a log aggregator (CloudWatch, Loki, Datadog). Writing logs to files inside the container is an anti-pattern..dockerignore. Like .gitignore, prevents sending node_modules, .git, logs, and env files to the Docker daemon during build. Speeds up builds and keeps secrets out of the build context.RUN commands. Each RUN creates a layer. RUN apt-get update && apt-get install -y pkg1 pkg2 && rm -rf /var/lib/apt/lists/* in one line reduces layer count and cleans up in the same layer (so the cleanup actually reduces image size).npm ci not npm install. ci is faster and respects the lockfile exactly. In , it's the only correct choice.docker scout quickview my-api:latest or Trivy (trivy image my-api:latest). Do this in CI — block deploys for critical CVEs./var/run/docker.sock) to a container unless absolutely necessary. A container with socket access can control the host's Docker — effectively root access.--read-only for stateless containers. docker run --read-only my-api makes the container's filesystem immutable. Any write attempt fails. Mount specific volumes for directories that need writes.COPY --chown=appuser:appgroup to ensure copied files are owned by the non-root user.Beginner:
nginx:alpine image. Map port 8080 to port 80. Create a custom index.html on your host and bind-mount it into /usr/share/nginx/html/. Verify your custom page is served.Intermediate:
Advanced:
docker scout or Trivy, find a CVE, and fix it by updating the base image or a dependency. Document the CVE ID, severity, affected package, and the fix applied.Beginner:
Q: What's the difference between a Docker image and a container? A: An image is a read-only template — a filesystem snapshot with your app and dependencies. A container is a running instance of an image with a writable layer on top. You can run many containers from the same image, like instantiating objects from a class.
Q: What is a Dockerfile?
A: A text file with instructions for building a Docker image. Each instruction (FROM, RUN, COPY, CMD) creates a layer. The Dockerfile is the recipe; the image is the baked cake.
Q: How do you pass environment variables to a container?
A: Using the -e flag: docker run -e DATABASE_URL=postgres://... my-api. Or with an env file: docker run --env-file .env my-api. Never hardcode secrets in the Dockerfile with ENV — those are baked into image layers.
Senior:
Q: Explain Docker layer caching. How do you structure a Dockerfile to maximize cache hits?
A: Each Dockerfile instruction creates a layer identified by a hash of its content and context. Docker caches layers and reuses them if the instruction and its inputs haven't changed. To maximize cache hits: (1) Order instructions from least to most frequently changing. (2) Copy dependency manifests (package.json, requirements.txt) before source code — install step is cached unless dependencies change. (3) Use .dockerignore to exclude files that would bust the cache unnecessarily (node_modules, .git). (4) Combine RUN commands to reduce layer count. (5) Use --mount=type=cache in BuildKit for package manager caches that persist across builds.
Q: How would you reduce a 2 GB Docker image to under 200 MB?
A: Multiple strategies combined: (1) Use a slim base image (alpine instead of ubuntu). (2) Multi-stage builds — compile/build in a heavy stage, copy only artifacts to a minimal stage. (3) npm prune --production or pip install --no-cache-dir to exclude dev dependencies. (4) Clean package manager caches in the same RUN layer (). (5) Use to prevent large files from entering the build context. (6) For interpreted languages, consider using base images (Google's approach — no shell, no package manager, just your app and runtime). (7) Check to identify large layers and optimize them.
Docker packages your application and its entire runtime environment into a portable, reproducible unit called a container. Unlike VMs, containers share the host kernel and start in milliseconds. Images are built in layers — each Dockerfile instruction creates one, and Docker caches them aggressively. Multi-stage builds keep production images small by separating build-time tools from runtime artifacts. Volumes persist data beyond a container's lifecycle. Port mapping exposes container ports to the host. Docker networking lets containers discover each other by name. The key mindset shift: your server is no longer a pet you SSH into and hand-tune — it's cattle, defined entirely by a Dockerfile and config, reproducible on any machine in seconds.
RUN/COPY/ADD creates one. Order from least to most frequently changing.FROM ... AS builder → build → COPY --from=builder into slim production image.USER appuser — never run containers as root.docker volume create) for persistent data. Bind mounts for dev.-p hostPort:containerPort.docker network create → containers resolve each other by name..dockerignore: Keeps junk and secrets out of the build context.docker system prune -a: Clean up unused images, containers, networks, and build cache.COPY package.json before COPY . . in a Node.js Dockerfile? Answer: So the npm install layer is cached unless dependencies change — code changes alone don't trigger a full reinstall.:latest tag dangerous in production? Answer: It's a moving target — latest points to whatever was most recently built. You can't roll back or reproduce. Use semantic versions.docker exec -it my-container sh do? Answer: Opens an interactive shell inside a running container — useful for debugging (checking env vars, file contents, running one-off commands).--memory--cpusapt-get clean && rm -rf /var/lib/apt/lists/*.dockerignoredistrolessdocker history <image>Q: A containerized database loses all its data when the container is restarted. Why, and how do you fix it?
A: Containers are ephemeral — their filesystem is destroyed when the container is removed (and restarting creates a new container). The fix is volumes: (1) Named volume: docker run -v db-data:/var/lib/postgresql/data postgres — Docker manages the volume location. (2) Bind mount: docker run -v /host/path:/var/lib/postgresql/data postgres — you control the exact path. Named volumes are preferred for production. Also check that the database process isn't writing to a directory that's NOT volume-mounted — some databases spread data across multiple paths.