Library
00/08 · ~40 min
GUIDEDECK · for shipping software that runs anywhere

Docker & Containers
package the environment,
not just the code.

A 40-minute working session on containers — from the "works on my machine" problem, through images, Dockerfiles and layers, volumes, networking, and Compose, then the tools you'll choose between, to a real bad → good setup you can copy.

~40 MINENGINEERSTOOL-AGNOSTIC
SCROLL
01 · The problem they solve 4 min

"Works on my machine"
is an environment bug.

Code rarely fails alone — it fails because the machine around it is different: another Node version, a missing system library, a config that only exists on someone's laptop. Containers fix this by shipping the environment with the code.

A container — an isolated, packaged process — bundles your app with everything it needs to run (runtime, libraries, files, env) into one unit that behaves the same on a laptop, in CI, and in production. Same image in, same behavior out.
3

environments where it must behave identically: dev, CI, prod.

1

artifact to rule them all — the image you build once.

ms

startup, not minutes — containers share the host kernel.

MBs

not GBs — no full guest OS to ship around.

Manual setup — drifts over time
# Getting started (good luck) # 1. install Node 18 (NOT 20, it breaks) # 2. brew install postgres@15, then createdb app # 3. set 7 env vars (ask Sam which ones) # 4. nvm use; npm i; ./scripts/seed.sh # …works for Sam. Not for you. Not in CI.
Containerized — reproducible
# Getting started (actually) docker compose up # the image pins the runtime, libs, and config. # identical on every laptop, in CI, in prod. # onboarding goes from a day to a minute.

VMs vs. containers

  • A virtual machine virtualizes hardware — each VM carries a full guest OS. Heavy: GBs, boots in seconds-to-minutes.
  • A container virtualizes the operating system — all containers share the host kernel (the core of the OS that talks to the hardware) and isolate only the process.
  • Result: containers are small and start instantly, so you can run many per host and rebuild them constantly.
App
Guest OS
App
Guest OS
App
Guest OS
App
+ libs
App
+ libs
App
+ libs
Hypervisor
Docker Engine
Host OS
Host OS · shared kernel
Hardware
Hardware
VIRTUAL MACHINES
CONTAINERS
full OS per app → GBs
one kernel for all → MBs

VMs virtualize hardware (a full OS each); containers virtualize the OS (one shared kernel) — so they ship in MBs and start in milliseconds.

02 · The mental model 6 min

An image is the blueprint;
a container is one running instance.

Get this one distinction right and the rest of Docker falls into place. The relationship is exactly class → object: build the template once, then run as many live copies as you like.

An image is a read-only, layered template — a snapshot of a filesystem plus metadata (the command to run, ports, env). A container is an image that's been started: a running process with its own thin writable layer on top. One image → many containers.

The lifecycle, in commands

  • docker build turns a Dockerfile into an image.
  • docker pull downloads a prebuilt image from a registry (e.g. Docker Hub).
  • docker run starts a container from an image.
  • docker ps lists running containers; stop / rm end and delete them.
  • docker build
  • docker pull
  • docker run
  • docker ps
  • docker stop
  • docker rm

Containers share the image's read-only layers; each adds a thin writable layer of its own.

Pets — mutate the running container
docker exec -it web bash apt-get install imagemagick # fix it live… vim /app/config.json # tweak by hand # changes live only in this container. # rebuild or restart → all of it vanishes.
Cattle — change the image, redeploy
# put the change in the Dockerfile / config docker build -t myapp:1.1 . docker run myapp:1.1 # the fix is versioned, reviewable, repeatable. # any container from this image is identical.

Treat containers as disposable and immutable: never patch a running one. If you need a change, change the image and roll a new container. Cattle, not pets — you don't nurse a sick container back to health by hand; you replace it with a fresh one built from the updated image.

03 · Building images 7 min

A Dockerfile is a recipe.
Every line is a cached layer.

Docker builds an image by running your Dockerfile top-to-bottom, and it caches each instruction as a layer. Understanding that cache is the difference between a 2-second rebuild and a 5-minute one — and between a 1.2GB image and a 90MB one.

A Dockerfile is a plain-text list of build steps. Each instruction (FROM, COPY, RUN…) produces one immutable layer. Docker reuses a layer's cache as long as that step and everything above it hasn't changed — so order matters enormously.

The instructions you'll use daily

  • FROM — the base image to start from.
  • WORKDIR — set the working directory.
  • COPY — copy files from your context into the image.
  • RUN — execute a command at build time (install deps, compile).
  • EXPOSE — document the port the app listens on.
  • CMD — the default command run at start time. Write it as a list — ["node","server.js"], the exec form — so that when Docker stops the container the "please shut down" signal reaches your app directly and it can exit cleanly. The plain-string shell form (node server.js) hides your app behind a shell, so it gets killed abruptly instead.
FROM node:20-slim # small base WORKDIR /app COPY package*.json ./ # deps first… RUN npm ci # …cached unless they change COPY . . # then the source EXPOSE 3000 CMD ["node", "server.js"] # exec form

Deps copied before source: edit a route and only the last two layers rebuild.

FROM node:20-slim WORKDIR /app COPY package*.json RUN npm ci COPY . . ← edited CMD ["node"…] cached ✓ rebuilt ✕ (this layer + everything below)

A cache hit stops at the first changed layer; everything below it rebuilds. Put what changes least at the top.

The #1 Dockerfile mistake

copies source before deps
FROM node:20 # ~1GB base COPY . . # any edit busts cache RUN npm install # reruns EVERY build CMD npm start # shell form, no signals
deps cached, slim base
FROM node:20-slim COPY package*.json ./ RUN npm ci # cached on code edits COPY . . CMD ["node","server.js"]

Two techniques that pay for themselves

Multi-stage builds

Build fat, ship thin

Use one stage with the full toolchain to compile, then copy only the output into a tiny runtime image. Your compiler, dev dependencies and source never reach production.

FROM node:20 AS build WORKDIR /app COPY . . RUN npm ci && npm run build # runtime stage — only the built assets FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html

Like  cooking in a full kitchen, then handing over just the plated dish — not the pots and pans.

.dockerignore

Keep junk out of the build

The build context is everything Docker uploads before building. A .dockerignore excludes the things that bloat images and bust the cache — and stops secrets sneaking in.

node_modules .git .env # never bake secrets into an image *.log dist Dockerfile

Like  a .gitignore for your image — smaller, faster, safer builds.

04 · Data that survives 5 min

A container's filesystem is throwaway.
Volumes are where data lives.

That thin writable layer on top of the image dies with the container. Delete the container — or redeploy a new image — and anything written inside is gone. For databases, uploads, and state, you need storage that outlives the process.

A volume is storage managed by Docker that lives outside the container's writable layer. Containers come and go; the volume — and your data — stays. Mount it into a path inside the container and writes there persist across restarts, rebuilds, and deletes.

Writes to a mounted path land in the volume, not the disposable writable layer — so they outlive the container.

no volume — data evaporates
docker run --name db postgres:16 # write 10,000 rows… docker rm -f db # every row is gone. forever.
named volume — data persists
docker volume create pgdata docker run --name db \ -v pgdata:/var/lib/postgresql/data \ postgres:16 # rm + recreate → rows are still there.

Three ways to mount storage

Named volume

Docker-managed

Docker owns the storage location. The default for production data — databases, uploads. Portable, backup-able, and decoupled from the host path.

-v pgdata:/var/lib/postgresql/data
Bind mount

A host folder, live

Maps a real directory on your machine into the container. Perfect for local dev — edit source on the host, the container sees it instantly (hot reload).

-v $(pwd)/src:/app/src
tmpfs

Memory only

Stored in RAM, never on disk, gone when the container stops. For secrets or scratch data you explicitly never want persisted.

--tmpfs /tmp/cache

Like  a hot-desk office: the desk (container) is wiped each night, but your locker (volume) keeps your things between days.

05 · Talking to the world 5 min

Containers are isolated by default.
You publish ports and join networks to connect them.

Two questions trip everyone up at first: how does the outside reach a container? (publish a port) and how do containers reach each other? (put them on the same network and use service names). Get these and Compose feels obvious.

Publishing a port with -p HOST:CONTAINER forwards a port on your machine to one inside the container. A user-defined network is a private bridge that lets containers on it find each other by name, via Docker's built-in DNS — no IPs, no --link.

Outside → in via a published port; inside → inside via the service name db on the shared network.

Reading -p 8080:3000

  • Left is the host port (what you type in the browser), right is the container port (what the app listens on).
  • localhost:8080 on your machine now reaches the app bound to :3000 inside.
  • Without -p, the port is reachable from other containers on the network but not from the host.
  • Inside a network, containers talk on the container's port directly — no publishing needed.
Hard-coded IPs & legacy --link
# container IPs change on every restart DB_HOST="172.17.0.3" # --link is deprecated and brittle docker run --link db:db web
Shared network + DNS by name
docker network create app-net docker run --network app-net --name db postgres:16 docker run --network app-net web # web connects to "db:5432" — stable forever
06 · Multi-service setups 6 min

One file. One command.
The whole stack up.

Real apps aren't one container — they're a web service, a database, maybe a cache and a queue, all wired together. Typing long docker run commands in the right order, every time, is a recipe for mistakes. Compose makes the stack declarative.

Docker Compose describes a multi-container application in one YAML file. You declare the services, their images, ports, volumes, env and dependencies; docker compose up creates the network and starts everything. The file is the documentation.
services: web: build: . ports: ["8080:3000"] environment: DATABASE_URL: postgres://db:5432/app depends_on: db: { condition: service_healthy } db: image: postgres:16 volumes: ["pgdata:/var/lib/postgresql/data"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] volumes: pgdata:

Everything from sections 2–5 in one place: image, port, env, volume, dependency order.

What this one file gives you

  • A private network is created automatically — web reaches db by name, no config.
  • depends_on + healthcheck start the DB and wait until it's actually ready before web.
  • The named volume keeps Postgres data across down/up cycles.
  • build: . builds web from your Dockerfile; image: pulls db prebuilt.
  • compose up -d
  • compose down
  • compose logs -f
  • compose ps
  • compose build
Imperative — order & flags by hand
docker network create app-net docker volume create pgdata docker run -d --name db --network app-net \ -v pgdata:/var/lib/postgresql/data postgres:16 # now sleep and hope the db is ready… docker run -d --name web --network app-net \ -p 8080:3000 -e DATABASE_URL=... myapp
Declarative — the file is the truth
docker compose up -d # network, volume, build, healthcheck-gated # startup order — all from compose.yaml. # tear it all down with one command: docker compose down
  • Compose is for one host — local dev, CI, simple single-box deploys. It is fantastic at exactly that.
  • When you need to run across many machines, with rolling deploys, self-healing and autoscaling, you graduate to an orchestrator — Kubernetes (or Nomad / ECS).
  • The mental model carries over: services, images, networks and volumes are the same nouns, just scheduled across a cluster.
07 · Pick the right tool 4 min

The container world is
bigger than Docker.

Docker popularized containers, but at each step — where you store images, what you build them with, which base you start from, and how you run them at scale — there are real alternatives. Here are the leading ones, with one honest pro and con each, and a quick rule for choosing.

Four decisions, four sets of tools. A registry is the cloud library you push images to and pull them from (like npm or GitHub, but for images). A build engine is the program that turns your Dockerfile into an image. A base image is the starting FROM layer everything else stacks on. An orchestrator runs your containers across one or many machines.

The pipeline shared by every tool below: build an image, push it to a registry, pull and run it elsewhere. The vendors just swap in at each arrow.

How to read these tabs

  • Each card names a real, widely-used option with one genuine strength and one genuine trade-off.
  • The Pro / Con pair is deliberately blunt — every tool here is a reasonable default for some team.
  • Read the Best for line at the bottom of each tab to skip straight to the choice that fits you.

Where your images live

Docker Hub

The default library

  • Pro — the biggest public catalog and where docker pull looks first, so almost every base image is one command away.
  • Con — free accounts hit strict pull rate limits, so busy CI pipelines can fail with "too many requests."
GitHub · GHCR

Next to your code

  • Pro — free for public images and sits right beside your repo and GitHub Actions, so building and publishing share one set of permissions.
  • Con — access is tied to GitHub accounts and teams, and it's less of a public discovery hub than Hub.
AWS · ECR

Private, in your cloud

  • Pro — fast and private, and wired tightly into AWS permissions and runners (ECS / EKS), so deploys stay inside one account.
  • Con — AWS-only, billed per GB stored, and logging in needs short-lived tokens and the right region set up.

Best for  public images and learning → Hub; you already live on GitHub → GHCR; you run on AWS and want everything private → ECR.

What turns a Dockerfile into an image

Docker

The standard

  • Pro — what everyone knows, with the largest ecosystem; its modern builder (BuildKit) gives fast, well-cached builds out of the box.
  • Con — it runs a background service as the root user, and Docker Desktop needs a paid license at larger companies.
Podman · Buildah

Daemonless & rootless

  • Pro — runs without a background service and without root by default (safer), and its CLI mirrors docker almost command-for-command.
  • Con — a smaller ecosystem; some Compose and Desktop conveniences lag behind or behave slightly differently.
Kaniko · Buildpacks

Build in CI, no daemon

  • Pro — build images inside CI or a Kubernetes cluster with no Docker daemon at all; Buildpacks can even skip the Dockerfile entirely.
  • Con — less flexible than a hand-written Dockerfile, and each adds its own concepts to learn.

Best for  most people → Docker; security or no-root environments → Podman; building inside a cluster or pipeline → Kaniko / Buildpacks.

The FROM you start with

Full · node:20

Batteries included

  • Pro — every shell and tool is already there, so it's the easiest to poke around in and debug.
  • Con — often 1GB+ and a bigger attack surface (more installed software means more things that could have security holes).
Slim · Alpine

Small and fast

  • Pro — tens of MB instead of a gigabyte, so images pull and start noticeably faster.
  • Con — Alpine uses a different system C library (musl, not the usual glibc), which can break native add-ons; fewer built-in debug tools.
Distroless

App and nothing else

  • Pro — ships only your app and its runtime — no shell, no package manager — so the attack surface is about as small as it gets.
  • Con — no shell to exec into makes debugging harder; you must build elsewhere and copy in (multi-stage).

Best for  quick local hacking → full; everyday production → slim; locked-down production → distroless.

Running containers at scale

Docker Compose

One box, one file

  • Pro — dead simple: one YAML file brings the whole stack up. Perfect for local dev, CI, and small single-server deploys.
  • Con — it only manages one machine, with no automatic restart-on-failure, autoscaling, or zero-downtime rolling updates.
Kubernetes

The cluster standard

  • Pro — the industry default for many machines: it restarts crashed containers, scales them up and down, and rolls out new versions with no downtime.
  • Con — a steep learning curve and real day-to-day operational work; genuinely overkill for a small app.
ECS · Cloud Run · Nomad

Managed middle ground

  • Pro — the cloud runs the hard control plane for you, so you get scaling and self-healing with far less to operate than raw Kubernetes.
  • Con — more tied to one vendor and less portable, with fewer knobs than running Kubernetes yourself.

Best for  dev and one server → Compose; many servers and a platform team → Kubernetes; scale without the ops burden → a managed service.

The honest default for most teams: Docker to build, a slim base, your platform's registry, and Compose until one machine genuinely isn't enough. Reach for the heavier options only when a real need shows up.

08 · A real setup & recap 3 min

From a fragile image
to one you'd ship.

Everything in one place: the same web service, done the way that bites you versus the way that holds up. Then five rules to walk out with.

What goes wrong

FROM node:20 # 1GB+ base COPY . . # node_modules, .git, .env and all RUN npm install # reruns on every code change EXPOSE 3000 CMD npm start # shell form → no graceful shutdown # runs as root · secrets baked in · 1.3GB image · slow builds
Symptoms
Minutes-long rebuilds, huge images, leaked .env, and a process that ignores SIGTERM on deploy.
Root cause
Cache-busting layer order, no .dockerignore, fat base, shell-form CMD.

What good looks like

FROM node:20-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim # clean runtime stage WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules USER node # drop root EXPOSE 3000 CMD ["node", "dist/server.js"] # exec form → clean signals
Wins
Cached deps, slim multi-stage image, non-root user (so a compromised app can't act as the all-powerful root account), exec-form CMD, secrets kept out via .dockerignore.
Pair it with
A compose.yaml for the db + volume + network, so compose up brings the whole stack online.

Five rules to walk out with

1Ship the environment, not instructions. The image is the artifact — identical in dev, CI and prod.
2Image = blueprint, container = instance. Treat containers as disposable; change the image, never patch a running one.
3Order layers by how often they change. Deps before source, slim bases, multi-stage builds, a .dockerignore.
4State lives in volumes. The writable layer is throwaway — anything you can't afford to lose gets a volume.
5One network, names not IPs; one Compose file. Declare the stack and bring it up with a single command.

Keep going

  • docs.docker.com — the official guides & Dockerfile reference
  • Dockerfile best practices — the build-cache & image-size checklist
  • Play with Docker — a free in-browser sandbox to try it live
  • Kubernetes — the next step when one host isn't enough

One sentence to remember

"Build once, run anywhere — because the environment ships with the code."

— the whole point of containers

Knowledge check

Did it stick?

Five quick questions on images, layers, volumes, networking and Compose — instant feedback, no sign-in.

Rate this deck
be the first

Navigate with ← → or scroll · back to library