open atlas
↑ Back to track
Docker, containers as a system DOCK · 06 · 03

The dev loop: overrides, watch/sync, profiles, and not drifting from prod

A productive Compose dev loop layers a base file with an override for live reload (develop.watch sync/rebuild), gates optional services behind profiles, and treats the compose file as the contract that keeps dev from drifting away from prod.

DOCK Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The bug only existed in production, and it existed because of a kindness in the dev setup. To make the inner loop fast, the team had bind-mounted the host source directory straight over the container’s app directory — edit a file, the change appears instantly inside the container, no rebuild. It worked beautifully for months. Then a dependency was added to package.json, installed locally, and the app ran fine on every developer’s laptop. CI built the image cleanly. Production crashed on boot with a missing-module error. The cause was the bind mount: it had shadowed the container’s node_modules — built during docker build with the right architecture and the full dependency tree — with the host’s node_modules, which happened to already contain the new package because someone had run npm install on the host. Dev was not running the image CI built; it was running the host’s files draped over a container shell. The Dockerfile’s install step had been dead code for months, never exercised, silently rotting, until prod ran the real image and found the install was wrong. The fix kept the fast loop but stopped lying about it: a named volume for node_modules so the image’s install survived the bind mount, and later a move to Compose’s watch so the container ran what was built and syncs were explicit.

Override files: one base, many environments

Compose automatically merges two files when present: compose.yaml (the base, shared, prod-shaped definition) and compose.override.yaml (local-only tweaks), with the override deep-merged on top of the base. This is the mechanism that lets one canonical file describe the topology while local concerns — a debug port, a bind mount, a relaxed log level — live in a separate file you can even gitignore. The merge rules have a senior subtlety: mappings merge, but most sequences replace. Two environment maps combine key-by-key; but a command: or entrypoint: in the override replaces the base entirely, and list-valued fields like ports: are concatenated for ports specifically while many other lists override. The safe mental model: scalars and the override’s keys win, maps deep-merge, and anything list-shaped you should assume replaces unless you have checked. You can also be explicit with -f base.yaml -f dev.yaml to control exactly which files merge and in what order — order matters, later files win.

# compose.yaml — base, prod-shaped, no source mounts, no debug ports
services:
  api:
    build: .
    environment:
      LOG_LEVEL: info
    ports:
      - "8080:8080"

# compose.override.yaml — local dev only, deep-merged on top
services:
  api:
    environment:
      LOG_LEVEL: debug      # merges into the base environment map
    ports:
      - "9229:9229"          # debugger port, concatenated with 8080
    develop:
      watch:
        - action: sync       # copy changed files into the running container
          path: ./src
          target: /app/src
        - action: rebuild    # changed files here force an image rebuild
          path: ./package.json

watch/sync vs bind mounts: live reload without the lie

The old fast loop was a bind mount of host source over the container path. It is instant but it is exactly the trap from the Hook: the host directory replaces the container’s directory, so anything the image built into that path — node_modules, compiled assets, a venv — is hidden behind host files, and dev silently stops running what the image contains. Compose’s develop.watch is the modern alternative and it inverts the model: instead of mounting the host over the container, it watches host paths and reacts to changes. The sync action copies changed files into the running container (typically sub-second, ~100–300ms for a small file) without shadowing the image’s build output, so a hot-reloader inside the container picks them up — fast loop, but the container still runs its own built dependencies. The rebuild action is for changes that cannot be hot-patched: edit package.json or the Dockerfile and watch rebuilds the image and recreates the container (seconds to tens of seconds depending on cache). The sync+restart variant copies then restarts the process for code that needs a clean restart rather than hot reload. The discipline this buys: dev runs the actual image with explicit, visible sync of source — the install step is exercised on every dependency change instead of rotting.

Quiz

A team bind-mounts host ./ over /app for instant edits. After adding a dependency, the app works locally but crashes in prod with a missing module. What did the bind mount do?

Why this works

Why does watch beat a bind mount even though both give a fast loop? Because they differ in what dev actually executes. A bind mount makes dev run host files over a container, so the container’s own build output — its installed dependencies, its compiled artifacts — is hidden, and the image build becomes untested until something downstream runs the real image. Watch makes dev run the built image and copies your source changes into it; the dependency install still happens in the image and is exercised on every rebuild. The bind mount optimizes the loop by removing the image from the loop; watch optimizes the loop while keeping the image in it. The first is faster to set up and quietly creates dev/prod drift; the second costs a little config and keeps dev honest. The Hook’s missing-module crash is the bill for the first option, paid in prod.

Profiles: optional services off by default

A large stack often carries services not everyone needs every day — a metrics stack, a mail-catcher, a seeded test fixture, an admin UI. Putting them in the base file means everyone pays their startup cost and resource footprint on every up. Profiles gate a service so it is only started when its profile is explicitly activated: a service tagged profiles: ["debug"] does not start on a plain compose up; it starts only with --profile debug or when named directly. Services with no profiles key are always started — that is your core set. The senior gotcha is dependency interaction: if an always-on service depends_on a profiled service, the profiled one is pulled in implicitly to satisfy the dependency, which can surprise you by starting something you thought was gated. Profiles keep the default up lean — boot the three services you actually need in a couple of seconds — while keeping the optional eight one flag away, all in the same file. The alternative, separate compose files per scenario, fragments the topology and reintroduces drift; profiles keep one file as the single source of truth.

Quiz

A metrics service is tagged profiles: [observability] so it stays off by default. A developer runs plain compose up and is surprised it starts anyway. What is the most likely cause?

Recall before you leave
  1. 01
    Why does a bind mount of host source over the container path cause dev/prod drift, and how does Compose watch avoid it?
  2. 02
    Explain Compose override merging and profiles, including the gotcha each carries.
Recap

A good local loop optimizes speed without quietly creating dev/prod drift. Start from one prod-shaped base file and layer a deep-merged compose.override.yaml for local concerns — debug ports, relaxed logging, watch config — remembering the merge asymmetry: maps combine key-by-key while list-shaped fields like command and entrypoint replace and ports concatenate, so assume a list replaces unless you have checked. For live reload, prefer Compose’s develop.watch over a bind mount: a bind mount overlays host files onto the container and shadows whatever the image built there, so dev runs host files over a shell and the image’s install step rots untested until prod runs the real image and crashes on a missing module — the Hook’s bill. Watch instead runs the built image and reacts to host changes: sync copies source in at ~100-300ms without shadowing, rebuild fires when package.json or the Dockerfile changes, and sync+restart bounces the process when hot reload is not enough — so the fast loop keeps the image, and the install is exercised on every dependency change. Finally, profiles gate optional services off the default up to keep boot lean, with one gotcha: an always-on depends_on can implicitly pull a profiled service in. Across all three, the throughline is that the compose file stays the single source of truth, and the dev conveniences are layered on top rather than forking the topology. Now when you open a repo and see a bind mount of ./ over /app, you know exactly what hidden drift to look for — and which two lines of watch config will fix it without slowing the loop.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.