TL;DR
- An image is an immutable, layered filesystem snapshot; a container is a running instance of one.
- Every line in a
Dockerfileis a cached layer - ordering them well is the whole game. - A multi-stage build took my Node image from 1.2GB → 180MB.
- "Works on my machine" stops being a sentence you ever say again.
Why I started here
I'd been deploying by SSH-ing into a box, git pull-ing, and praying the Node version matched. It worked until it didn't - a teammate's Postgres client library compiled against a different libc and the whole thing fell over. I realized I didn't actually understand the environment my code ran in. Docker was the obvious gap to close before touching anything cloud.
What clicked: images vs containers
The mental model that finally made it stick:
- An image is like a class. It's a read-only template built from stacked layers.
- A container is like an instance of that class - a thin writable layer on top of the image, plus an isolated process tree, network namespace, and filesystem.
You can spin up 50 containers from one image and they share the underlying layers on disk. That's why containers are cheap and VMs aren't - there's no guest OS, just isolated processes sharing the host kernel.
The thing nobody told me: layer caching
My first Dockerfile rebuilt npm install on every code change. Builds took 4 minutes. The fix was understanding that Docker caches each layer and invalidates everything after the first changed line:
# ❌ Slow - any source change busts the npm cache
COPY . .
RUN npm install
# ✅ Fast - deps only reinstall when package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Copying package.json first means the expensive npm ci layer stays cached as long as my dependencies don't change. Build time dropped to ~15 seconds on a code-only change. This one reordering is the single highest-leverage Docker thing I learned.
Multi-stage builds - the 1.2GB → 180MB moment
My production image was shipping the entire build toolchain - TypeScript, dev dependencies, the works. Multi-stage builds let you use a fat image to build and a lean one to run:
# --- 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 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]The final image only contains compiled output and production deps. No compilers, no source, no .git. Smaller images mean faster pulls, faster cold starts, and a smaller attack surface.
Mistakes I made (so you don't have to)
- No
.dockerignore. I was shippingnode_modulesand.gitinto the build context, which made builds crawl. Adding a.dockerignorewas an instant win. - Running as root. Containers run as root by default. Adding
USER nodeis a free security upgrade. - Treating containers as pets. They're cattle. State goes in volumes or a database, never in the container's writable layer.
Verification
docker build -t taskflow-api .
docker images taskflow-api # 180MB, down from 1.2GB
docker run --rm -p 3000:3000 taskflow-apiSame image on my laptop, in CI, and in production. Byte-for-byte identical.
Takeaways
- Order your Dockerfile by change frequency - least-changing layers first.
- Multi-stage builds are non-negotiable for compiled languages.
- A container isn't a tiny VM; it's an isolated process. Internalizing that changed how I think about state, networking, and scaling.
Next
Now that I can package anything reproducibly, the next bottleneck is my database. I've been writing queries without understanding what the planner actually does with them - time to learn PostgreSQL indexing properly.