Skip to main content

๐Ÿณ 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 Dockerfile that layers dependencies for fast rebuilds
  • Bind-mount source code for live edits while protecting the container's node_modules with an anonymous volume
  • Split configuration across compose.yaml and compose.override.yaml for dev-versus-prod parity
  • Drive the daily loop with docker compose up, logs -f, and exec
  • Add a healthcheck and depends_on so 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 clone then docker 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 -v resets you to a clean slate.
Without Docker each machine differs and deploys diverge; with Docker every environment is identical Without Docker Dev laptop Node 18 ยท macOS CI runner Node 20 ยท Linux Production Node 16 ยท Alpine โ†’ ๐Ÿ˜ฑ drift With Docker node:20-alpine image same everywhere dev ยท CI prod โœ… identical stack
The image is the contract. When every environment builds from the same base image, "works on my machine" becomes "works everywhere."

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.

graph TD A["Docker dev workflows"] --> B["1. Build and Run"] A --> C["2. Bind-Mount live edits"] A --> D["3. Compose multi-service"] A --> E["4. Dev Containers in the IDE"] B --> B1["Rebuild image every change
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.

Host source is bind-mounted into the container while an anonymous volume protects node_modules Host (your laptop) src/ ยท package.json (edit these live) no node_modules needed Container /app src/ ยท package.json node_modules anonymous volume ๐Ÿ”’ bind mount
The bind mount carries your source into the container; the anonymous volume carves out 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() });
});
sequenceDiagram participant U as You participant C as Compose participant DB as Postgres participant API as API U->>C: docker compose up C->>DB: start container loop until healthy C->>DB: run pg_isready check DB-->>C: not ready yet end DB-->>C: healthy C->>API: start container API->>DB: connect at db 5432 API-->>U: listening on port 3000

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*.json and install before copying source, to keep the dependency layer cached
  • Keep shared config in compose.yaml; put dev-only conveniences in compose.override.yaml
  • Protect the container's node_modules with 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 environment or an .env file that's git-ignored
  • Rely on compose.override.yaml loading in CI or prod โ€” pass -f explicitly 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_on means "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 Dockerfile so dependencies install before source is copied โ€” that keeps rebuilds fast
  • compose.override.yaml auto-merges over compose.yaml in dev; production uses explicit -f files
  • Bind-mount source for live edits, and shield node_modules with an anonymous volume
  • Talk to other services by service name, and use healthchecks to order startup

๐Ÿ“š Additional Resources

๐Ÿš€ 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.