๐ณ Development Workflows with Docker
"It works on my machine" is the oldest bug in software. Docker retires it. In this lesson you'll turn Docker from a production-only deployment tool into your everyday development environment โ one that a new teammate can clone and run in a single command, that mirrors production closely enough to catch bugs early, and that gets out of your way while you code.
Week 11 · Day 4 (Thursday: Docker for Development) · Lecture 1
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain why a containerized dev environment eliminates "works on my machine" bugs
- Write a development
Dockerfilethat layers dependencies for fast rebuilds - Bind-mount source code for live edits while protecting the container's
node_moduleswith an anonymous volume - Split configuration across
compose.yamlandcompose.override.yamlfor dev-versus-prod parity - Drive the daily loop with
docker compose up,logs -f, andexec - Add a healthcheck and
depends_onso services start in the right order
Estimated Time: 70 minutes
Practice: Stand up a two-service (API + Postgres) dev environment that a teammate can run with one command.
In This Lesson
Docker Beyond Production
Most people meet Docker as a deployment tool: build an image, ship it, run it in the cloud. But the same property that makes it good at deployment โ a reproducible, self-contained environment โ makes it just as valuable while you're writing the code. When your database, your exact Node version, and your OS libraries all live inside containers described by files in your repo, every developer runs the same stack, and that stack looks a lot like production.
๐ณ The professional kitchen analogy
Traditional local setup is like every chef cooking at home on different stoves with different pans, then being surprised the dish comes out wrong in the restaurant. A Docker dev environment hands every chef the same standardized kitchen. If the dish tastes right in development, it tastes right in production โ because it's literally the same kitchen.
Concretely, running your dev stack in containers buys you:
- Consistency โ one Node version, one Postgres version, for the whole team, on any OS.
- Isolation โ project A's dependencies can't collide with project B's, and nothing pollutes your host machine.
- Fast onboarding โ
git clonethendocker compose up, and a new hire is productive in minutes instead of a day of README archaeology. - Production parity โ deployment surprises shrink because dev and prod share the same base images.
- Disposability โ break things freely;
docker compose down -vresets you to a clean slate.
Four Workflow Patterns
There's a spectrum of how tightly you couple Docker to your inner loop. Knowing the four common patterns helps you pick the right one for the project in front of you.
slow feedback"] C --> C1["Edit on host, run in container
instant feedback"] D --> D1["App plus DB plus cache
one command"] E --> E1["Editor runs inside the container
full parity"]
Pattern 1 โ Build and Run
The simplest loop: rebuild the image on every change. Fine for a demo or a quick smoke test, painfully slow for real work because each edit means a full docker build.
docker build -t my-app .
docker run -p 3000:3000 my-app
# edit code โ stop โ rebuild โ run again (slow!)
Pattern 2 โ Bind-Mount for live edits
Mount your source directory into the container so the code the container runs is the code on your disk. Edit in your editor, the change is instantly visible inside the container. This is the workhorse pattern for interpreted stacks like Node.js, and the one we'll build on for the rest of the lesson.
Pattern 3 โ Compose multi-service
Real apps aren't one process. Docker Compose declares your app plus its database, cache, and message queue in a single file and starts them together on a shared network. This is where Docker development earns its keep.
Pattern 4 โ Dev Containers
Your editor (VS Code with the Dev Containers extension) attaches to a running container, so your terminal, linter, and language server all execute inside the same environment your app runs in. Maximum parity, slightly more setup.
๐ก Which do I use?
Start with Pattern 2 + 3 together: Compose to orchestrate services, bind mounts for live edits. It covers the vast majority of JavaScript projects. Reach for Dev Containers when you want the editor tooling itself to run in the container.
A Development Dockerfile
A dev image and a prod image have different jobs. Production wants a small, locked-down image with only what's needed to run. Development wants your dev dependencies, a hot-reloading start command, and a build that rebuilds quickly when only your source changes.
The single most important trick is layer ordering: copy package*.json and install dependencies before copying the rest of your source. Docker caches each layer, so as long as your dependencies haven't changed, edits to your app code skip the slow npm install entirely.
# Dockerfile.dev โ optimized for the inner loop
FROM node:20-alpine
WORKDIR /app
# 1. Copy ONLY the manifest first. This layer is cached and reused
# on every rebuild where package.json/package-lock.json are unchanged.
COPY package*.json ./
# 2. Install ALL dependencies, including devDependencies (nodemon, etc.)
RUN npm ci
# 3. Copy the source last, so editing code doesn't bust the deps layer.
COPY . .
# 4. The dev server port
EXPOSE 3000
# 5. Start a watcher, not "node index.js" โ we want restarts on change
CMD ["npm", "run", "dev"]
โ
Why npm ci instead of npm install?
npm ci installs exactly what's in package-lock.json โ deterministic, faster, and it fails loudly if the lockfile and package.json disagree. It's the right choice inside any image because reproducibility is the whole point.
Pair it with a .dockerignore so your host's node_modules and git history never get copied into the build context (which would be slow and could clobber the container's own modules):
# .dockerignore
node_modules
npm-debug.log
.git
.env
.env.local
dist
build
coverage
Dev vs Prod: compose.override.yaml
Modern Compose (the docker compose v2 plugin) reads two files automatically when you run docker compose up: the base compose.yaml and, if present, compose.override.yaml. The override is deep-merged on top of the base. This gives you a clean split: shared truth in the base file, dev-only conveniences in the override, and a separate explicit file for production.
๐ A note on filenames
The Compose Specification prefers compose.yaml and compose.override.yaml. The older names docker-compose.yml and docker-compose.override.yml still work and you'll see them everywhere. The version: top-level key is now obsolete โ you can delete it.
Base โ compose.yaml (shared truth)
# compose.yaml โ what dev and prod agree on
services:
api:
build:
context: .
environment:
NODE_ENV: production
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db_data:
Override โ compose.override.yaml (dev only, auto-loaded)
# compose.override.yaml โ merged on top automatically in dev
services:
api:
build:
dockerfile: Dockerfile.dev # use the dev image instead
environment:
NODE_ENV: development # override the base value
volumes:
- ./:/app # bind-mount source for live edits
- /app/node_modules # keep the container's node_modules
command: npm run dev # hot-reload start command
Production โ explicit, never auto-loaded
# Dev: base + override merge automatically
docker compose up
# Prod: name the files explicitly so the override is NOT applied
docker compose -f compose.yaml -f compose.prod.yaml up -d
โ ๏ธ The override is a footgun in CI
Because compose.override.yaml loads silently, a CI job that just runs docker compose up will accidentally pick up your dev bind mounts. In any non-dev context, always pass -f flags explicitly so you know exactly which files are in play.
Bind Mounts & the node_modules Trap
A bind mount maps a host directory into the container: ./:/app means "the container's /app is my project folder." Edit a file on your laptop and the container sees it immediately โ that's what makes live editing possible. But there's a classic gotcha.
When you bind-mount ./ onto /app, your host's project folder replaces the container's /app โ including the node_modules the image installed during docker build. If your host has no node_modules (or has ones built for a different OS/architecture), the app breaks with "module not found" or native-binary errors.
The fix is a second, anonymous volume layered on top of the bind mount, aimed precisely at node_modules:
volumes:
- ./:/app # bind-mount your source (live edits)
- /app/node_modules # anonymous volume: shields the container's own modules
Because the more specific path (/app/node_modules) wins, the container keeps the node_modules it installed at build time, while everything else in /app comes live from your host. Best of both worlds.
node_modules so the host can't overwrite it.โ ๏ธ Volume performance on macOS & Windows
Bind mounts are native-fast on Linux. On macOS and Windows the files cross a virtualization boundary, so large mounts can feel sluggish. Mitigate by mounting only what changes (e.g. ./src:/app/src), using the WSL2 backend on Windows, and keeping your project inside the Linux filesystem rather than /mnt/c.
The Daily Command Loop
Once your Compose files exist, day-to-day development is a handful of commands. These are the ones you'll type dozens of times a day.
# Start everything (foreground; Ctrl+C to stop)
docker compose up
# Start in the background instead
docker compose up -d
# Rebuild images (after changing a Dockerfile or dependencies)
docker compose up --build
# Follow logs for one service in real time
docker compose logs -f api
# Open a shell inside a running container
docker compose exec api sh
# Run a one-off command (install a package, run a migration)
docker compose exec api npm install zod
docker compose exec api npm run migrate
# Run a throwaway command in a fresh container, then remove it
docker compose run --rm api npm test
# Stop and remove containers (keep named volumes / data)
docker compose down
# Stop AND wipe volumes for a truly clean slate
docker compose down -v
Terminal output
$ docker compose up
[+] Running 2/2
โ Container app-db-1 Healthy 5.1s
โ Container app-api-1 Started 5.3s
app-api-1 | Server listening on http://0.0.0.0:3000
app-api-1 | Connected to postgres at db:5432
๐ก Service names are hostnames
Inside the Compose network, each service is reachable by its name. Your API connects to Postgres at db:5432, not localhost โ because localhost inside the API container is the API container itself. This is the number-one connection-string mistake beginners make.
Healthchecks & Startup Order
depends_on alone only waits for a container to start, not to be ready. Your API might launch and try to connect to Postgres before Postgres has finished initializing, and crash. A healthcheck plus condition: service_healthy makes Compose wait for genuine readiness.
services:
api:
# ...
depends_on:
db:
condition: service_healthy # wait until db's healthcheck passes
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s # how often to run the check
timeout: 3s # how long each check may take
retries: 5 # failures allowed before "unhealthy"
start_period: 10s # grace window on first boot
You can healthcheck your own app too. Add a tiny endpoint and point Compose at it:
api:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
// A minimal health endpoint (Express)
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
Practice & Quiz
๐๏ธ Exercise 1: A one-command dev environment
Goal: Write a compose.override.yaml for an Express API so that a teammate who runs docker compose up gets live-reloading source edits, a working node_modules, and a Postgres database โ with the base compose.yaml from the lesson above.
๐ก Hint
The override only needs to change the api service: swap in Dockerfile.dev, set NODE_ENV=development, add the two volume lines (bind mount + anonymous node_modules), and set a command that runs nodemon. The db service is inherited unchanged.
โ Solution
# compose.override.yaml
services:
api:
build:
dockerfile: Dockerfile.dev
environment:
NODE_ENV: development
volumes:
- ./:/app
- /app/node_modules
command: npm run dev
With the base file's db healthcheck and depends_on already in place, docker compose up now starts Postgres, waits for it to be healthy, then starts the API with hot reload.
๐๏ธ Exercise 2: Fix the broken connection string
Goal: A teammate's API can't reach the database. Their code reads postgres://postgres:secret@localhost:5432/appdb and it works locally but fails inside Compose. Explain and fix it.
โ Solution
Inside the API container, localhost refers to the API container itself, not the database. On the Compose network the database is reachable by its service name. Change the host to db:
# Wrong (inside a container)
postgres://postgres:secret@localhost:5432/appdb
# Right โ "db" is the service name from compose.yaml
postgres://postgres:secret@db:5432/appdb
Set it via environment so the same code works everywhere: DATABASE_URL: postgres://postgres:secret@db:5432/appdb.
๐ฏ Quick Quiz
Question 1: Why do you add an anonymous volume - /app/node_modules alongside the bind mount - ./:/app?
Question 2: When does compose.override.yaml get applied?
Question 3: Your API keeps crashing on startup because the database "isn't ready." What's the cleanest fix?
Best Practices & Pitfalls
โ Do
- Copy
package*.jsonand install before copying source, to keep the dependency layer cached - Keep shared config in
compose.yaml; put dev-only conveniences incompose.override.yaml - Protect the container's
node_moduleswith an anonymous volume - Reference other services by their Compose service name, never
localhost - Add healthchecks so services start in the right order
- Commit all Docker config to version control so onboarding is one command
โ Don't
- Bake secrets into images โ pass them via
environmentor an.envfile that's git-ignored - Rely on
compose.override.yamlloading in CI or prod โ pass-fexplicitly there - Bind-mount your whole tree on macOS/Windows if performance suffers โ mount only
./src - Leave the obsolete
version:key in new Compose files - Assume
depends_onmeans "wait until ready" โ it doesn't without a healthcheck
โ ๏ธ Permission mismatches
Files created inside the container may be owned by root on your host (or vice versa), leading to "permission denied" on edit. On Linux, run the container as your user (user: "${UID}:${GID}") or add a non-root user in the Dockerfile. This bites Linux users far more than macOS/Windows users.
Summary
๐ Key Takeaways
- A containerized dev environment gives you consistency, isolation, one-command onboarding, and production parity
- Order your dev
Dockerfileso dependencies install before source is copied โ that keeps rebuilds fast compose.override.yamlauto-merges overcompose.yamlin dev; production uses explicit-ffiles- Bind-mount source for live edits, and shield
node_moduleswith an anonymous volume - Talk to other services by service name, and use healthchecks to order startup
๐ Additional Resources
- Docker Docs โ Merge Compose files
- Docker Docs โ Dockerfile best practices
- Docker Docs โ depends_on & healthchecks
- Docker Docs โ Bind mounts
๐ What's Next?
Your bind-mount is delivering fresh code into the container, but the process inside still needs to notice and restart. The next lesson wires up Hot Reloading in Containers โ nodemon, Vite --host, and the CHOKIDAR_USEPOLLING flag that rescues file-watching on tricky mounts.
๐ Well done!
You can now stand up a full dev stack that a teammate runs with a single command โ and that behaves like production.