Skip to content
Codeloom
Docker

Docker Compose Watch: Hot Reload for Containers

Learn how to use Docker Compose Watch to automatically sync file changes and rebuild containers during development. Faster feedback loops without manual restarts.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • What Docker Compose Watch is and how it works
  • Configuring sync, rebuild, and sync+restart actions
  • Setting up watch for Node.js, Python, and Go projects
  • Comparing watch to bind mounts and other solutions

Prerequisites

  • Docker Compose v2.22 or later
  • Basic Docker Compose knowledge

Developing inside containers has always had one major friction point: the feedback loop. You change a file, then you have to rebuild the image, recreate the container, and wait. Bind mounts helped, but they come with their own problems around file permissions, performance on macOS, and leaking host-specific paths into your configuration. Docker Compose Watch, introduced in Compose v2.22, gives you a better alternative. It watches your source files and automatically syncs changes, restarts services, or triggers rebuilds depending on what changed.

How Compose Watch Works

Compose Watch monitors your host filesystem for changes and takes one of three actions:

  • sync: Copies the changed file directly into the running container without restarting it. Best for interpreted languages and static assets.
  • rebuild: Triggers a full image rebuild and container replacement. Best for changes to dependency files like package.json or requirements.txt.
  • sync+restart: Syncs the file into the container and then restarts the service. Best for compiled languages or configuration files that need a process restart to take effect.

You configure these actions in your compose.yaml under a develop.watch section for each service.

Starting Compose Watch

docker compose watch

This starts all services defined in your compose.yaml and begins watching for file changes. The command runs in the foreground and logs sync/rebuild events as they happen:

watching: app
syncing: src/index.ts -> /app/src/index.ts
syncing: src/routes/api.ts -> /app/src/routes/api.ts
rebuilding: service "app" (package.json changed)

You can also start watch alongside your regular services:

docker compose up --watch

Configuring Watch for Node.js

Here is a typical setup for a Node.js application using TypeScript and nodemon:

services:
  app:
    build:
      context: .
      target: development
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: ./package.json
        - action: rebuild
          path: ./package-lock.json

The corresponding multi-stage Dockerfile:

FROM node:20-alpine AS base
WORKDIR /app

FROM base AS development
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npx", "nodemon", "--watch", "src", "src/index.ts"]

FROM base AS production
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

With this setup:

  • Editing any file in src/ syncs it into the container instantly. Nodemon detects the change and restarts the Node process.
  • Changing package.json or package-lock.json triggers a full rebuild so new dependencies are installed.

Configuring Watch for Python

For a Python Flask or FastAPI application:

services:
  api:
    build:
      context: .
    ports:
      - "8000:8000"
    develop:
      watch:
        - action: sync
          path: ./app
          target: /code/app
        - action: rebuild
          path: ./requirements.txt
        - action: sync+restart
          path: ./config.yaml
          target: /code/config.yaml
FROM python:3.12-slim
WORKDIR /code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Here, --reload in uvicorn handles the process-level restart when Python files change. The sync action just gets the files into the container. For config.yaml, we use sync+restart because the config is only read at startup.

Configuring Watch for Go

Go requires compilation, so the strategy differs:

services:
  api:
    build:
      context: .
      target: dev
    ports:
      - "8080:8080"
    develop:
      watch:
        - action: sync+restart
          path: ./cmd
          target: /app/cmd
        - action: sync+restart
          path: ./internal
          target: /app/internal
        - action: rebuild
          path: ./go.mod
        - action: rebuild
          path: ./go.sum
FROM golang:1.22-alpine AS dev
WORKDIR /app
RUN go install github.com/air-verse/air@latest
COPY go.mod go.sum ./
RUN go mod download
COPY . .
CMD ["air", "-c", ".air.toml"]

FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/server /bin/server
ENTRYPOINT ["/bin/server"]

The air tool watches for file changes inside the container and recompiles the Go binary. Compose Watch syncs the changed source files in, and air handles the rebuild and restart.

Ignoring Files

You do not want every file change to trigger a sync. Use the ignore field to exclude paths:

services:
  app:
    build:
      context: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
          ignore:
            - "**/*.test.ts"
            - "**/*.spec.ts"
            - "**/__tests__/**"
            - "**/node_modules/**"
        - action: rebuild
          path: ./package.json

The ignore patterns use standard glob syntax. This prevents test file edits from triggering unnecessary syncs that could slow down feedback.

Watch with Multiple Services

A typical full-stack application has a frontend, backend, and database:

services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "5173:5173"
    develop:
      watch:
        - action: sync
          path: ./frontend/src
          target: /app/src
        - action: rebuild
          path: ./frontend/package.json

  backend:
    build:
      context: ./backend
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy
    develop:
      watch:
        - action: sync
          path: ./backend/src
          target: /app/src
        - action: rebuild
          path: ./backend/package.json

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

Running docker compose watch monitors both frontend and backend source directories simultaneously. The database service has no watch configuration because it uses a pre-built image.

Watch vs Bind Mounts

You might wonder why not just use bind mounts like before:

# The old way - bind mounts
services:
  app:
    build: .
    volumes:
      - ./src:/app/src

Bind mounts have real drawbacks:

  1. Performance on macOS and Windows: File system events cross the VM boundary, causing significant latency. On large projects, this can make your application noticeably slow.
  2. Permission issues: The UID/GID inside the container may not match your host user, leading to permission denied errors or files owned by root on your host.
  3. Host path leakage: Your compose.yaml contains absolute or relative host paths that differ across team members’ machines.
  4. No selective action: Every file change is immediately visible. You cannot trigger a rebuild for package.json while syncing src/ files.

Compose Watch solves all of these. It copies files using a high-performance sync mechanism, handles permissions internally, and lets you define different actions for different file patterns.

Debugging Watch Issues

Files Not Syncing

Check that your path matches the actual location:

# Verify the watch configuration
docker compose config | grep -A 10 watch

# Check container filesystem
docker compose exec app ls -la /app/src

Rebuild Loops

If a rebuild triggers file changes that cause another rebuild, add the generated files to the ignore list:

develop:
  watch:
    - action: sync
      path: ./src
      target: /app/src
      ignore:
        - "**/dist/**"
        - "**/.cache/**"

Slow Syncs

For large projects, limit the watch scope to only the directories that matter:

# Instead of watching the entire project
- action: sync
  path: .
  target: /app

# Watch specific directories
- action: sync
  path: ./src
  target: /app/src
- action: sync
  path: ./public
  target: /app/public

Performance Tips

  1. Keep watch paths narrow. Watching ./src is faster than watching . because there are fewer files to monitor.
  2. Use ignore patterns aggressively. Exclude test files, build outputs, and IDE-specific directories.
  3. Prefer sync over rebuild. Rebuilds are slow because they rebuild the entire image. Only use rebuild for files that truly require it, like dependency manifests.
  4. Combine with in-container watchers. Use nodemon, uvicorn reload, or air inside the container to handle the process restart after sync delivers the files. This is faster than sync+restart which restarts the entire container.

Wrapping Up

Docker Compose Watch brings hot-reload ergonomics to containerized development without the downsides of bind mounts. Configure sync for source files, rebuild for dependency changes, and sync+restart for configuration files. Pair it with in-container watchers like nodemon or air for the fastest feedback loop. The setup takes a few minutes to add to your compose.yaml and eliminates the manual rebuild cycle that slows down container-based development.