Bringing the platform up with one command is already a win. But if seeing the effect of changing one line of server.js means rebuilding the image and recreating the container, nobody will use Docker to write code: they will end up running Node on the host and we will be back to the fifteen steps.

This lesson turns the development compose.override.yaml into a daily working environment: you edit in your editor, the change shows up instantly, you step through the debugger, you run isolated tests and you reset the environment with one command. It is the last lesson of module 4.

Contents

  1. The goal and the development override
  2. Bind mounting the code and the node_modules problem
  3. Automatic process reloading
  4. develop.watch: Compose's native mechanism
  5. docker compose watch in action
  6. Step-by-step debugging with VS Code
  7. Getting at the database
  8. Tests in throwaway containers
  9. Development data and resetting the environment
  10. Bind mount performance on macOS and Windows
  11. The team's shortcuts: dev.sh and Makefile

  1. The goal and the development override

The cycle we are after is: save the file in the editor → the container's process restarts on its own → reload the browser. No build, no up, no waiting. Everything needed lives in compose.override.yaml, which Compose loads automatically locally (lesson 04-06) and which is never used on the server.

# compose.override.yaml — Aurora Libros development environment
services:

  aurora-api:
    build:
      context: ./api
      target: development         # Dockerfile stage with the devDependencies
    command: ["node", "--watch", "--inspect=0.0.0.0:9229", "src/server.js"]
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug
    ports:
      - "3000:3000"
      - "9229:9229"               # Node inspector
    volumes:
      - ./api/src:/app/src        # ONLY the code, not the whole project
      - /app/node_modules         # anonymous volume that protects the dependencies
    restart: "no"

  aurora-db:
    ports:
      - "127.0.0.1:5432:5432"
    networks: [backend, frontend] # needed in order to publish the port

  1. Bind mounting the code and the node_modules problem

A bind mount makes the host directory replace the container's. And there lies the classic trap: if you mount the whole project (./api:/app), the host's node_modules covers up the one the image installed during docker build.

The consequences are the kind that ruin an afternoon:

  • If you have no node_modules on the host, the container ends up with no dependencies: Error: Cannot find module 'express'.
  • If you do have it, those are the ones your operating system and your Node version installed. Any package with native binaries compiled for macOS will not work inside an Alpine image.
docker compose exec aurora-api ls node_modules | head -3
ls: node_modules: No such file or directory
Option How Advantages Drawbacks
Mount only src - ./api/src:/app/src Simple and explicit; node_modules untouched Changing package.json requires a rebuild
Anonymous volume on top - ./api:/app + - /app/node_modules You can mount the whole project The volume goes stale when dependencies change
node_modules outside the tree ENV NODE_PATH=/deps/node_modules in the Dockerfile Collisions become impossible Requires touching the Dockerfile
Install on the host A local npm install Nothing to configure Breaks Docker's premise: host binaries

The second option deserves an explanation, because the mechanism is not obvious: the more specific mount wins. Docker mounts ./api over /app and, on top of it, an anonymous volume over /app/node_modules, which on first creation is initialized with whatever the image already had there. The result is host code and image dependencies.

For Aurora Libros we use the first option, which is the most predictable. And the rule that avoids 90% of the problems: when you change package.json, rebuild.

docker compose up -d --build aurora-api
docker compose down -v && docker compose up -d --build   # if you use an anonymous volume

  1. Automatic process reloading

Mounting the code is not enough: the node process keeps running whatever it loaded at startup. Node 22 ships the solution out of the box:

    command: ["node", "--watch", "src/server.js"]

--watch watches the imported files and restarts the process when it detects a change. You no longer need nodemon, though it is still valid if you need its advanced options (--watch-path, delays, pre-run commands).

docker compose logs -f aurora-api
# in another terminal, edit api/src/server.js and save
aurora-api-1  | Aurora API listening on port 3000
aurora-api-1  | Restarting 'src/server.js'
aurora-api-1  | Aurora API listening on port 3000

A warning about filesystems: propagating inotify events through a bind mount does not always work on macOS and Windows. If --watch does not react, use --watch-preserve-output with polling, or the mechanism in the next section, which does not depend on inotify inside the container.

  1. develop.watch: Compose's native mechanism

Compose includes a synchronization system of its own. The key difference: it watches from the host, not from inside the container, so it works the same on Linux, macOS and Windows.

    develop:
      watch:
        - action: sync                 # copy files into the container
          path: ./api/src
          target: /app/src
          ignore:
            - "**/*.test.js"
        - action: sync+restart         # copy and restart the container
          path: ./api/config
          target: /app/config
        - action: rebuild              # rebuild the whole image
          path: ./api/package.json
Action What it does When to use it
sync Copies the changed files into the container Source code, with hot reloading in the process
sync+restart Copies and restarts the container Configuration that is read at startup
rebuild Rebuilds the image and recreates the container package.json, Dockerfile, dependencies
restart Only restarts, no copying Changes that already got inside some other way

The complete block for Aurora Libros:

# compose.override.yaml
services:
  aurora-api:
    build: { context: ./api, target: development }
    command: ["node", "--watch", "--inspect=0.0.0.0:9229", "src/server.js"]
    ports: ["3000:3000", "9229:9229"]
    develop:
      watch:
        - action: sync
          path: ./api/src
          target: /app/src
        - action: rebuild
          path: ./api/package.json
        - action: rebuild
          path: ./api/Dockerfile

  aurora-web:
    develop:
      watch:
        - action: sync+restart
          path: ./web/nginx.conf
          target: /etc/nginx/conf.d/default.conf
        - action: sync
          path: ./web/index.html
          target: /usr/share/nginx/html/index.html

Notice how the work is divided: the HTML page only needs copying (sync), but nginx.conf is read at startup, so it demands sync+restart. And package.json cannot be resolved by copying: it has to be rebuilt.

  1. docker compose watch in action

docker compose watch
Watch enabled
 ⦿ Syncing "aurora-api" 1 file to /app/src
aurora-api-1  | Restarting 'src/server.js'
 ⦿ Rebuilding service "aurora-api" after changes were detected in package.json
 ✔ Container aurora-libros-aurora-api-1  Recreated
 ⦿ Syncing "aurora-web" 1 file to /usr/share/nginx/html

The command keeps your terminal busy showing the synchronization live. With --no-up it does not bring the stack up, it only watches what is already running; and if you prefer to see it alongside the logs, docker compose up --watch combines both.

docker compose watch --no-up
docker compose up --watch

An under-discussed advantage of watch over a bind mount: you can use the same Dockerfile and the same image as in production, because the code is not mounted, it is copied. That eliminates a whole class of differences between "it works on my machine" and the server.

  1. Step-by-step debugging with VS Code

With --inspect=0.0.0.0:9229, Node opens its inspector. The 0.0.0.0 is not optional: by default it listens only on the container's 127.0.0.1, unreachable from the host.

docker compose logs aurora-api | grep -i debugger
aurora-api-1  | Debugger listening on ws://0.0.0.0:9229/8f2c...

.vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Aurora API (Docker)",
      "type": "node",
      "request": "attach",
      "address": "localhost",
      "port": 9229,
      "localRoot": "${workspaceFolder}/api/src",
      "remoteRoot": "/app/src",
      "restart": true,
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

The two keys almost everybody gets wrong:

Key Value Why it matters
localRoot Path of the code on your machine Translates the breakpoint paths
remoteRoot Path of the code in the container Without the mapping, breakpoints come out "unverified"
restart true Reattaches the debugger after each --watch restart

Set a breakpoint in the /books handler, launch the configuration and run curl -s http://localhost:8080/api/books. Execution stops and you can inspect variables, the stack and the console, with the process running inside the container, with its memory limits and its network.

Security warning: port 9229 grants arbitrary code execution. Publish it locally only ("127.0.0.1:9229:9229") and never on a server.

  1. Getting at the database

Two routes, and it is worth having both:

# From the host, with your favorite client (requires the port published by the override)
psql -h 127.0.0.1 -p 5432 -U aurora -d aurora_books

# Without installing anything: the psql that already lives in the container
docker compose exec aurora-db psql -U aurora -d aurora_books
docker compose exec aurora-db psql -U aurora -d aurora_books -c "\dt"
docker compose exec aurora-cache redis-cli KEYS 'books:*'
              List of relations
 Schema | Name  | Type  |  Owner
--------+-------+-------+--------
 public | books | table | aurora
1) "books:all"

Remember from lesson 04-04 that aurora-db is on the backend network, which is internal: in order to publish its port, the override also adds it to frontend. And for quick dumps, -T is mandatory:

docker compose exec -T aurora-db pg_dump -U aurora aurora_books > ~/aurora-backup.sql

  1. Tests in throwaway containers

The direct approach, already seen in lesson 04-03:

docker compose run --rm --no-deps aurora-api npm test

But integration tests need a real, disposable database. A dedicated service with its PostgreSQL on tmpfs —that is, in RAM— solves both things: total isolation and a speed disk cannot match.

# compose.tests.yaml
name: aurora-tests

services:
  test-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: aurora
      POSTGRES_PASSWORD: tests
      POSTGRES_DB: aurora_tests
    tmpfs:
      - /var/lib/postgresql/data      # ALL in RAM: it evaporates on stop
    command: ["postgres", "-c", "fsync=off", "-c", "full_page_writes=off"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_tests"]
      interval: 2s
      retries: 15

  tests:
    build: { context: ./api, target: development }
    command: ["npm", "run", "test:integration"]
    environment:
      DB_HOST: test-db
      DB_USER: aurora
      DB_PASSWORD: tests
      DB_NAME: aurora_tests
      NODE_ENV: test
    depends_on:
      test-db: { condition: service_healthy }
docker compose -f compose.tests.yaml up --abort-on-container-exit --exit-code-from tests
tests-1  | ✔ GET /books returns the full catalog (48ms)
tests-1  | ✔ GET /books/:id returns a title (11ms)
tests-1  | ✔ POST /books rejects a duplicate ISBN (23ms)
tests-1  | ℹ pass 12  fail 0
tests-1 exited with code 0

Three pieces fitted together: --abort-on-container-exit stops everything as soon as the tests finish, --exit-code-from tests makes the command return the tests' exit code (indispensable in CI), and fsync=off is acceptable only because this data is disposable; in production it would be madness.

  1. Development data and resetting the environment

The seed service from the data profile (lesson 04-06) loads an expanded fictitious catalog on top of the nine titles from init.sql:

docker compose --profile data run --rm seed
Inserted 40 demo titles. Catalog: 49 books.

And the full reset, the maneuver you will perform most often when something gets tangled:

docker compose down -v && docker compose up -d --wait && \
  docker compose --profile data run --rm seed

Sixty seconds and you have an environment identical to the rest of the team's. Here -v is correct and desirable: this is development data generated from versioned files. It is the one context in which that command should not scare you.

  1. Bind mount performance on macOS and Windows

On Linux, a bind mount is a kernel operation: zero cost. On macOS and Windows, Docker runs inside a lightweight virtual machine and every read crosses a boundary between filesystems. With node_modules —tens of thousands of tiny files— the difference is very noticeable.

Platform Situation Mitigation
Linux Native, no penalty None needed
macOS Host ↔ VM translation VirtioFS enabled in Docker Desktop (the default since 2023)
Windows + WSL 2 Fast if the code is inside WSL Keep the repository in ~/projects, never in /mnt/c/...
Windows without WSL 2 Very slow Migrate to WSL 2
Any Shared node_modules Do not mount it: anonymous volume or watch's sync

The most frequent performance mistake on Windows is keeping the repository in C:\Users\... and accessing it from WSL through /mnt/c/: every require() crosses two translation layers. Moving the project onto the Linux filesystem multiplies the speed tenfold.

The :cached and :delegated options you will see in old documentation were consistency settings for the old osxfs. Today they are accepted and ignored: VirtioFS has made them obsolete. And the best mitigation is still a structural one: docker compose watch copies instead of mounting, so it avoids the problem at the root.

  1. The team's shortcuts: dev.sh and Makefile

Nobody remembers docker compose -f compose.yaml -f compose.tests.yaml up --abort-on-container-exit --exit-code-from tests. Put it in a file and document the environment while you are at it:

# Makefile — Aurora Libros shortcuts
.PHONY: up down logs sh psql test seed reset watch

up:            ## Brings the platform up and waits until it is healthy
	docker compose up -d --build --wait

down:          ## Stops the platform (keeps the data)
	docker compose --profile tools down

logs:          ## Follows the logs of every service
	docker compose logs -f --tail 100

watch:         ## Development with automatic synchronization
	docker compose watch

sh:            ## Shell inside the API
	docker compose exec aurora-api sh

psql:          ## PostgreSQL console
	docker compose exec aurora-db psql -U aurora -d aurora_books

test:          ## Integration tests with a throwaway database
	docker compose -f compose.tests.yaml up \
		--abort-on-container-exit --exit-code-from tests

seed:          ## Loads the demo catalog
	docker compose --profile data run --rm seed

reset:         ## DELETES the data and rebuilds the environment from scratch
	docker compose down -v
	docker compose up -d --build --wait
	docker compose --profile data run --rm seed
make up
make watch
make reset

Two non-negotiable details: in a Makefile indentation is done with a tab, and the destructive target is called reset —not clean, not down— so that nobody types it out of habit. If you prefer a dev.sh, the idea is the same: the team's commands, in Git, next to the code.

Common Mistakes and Tips

Mounting the whole project over /app without protecting node_modules. The host's node_modules covers the image's and you get modules that do not exist or binaries from another platform.

Changing package.json and expecting a save to be enough. Installing dependencies requires a rebuild: up -d --build or a rebuild rule in develop.watch.

Using --inspect without 0.0.0.0. The inspector listens only inside the container and VS Code cannot connect.

Leaving 9229 published on every interface. That is remote code execution. 127.0.0.1:9229:9229 and locally only.

Putting development conveniences in the base compose.yaml. A build or a debugging port eventually sneaks onto the server.

Working from /mnt/c/ under WSL 2. Performance falls off a cliff. The repository belongs on the Linux filesystem.

Tip: if the automatic restart does not fire, the diagnostic order is: is the file really inside the mounted or watched path? (docker compose exec aurora-api ls -la src/), is the process started with --watch? (docker compose exec aurora-api ps aux), and if you are on macOS or Windows, switch to docker compose watch, which does not depend on inotify inside the container.

Exercises

Exercise 1. Mount ./api/src in aurora-api, start it with node --watch and demonstrate the full cycle: change the /health message in your editor and check with curl that the response changes without rebuilding or recreating the container. Verify as well that the container is the same one as before.

Exercise 2. Configure develop.watch with the three actions (sync for src, sync+restart for a configuration file and rebuild for package.json), launch docker compose watch and trigger all three. Explain what you observe in each case and why each file needs a different action.

Exercise 3. Set up the test environment with PostgreSQL on tmpfs, run it twice in a row and prove that (a) the second run starts from a completely clean database and (b) the command returns the tests' exit code, not the database container's.

Solutions

Solution 1.

docker compose up -d --build
docker inspect --format '{{.Id}}' aurora-libros-aurora-api-1 | cut -c1-12
curl -s http://localhost:3000/health | jq -r '.message'
c4f1a9e0b73d
Aurora API operational

Edit api/src/server.js, changing the message to "Aurora API operational and reloaded", and save:

sleep 2
curl -s http://localhost:3000/health | jq -r '.message'
docker inspect --format '{{.Id}}' aurora-libros-aurora-api-1 | cut -c1-12
docker compose logs aurora-api --tail 2
Aurora API operational and reloaded
c4f1a9e0b73d
aurora-api-1  | Restarting 'src/server.js'
aurora-api-1  | Aurora API listening on port 3000

The container ID is identical: nothing was recreated. All that happened is that the node process inside the container restarted on detecting a change in a file that, thanks to the bind mount, is the very same inode as the one in your editor. That is the development cycle we were after: two seconds between saving and seeing the effect.

Solution 2.

docker compose watch
Change triggered Output observed Why that action
Editing src/server.js Syncing "aurora-api" 1 file and Restarting 'src/server.js' The file only needs copying; the reload is done by --watch
Editing config/settings.json Syncing ... Restarting service Configuration is read at startup: copying is not enough
Editing package.json Rebuilding service "aurora-api" and Container ... Recreated A new dependency requires npm install, which only happens at build time

The progression goes from lowest to highest cost: sync takes milliseconds, sync+restart a few seconds, rebuild tens of seconds. That is why you should reserve rebuild for the files that genuinely need it —package.json, package-lock.json, Dockerfile— and not point it at a whole directory: a rebuild rule over ./api would rebuild the image every time you touch a line of code.

Solution 3.

docker compose -f compose.tests.yaml up --abort-on-container-exit --exit-code-from tests
echo "first run exit code: $?"
docker compose -f compose.tests.yaml down
docker compose -f compose.tests.yaml up --abort-on-container-exit --exit-code-from tests
echo "second run exit code: $?"
docker volume ls --filter name=aurora-tests --format "{{.Name}}" | wc -l
tests-1  | ℹ pass 12  fail 0
first run exit code: 0
tests-1  | ℹ pass 12  fail 0
second run exit code: 0
0

(a) The second run gives exactly the same results and there is no volume at all: the tmpfs lives in the host's memory and disappears when the container is removed, so every run starts from a freshly initialized database. That is the property that makes integration tests trustworthy: if run number fifty can fail because of leftovers from run forty-nine, the tests are worthless.

(b) --exit-code-from tests is what makes this usable in CI. Without that option, up returns the code of the first container to finish, which may be the database shutting down cleanly with a cheerful 0 while the tests were failing. Check it by making a test fail on purpose: the code becomes 1 and the pipeline goes red, which is exactly what should happen.

Conclusion

Docker has stopped being something you use "at the end, to package things" and has become the environment where you write code. You know how to mount the code with a bind mount and how to dodge the eternal node_modules problem —the host's covering the image's— with the four possible strategies and their golden rule: if package.json changes, you have to rebuild. You have automatic reloading with Node 22's native --watch, and above it Compose's own mechanism, develop.watch, with its actions graded by cost: sync for code, sync+restart for configuration read at startup and rebuild for dependencies, watching from the host and working the same on all three platforms.

You debug properly: --inspect=0.0.0.0:9229, the port published on 127.0.0.1 only, and a launch.json with the localRoot/remoteRoot mapping that makes breakpoints stop in code running inside the container. You get into the database from the host or with docker compose exec, you run integration tests against a throwaway PostgreSQL on tmpfs with --abort-on-container-exit and --exit-code-from, you load fictitious data with a seeding service and you reset the whole environment in one command. And you understand the reason for the slowness on macOS and Windows —the boundary between the host and the virtual machine— along with its real mitigations: VirtioFS, keeping the code inside the WSL 2 filesystem and, above all, not mounting node_modules. All of it summarized in a versioned Makefile anyone on the team can read.

And with that, module 4 closes. You started with fifty lines of chained imperative commands, fragile and unversioned, and you finish with the entire platform declared in a text file: four services plus a migration, two segmented networks with the data zone isolated from the outside, a persistent volume, health probes that distinguish "started" from "ready", dependencies that respect that nuance, resource limits, restart policies, parameterized variables with their versioned .env.example and their secrets out of the environment, profiles for the optional tooling, and overrides that make the same project work on your laptop, in CI and on the server. All of it reviewable in a pull request, all of it reproducible. The fifteen onboarding steps from lesson 01-07 are today exactly two: git clone and docker compose up -d --wait.

From here on, the course changes register. So far you have learned to use Docker very well; in module 5 you are going to open the black box and understand why it works the way it does: networking from the inside, with its drivers, routing tables and iptables rules; storage in depth, with drivers and backup strategies; real security, with unprivileged users, kernel capabilities, seccomp and vulnerability scanning; image optimization, where those 274 MB of PostgreSQL and those 142 MB of the API go on a diet with multi-stage builds; BuildKit and Buildx, with remote caching and multi-architecture builds; logging and monitoring of a running platform; and, at the end, the Linux kernel namespaces, cgroups and layers that have been holding up everything you have done since the very first lesson. When you finish that module, you will no longer be someone who knows how to write a compose.yaml: you will be someone who knows exactly what happens when they run it.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved