Why I built a Docker lab instead of just reading docs
Most Docker tutorials stop at docker run hello-world. The gap between "I ran a container" and "I can containerize a real multi-service app for production" is where most self-taught knowledge falls apart — usually around volumes, networking, and the moment you realize your bind mount just deleted node_modules inside the container.
So instead of treating Docker as something to skim, I built it as a numbered curriculum inside one repo: nine progressive modules, each with its own working Dockerfile, compose file, or app, moving from a single container to a Compose-orchestrated multi-service stack, and finishing with a real Next.js + Prisma + Postgres project built and deployed the way I'd actually ship it for a client.
00. DockerFiles/ -> first images, EXPOSE, CMD
02. ImagesContainers/ -> image layers, container lifecycle
03. DataVolumes/ -> named volumes, bind mounts, anonymous volumes
04. Networking/ -> container-to-container DNS resolution
05. MultiContainer/ -> manually wired frontend + backend + db
06. Compose/ -> the same stack, declarative
07. UtilityContainers/ -> containers as CLI tools (npm, composer)
08. Laravel/ -> nginx + php-fpm + mysql, dockerized
09. Deploy/ -> shipping images to a registry
Each folder is runnable on its own — not slideware, actual Dockerfiles and compose configs I built and broke things with.
The volume mounting problem that teaches you how Docker actually works
The most useful thing in this repo isn't the happy path — it's the documented failure. In module 03, I set up a Node app with a bind mount for live reload and hit the classic trap on Windows:
# Bind Mount and Anonymous Volume for node_modules (Windows - DOES NOT WORK)
docker run -d -p 3000:80 --rm --name feedback-app \
-v feedback-app:/app/feedback \
-v "$(pwd):/app" \
-v /app/node_modules \
--env-file ./.env feedback-node:volumes
On Windows, this silently breaks the app — the bind mount for /app overwrites the container's node_modules, because Docker Desktop's Windows-to-container volume layering doesn't respect the anonymous-volume-over-bind-mount precedence the same way WSL/Linux does. The fix isn't a Docker flag, it's an architectural one: keep dependencies out of the bind-mounted path entirely, either by installing them inside the image and excluding them from the mount via Compose named volumes, or running everything through WSL where the same command works correctly.
That distinction — why the same command behaves differently across host OSes — is the kind of thing you only learn by hitting it, and it's exactly the kind of gotcha that costs a client hours if their engineer hasn't seen it before.
From manual wiring to Compose, side by side
Module 05 and 06 solve the identical problem two ways on purpose: a three-service app (MongoDB + Node backend + React frontend) first wired by hand with docker network create and explicit --network flags on every run command, then again as a single docker-compose.yaml:
services:
backend:
container_name: nodes
build: ./backend
ports:
- '3001:3001'
volumes:
- /app/node_modules # anonymous volume
- logs:/app/logs # named volume
- ./backend:/app # bind mount
env_file:
- ./env/nodes.env
depends_on:
- mongodb
frontend:
build: ./frontend
ports:
- '3000:3000'
depends_on:
- backend
volumes:
data:
logs:
Having both versions in the same repo makes the value of Compose concrete rather than assumed: it's not "a nicer syntax," it's the elimination of an entire class of manual-networking mistakes (typo'd container names, forgotten --network flags, services started in the wrong order) by making dependency order and volume strategy declarative and reviewable in a diff.
Utility containers and a real Laravel stack
Module 07 uses Docker for something people often miss — as disposable CLI tooling. An npm-flavored image with ENTRYPOINT ["npm"] means docker-compose run npm install runs against the exact Node version the project needs, with zero local Node installation required. It's a small pattern, but it's the same one that keeps a team's "works on my machine" surface area near zero.
Module 08 scales that up to a five-service Laravel stack — nginx, php-fpm, MySQL, and separate one-shot containers for Composer and Artisan:
| Service | Role |
|---|---|
server (nginx) | reverse proxy, serves /var/www/html, forwards to php-fpm |
php | custom Dockerfile, php-fpm runtime |
mysql | database, config via env_file |
composer | one-shot container, dependency install only |
artisan | one-shot container, entrypoint pinned to php artisan |
npm | one-shot container for frontend asset builds |
Splitting Composer and Artisan into their own services rather than shelling into the php container is the detail that matters: it keeps the runtime image lean (no Composer binary shipped to production) while still making docker-compose run artisan migrate a one-liner in local dev.
Closing the loop: a production multi-stage build
The lab's most recent addition is ISPName, a Next.js 16 + Prisma + Postgres app, and its Dockerfile is the one I'd actually hand to a client:
FROM node:22-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runner
ENV NODE_ENV=production
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
RUN mkdir -p /app/.next/cache && chown -R nextjs:nodejs /app/.next
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Three things separate this from the module-00 Dockerfile the lab started with:
- Multi-stage builds —
depsandbuildernever ship; only Next.js'sstandaloneoutput and static assets reach the final image, keeping it small and free of source/dev dependencies. - Non-root runtime user — a dedicated
nextjsUID/GID instead of running as root in production, withchownscoped only to the cache directory the process actually needs to write to. - Layer caching by dependency graph —
package.json/package-lock.jsonare copied and installed before the rest of the source, so a code-only change doesn't invalidate thenpm cilayer.
Takeaway
This repo isn't a demo app — it's evidence of how I approach a new piece of infrastructure: don't stop at "it runs," reproduce the failure modes on purpose (the Windows bind-mount trap, manual networking vs. Compose, root vs. non-root runtimes) so the eventual production setup is informed by what actually breaks, not just what a quickstart guide shows. For a client, that's the difference between a Dockerfile that works in a demo and one that's still fine six months later when the team, the load, or the host OS changes.