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
- The goal and the development override
- Bind mounting the code and the
node_modulesproblem - Automatic process reloading
develop.watch: Compose's native mechanismdocker compose watchin action- Step-by-step debugging with VS Code
- Getting at the database
- Tests in throwaway containers
- Development data and resetting the environment
- Bind mount performance on macOS and Windows
- The team's shortcuts:
dev.shandMakefile
- 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
- Bind mounting the code and the
node_modules problem
node_modules problemA 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_moduleson 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.
| 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
- 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:
--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).
aurora-api-1 | Aurora API listening on port 3000
aurora-api-1 | Restarting 'src/server.js'
aurora-api-1 | Aurora API listening on port 3000A 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.
develop.watch: Compose's native mechanism
develop.watch: Compose's native mechanismCompose 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.htmlNotice 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.
docker compose watch in action
docker compose watch in actionWatch 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/htmlThe 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.
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.
- 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.
.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.
- 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:
- Tests in throwaway containers
The direct approach, already seen in lesson 04-03:
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 }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 0Three 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.
- 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:
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 seedSixty 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.
- 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.
- The team's shortcuts:
dev.sh and Makefile
dev.sh and MakefileNobody 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 seedTwo 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'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 2Aurora API operational and reloaded
c4f1a9e0b73d
aurora-api-1 | Restarting 'src/server.js'
aurora-api-1 | Aurora API listening on port 3000The 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.
| 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 -ltests-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
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
