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.
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.
environments where it must behave identically: dev, CI, prod.
artifact to rule them all — the image you build once.
startup, not minutes — containers share the host kernel.
not GBs — no full guest OS to ship around.
VMs virtualize hardware (a full OS each); containers virtualize the OS (one shared kernel) — so they ship in MBs and start in milliseconds.
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.
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.Containers share the image's read-only layers; each adds a thin writable layer of its own.
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.
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.
FROM, COPY, RUN…) produces one immutable layer. Docker reuses a layer's cache as long as that step and everything above ithasn't changed — so order matters enormously.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.Deps copied before source: edit a route and only the last two layers rebuild.
A cache hit stops at the first changed layer; everything below it rebuilds. Put what changes least at the top.
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.
Like cooking in a full kitchen, then handing over just the plated dish — not the pots and pans.
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.
Like a .gitignore for your image — smaller, faster, safer builds.
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.
Writes to a mounted path land in the volume, not the disposable writable layer — so they outlive the container.
Docker owns the storage location. The default for production data — databases, uploads. Portable, backup-able, and decoupled from the host path.
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).
Stored in RAM, never on disk, gone when the container stops. For secrets or scratch data you explicitly never want persisted.
Like a hot-desk office: the desk (container) is wiped each night, but your locker (volume) keeps your things between days.
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.
-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.
-p 8080:3000localhost:8080 on your machine now reaches the app bound to :3000 inside.-p, the port is reachable from other containers on the network but not from the host.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 up creates the network and starts everything. The file is the documentation.Everything from sections 2–5 in one place: image, port, env, volume, dependency order.
web reaches db by name, no config.depends_on + healthcheck start the DB and wait until it's actually ready before web.volume keeps Postgres data across down/up cycles.build: . builds web from your Dockerfile; image: pulls db prebuilt.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.
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.
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."
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.
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.
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.
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.
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.
FROM you start withPro— 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).
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.
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.
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.
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.
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 Composeuntil one machine genuinely isn't enough. Reach for the heavier options only when a real need shows up.
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.
.env, and a process that ignores SIGTERM on deploy..dockerignore, fat base, shell-form CMD.CMD, secrets kept out via .dockerignore.compose.yaml for the db + volume + network, so compose up brings the whole stack online..dockerignore."Build once, run anywhere — because the environment ships with the code."
— the whole point of containers
Five quick questions on images, layers, volumes, networking and Compose — instant feedback, no sign-in.
Navigate with ← → or scroll · back to library