Skip to content
Available for work
Adam
←  All posts

Learning Docker - From 'It Works on My Machine' to Reproducible Builds

My first real dive into containers - what an image actually is, why layers matter, and how multi-stage builds cut my image from 1.2GB to 180MB.

4 min readdocker · devops · containers

TL;DR

  • An image is an immutable, layered filesystem snapshot; a container is a running instance of one.
  • Every line in a Dockerfile is 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 shipping node_modules and .git into the build context, which made builds crawl. Adding a .dockerignore was an instant win.
  • Running as root. Containers run as root by default. Adding USER node is 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-api

Same 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.