Skip to content
Go back

Compose Watch: Faster Dev Loops

By SumGuy 11 min read
Compose Watch: Faster Dev Loops

Your Dev Loop Is Broken (And You Know It)

Edit code. Wait for the build. Restart the container. Refresh the browser. Stare at the screen. Repeat 47 times before lunch.

If you’re developing inside Docker Compose and not using compose watch, you’re living in 2020. And the thing is—you’ve probably got bind mounts set up, which kind of works for interpreted languages like Python and Node. Files sync instantly, and your app picks up the changes. Good enough, right?

Except when it’s not. When you need to rebuild the image. When your dependencies change. When you’ve got compiled code or a complex build step hiding inside your Dockerfile. When you add a new npm package and suddenly the watch isn’t fast enough because the import resolution changed.

Docker Compose Watch is the answer to this pain. It’s built into Docker Compose (since v2.22), it’s free, and it solves the “how do I develop inside containers without losing my mind” problem in a way that bind mounts alone can’t.

Here’s the thing: compose watch isn’t a replacement for bind mounts. It’s what you layer on top of them. And once you get it right, your dev loop goes from “edit → rebuild → restart → test” to “edit → test” in about two seconds.

The Problem With Vanilla Compose Dev Loops

Let’s be honest: developing with Docker Compose is a special kind of friction.

Without compose watch, your workflow looks like this:

Terminal window
$ # Edit your code
$ # Then do this:
$ docker compose down
$ docker compose up --build
$ # Wait 30 seconds for the image to rebuild
$ # App starts
$ # Test it
$ # Oh, you need to change one line
$ # Repeat

For small projects, that’s fine. But on a larger codebase—think a Node monorepo with 12 packages, or a Python project with a heavy build step—you’re losing minutes per iteration. Over a day of development, that’s half an hour gone. Over a sprint, it’s measured in hours.

Bind mounts help. You mount your source directory into the container, and file changes sync in real-time:

services:
app:
build: .
volumes:
- .:/app

Now you edit code, the container sees the changes instantly, and if you’re running an interpreted language (Python, Node, Ruby), the app reloads automatically. Much better.

But bind mounts have limits. They don’t rebuild your image when dependencies change. They don’t help with compiled languages. They don’t trigger custom setup scripts. And they can cause subtle permission issues on Linux if you’re not careful.

Enter compose watch. It lives between bind mounts and full rebuilds, and it’s smart about when to do what.

What Is Compose Watch, Actually?

compose watch is a feature in the develop block of your compose.yaml. It watches your filesystem and does one of three things:

  1. Sync the files into the running container (like a bind mount, but intentional and configurable)
  2. Rebuild the image (when dependencies change, for example)
  3. Rebuild and restart the service (the nuclear option, but sometimes necessary)

The magic is that you decide which files trigger which action. Change a source file? Sync it. Change requirements.txt? Rebuild. Change the Dockerfile? Rebuild and restart. Everything else? Ignore it.

Here’s the syntax:

services:
app:
build: .
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: ./requirements.txt
- action: sync+restart
path: ./config.yaml

The watch array is a list of rules. Each rule has:

Docker Compose watches both of these automatically and doesn’t require any explicit rules:

When to Sync vs. Rebuild vs. Sync+Restart

The three actions cover different scenarios. Knowing which to use is the key to a smooth dev loop.

Sync: For Source Code

Use sync when your app can pick up changes on the fly. This is the fast path—files copy into the running container in milliseconds, and your app reloads without restarting.

develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: sync
path: ./templates
target: /app/templates

Perfect for Python web apps (Flask, Django), Node servers (Express, Fastify), and any framework with hot reload built in.

Rebuild: For Dependencies and Compiled Code

Use rebuild when you’ve changed something that requires the image to be rebuilt. This triggers a docker compose build (not a full up --build), which is faster than a restart because the Compose daemon reuses layers and doesn’t halt the running container until it’s ready.

develop:
watch:
- action: rebuild
path: ./requirements.txt
- action: rebuild
path: ./package.json
- action: rebuild
path: ./Dockerfile

Add requirements.txt or package.json here if those files update during development (you added a dependency with pip install -e . or npm install, for example).

The Dockerfile itself doesn’t need an explicit rule—it always triggers a rebuild.

Sync+Restart: For Config Changes

Use sync+restart when you need to sync the file and restart the service cleanly. This is for configuration files, environment-specific settings, or anything that the app needs to re-read at startup.

develop:
watch:
- action: sync+restart
path: ./config.yaml
target: /app/config.yaml

The service will be gracefully stopped, the file synced, and the service restarted.

A Real Example: Node.js Development

Here’s a complete compose.yaml for a Node.js project with Compose Watch set up right:

compose.yaml
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: development
volumes:
- node_modules_cache:/app/node_modules
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: sync
path: ./public
target: /app/public
- action: rebuild
path: ./package.json
volumes:
node_modules_cache:

Notice the node_modules_cache volume? That’s important. You do not want to sync node_modules from your host into the container—it’ll be platform-specific and break instantly. Instead, keep it as a named volume so the container’s version stays isolated.

Here’s the Dockerfile to go with it:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

Now your workflow is:

Terminal window
$ docker compose up
# App starts and watches for changes
$ # Edit src/app.js
$ # File syncs in ~100ms
$ # Server reloads automatically (if you're using nodemon or similar)
$ # Browser hot-reloads
$ # Install a new package
$ npm install express-validator
$ # Compose detects package.json changed, rebuilds image
$ # 5-10 seconds later, container is running with the new deps

Much better than the rebuild-restart cycle.

A Real Example: Python Development

Here’s the same setup for a Python project using Flask or FastAPI:

compose.yaml
version: "3.9"
services:
app:
build: .
ports:
- "5000:5000"
environment:
FLASK_APP: app.py
FLASK_ENV: development
PYTHONUNBUFFERED: 1
volumes:
- venv_cache:/app/venv
develop:
watch:
- action: sync
path: ./app
target: /app/app
- action: sync
path: ./templates
target: /app/templates
- action: sync
path: ./static
target: /app/static
- action: rebuild
path: ./requirements.txt
volumes:
venv_cache:

And the Dockerfile:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /app/venv && \
/app/venv/bin/pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PATH="/app/venv/bin:$PATH"
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0"]

Same principle: sync source code, rebuild when dependencies change, keep the venv isolated in a volume.

Your dev loop:

Terminal window
$ docker compose up
# Flask app starts with auto-reload enabled
$ # Edit app/routes.py
$ # File syncs in ~100ms
$ # Flask detects change and reloads
$ # Test it instantly
$ # Add a new dependency to requirements.txt
$ # Compose detects the change, rebuilds the image
$ # 10-15 seconds later, new deps are installed and app restarts

Pitfalls: node_modules and venv

Here’s where most people get tripped up.

Do not sync node_modules or venv from your host into the container. These directories are platform-specific. Your Mac’s node_modules won’t work inside a Linux container. Your host’s venv won’t work either.

Instead, use named volumes:

volumes:
- node_modules_cache:/app/node_modules # For Node
- venv_cache:/app/venv # For Python

And add them to your .dockerignore:

node_modules
venv
__pycache__
.pytest_cache
.venv

This prevents them from being copied into the image during the build, saving space and avoiding conflicts.

If you’re doing bind-mount development without Compose Watch, you might mount the entire project directory:

volumes:
- .:/app

But with Compose Watch, you’re explicit about what syncs. That’s actually safer—you’re less likely to accidentally sync something that shouldn’t be there.

Ignored Paths

You can tell compose watch to ignore certain paths entirely by prefixing them with !:

develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- "**/*.test.js"
- "**/__pycache__"
- "**/.env.local"

Useful for excluding test files, cache directories, or local environment files that shouldn’t trigger a rebuild or sync.

Compose Watch vs. Bind Mounts

Let’s clarify the relationship here, because it’s a common source of confusion.

Bind mounts sync your entire host directory into the container. Everything flows through the filesystem, and your app sees changes instantly. They’re simple and they work, but they’re all-or-nothing—you can’t easily say “sync this file, rebuild for that file.”

Compose Watch is on top of bind mounts (or without them—you can use watch alone). It gives you fine-grained control over what happens when files change. Change a source file? Sync it. Change a dependency file? Rebuild. You’re in control.

In practice, most Compose Watch setups don’t use full bind mounts. They use named volumes to keep isolated dependencies (like node_modules or venv) and let Compose Watch handle the syncing of source code. This is actually more reliable than bind mounts because you avoid cross-platform issues.

If you’re using a Mac and developing a Docker Compose setup, bind mounts can be slow (Docker Desktop on Mac uses a VM, and file syncing over the VM boundary isn’t instant). Compose Watch doesn’t have that problem—it uses the native Docker daemon on your host to copy files, and it’s much faster.

Don’t Use Watch in Production

This is important: develop blocks are for local development only. They’re ignored when you deploy to production.

If you’re running docker compose up on a server, the develop block is skipped. Your production container doesn’t watch for file changes, which is correct—you want predictability, not file-sync chaos.

You can safely commit your compose.yaml with the develop block in place. It won’t affect production.

Running It

Once your compose.yaml is set up with develop.watch, it’s simple:

Terminal window
docker compose up

That’s it. Compose watches your filesystem, and changes are handled according to your rules.

If you want to watch without starting the services (just validate the setup), you can:

Terminal window
docker compose watch --no-up

This is useful for checking that your watch rules are valid before committing.

A Checklist for Your Next Project

Here’s what to do when you’re setting up a new Compose-based project:

  1. Write your Dockerfile and compose.yaml
  2. Add a develop block with watch rules:
    • Sync source code directories
    • Rebuild on dependency file changes
    • Use sync+restart for config files
  3. Use named volumes for dependencies (node_modules, venv, etc.)
  4. Add ignored patterns to .dockerignore
  5. Run docker compose up and edit some code

You’ll be amazed at how fast the feedback loop becomes.

The Payoff

The time you save compounds. An extra 20 seconds per edit, multiplied by 40 edits a day, is over 13 minutes gone. Over a week, it’s an hour. Over a year, it’s a work week.

Compose Watch gets you back those minutes. It’s the difference between “developing in Docker” and “developing with Docker”—meaning Docker is helping you, not slowing you down.

Set it up once, commit it to the repo, and every developer on your team gets the same fast feedback loop. That’s developer experience done right.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Riemann: The Forgotten Event-Stream Monitor for Home Labs
Next Post
Glances vs Netdata: Two Free-Tier Monitors Compared

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts