Containers and Docker basics

Why containers — a "shrink-wrapped" copy of your application including its entire runtime, all dependencies, and precise OS version. What Docker is, why containerization matters at scale, and the multi-stage build pattern.

Beginner

4 min read

The problem containers solve

You've built and tested a Node.js application on your machine. It works. You hand it off to an ops team to deploy. They install it on a Linux server, but the server has a different Node version, a different OpenSSL library version, and a system dependency your code assumes exists but isn't there. It doesn't work.

You ask, "Can you match my machine's setup?" They ask, "How?" You realize you've never documented what "your machine's setup" actually is — Node version, npm version, OS, system packages. They install something close, it mostly works, and you spend weeks debugging production issues that can't be reproduced locally.

Containers solve this by packaging everything: the application code, the Node runtime, the exact OS version, and every system dependency, all inside a single image. Run that image anywhere — your laptop, a production server, the cloud — and it behaves identically. No "works for me" surprises; no manual setup steps.

Docker: the container runtime

Docker is a tool that builds and runs containers. When you define a Dockerfile (a recipe), Docker reads it and constructs an image — a complete, read-only snapshot of your application and everything it depends on. When you run an image, Docker creates a container — a temporary, isolated environment where your application executes.

A minimal Dockerfile:

FROM node:20-slim
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci
 
COPY . .
 
EXPOSE 3000
 
CMD ["node", "dist/index.js"]
  • FROM node:20-slim — start with an official base image containing Node 20 and a minimal OS.
  • WORKDIR /app — set the working directory inside the container.
  • COPY package*.json ./ — copy your package files.
  • RUN npm ci — install dependencies inside the container (not on your machine).
  • COPY . . — copy your application code.
  • EXPOSE 3000 — document that the app listens on port 3000 (informational only).
  • CMD ["node", "dist/index.js"] — when the container starts, run this command.

Build the image: docker build -t my-app:latest .
Run it: docker run -p 3000:3000 my-app:latest

Now your application runs inside the container, isolated from the host machine.

Layers and efficiency

Docker images are built in layers. Each line in the Dockerfile (each RUN, COPY, etc.) creates a layer. Layers are cached — if you rebuild the image and only your application code changed (line COPY . .), Docker reuses the earlier layers (the FROM, the RUN npm ci that installs dependencies) without redoing them. This makes rebuilds fast.

Layer order matters: put things that change rarely (base image, dependencies) early, and things that change often (your code) later. If you COPY . . before RUN npm ci, every code change invalidates the dependency cache, and every build reinstalls all packages from scratch — slow and wasteful.

Multi-stage builds: shrinking production images

A common pattern: multi-stage builds separate the build environment from the runtime environment.

# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
# Stage 2: Runtime
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

Stage 1 (builder) has everything — TypeScript compiler, dev dependencies, source files. Stage 2 (the final image) copies only the compiled dist/ and production node_modules, discarding the TypeScript compiler, build tooling, and source code. The final image is much smaller and doesn't ship unnecessary tools.

Registries: storing and sharing images

Once built, images must be stored somewhere so other machines (and deployment systems) can pull and run them. A container registry is a repository of images, like GitHub but for Docker images:

  • Docker Hub — public, free for open-source.
  • Amazon ECR (Elastic Container Registry) — AWS's managed registry, often used for private/internal images.
  • Google Container Registry (GCR), Azure Container Registry — similar services from GCP and Azure.

To push an image to ECR:

docker tag my-app:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest

Now other systems can pull and run that exact image: docker run 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest. No manual deployment steps; no "install this version of the runtime" — just pull the image and run.

Why this matters for infrastructure at scale

Without containers, every deployment is a manual process: SSH into a server, check out code, install/update dependencies, restart the application. At scale (dozens or hundreds of servers), this is error-prone and slow. With containers, you build once, test the image, and deploy the identical image to any number of servers instantly. Orchestration tools like Kubernetes manage running and scaling these containers automatically.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does a Dockerfile's FROM statement do, and why is it important?

2. Why is layer order important in a Dockerfile?

3. What problem do multi-stage Docker builds solve?

4. Why must container images be pushed to a registry?