Mert Özen Further With Every Line
DevOps Docker Compose — #04

A Development Environment: Compose Watch, Hot Reload and Deciding What Runs in a Container

Mert Özen Nov 14, 2026 10 min 14 views
A Development Environment: Compose Watch, Hot Reload and Deciding What Runs in a Container

Compose Watch carries host changes into the running container through develop.watch rules: sync copies files and is enough for tools with hot reload, rebuild builds the image again and recreates the container. We compare dependencies-in-Compose/app-in-the-IDE, everything-in-containers and Dev Containers, and look at a 5-second rebuild for Bookstore.Api, sync for the Angular dev server and the delete-permission trap with a non-root user, all with real output.

Version note: The examples in this post were run with Docker Compose 5.7.1, Docker Engine 29.9.3, .NET SDK 10.0.404, Angular 22.2 (Angular CLI 22.2.7, Node.js 26) and SQL Server 2025.

Guess First

You changed a log message in Bookstore.Api's Program.cs. With the flow so far, you had to type docker compose up -d --build api for the change to show up in the container. With Compose Watch on, how many seconds after saving the file do you see the new log line: 5, 30 or 60? Second question: Angular's dev server runs in a container, as a non-root user. When you delete a file on the host, is its copy in the container deleted too?

Which Part Should Run in a Container?

There are three models for a development environment, and none of them is right for every team.

(a) Only the dependencies in Compose, the application in the IDE. SQL Server, Redis, Mailpit run in containers; Bookstore.Api runs on the host in Visual Studio or with dotnet run, bookstore-web with ng serve. Breakpoints, hot reload and the profiler work by default; debugging is fastest here. The price: the right versions of the .NET SDK and Node.js must be installed on the host, and the application does not run in exactly the same environment as in its container (chiseled image, non-root user, Linux file system).

(b) Everything in containers, with Compose Watch. Nothing but Docker is needed on the host; a newcomer brings up the whole system with docker compose watch. The price: debugging takes more effort and every change goes through a sync or rebuild step.

(c) Dev Container: the IDE itself runs inside a container, the SDKs live in the image, the dependencies still come from Compose. Setup consistency is as good as (b), the debugging experience close to (a); it works best in VS Code. The details are in the IDEs series post "Full-Stack .NET and Angular in VS Code: C# Dev Kit, Angular Language Service, Profiles and Dev Containers".

For Bookstore we kept two paths open: (a) for everyday debugging, (b) for "I just opened the project, everything should work" and end-to-end tests. The same compose.yaml serves both.

Model (a): Only the Dependencies

Given service names, Compose starts only those services and their dependencies:

docker compose up -d db migrate

The database is published on 127.0.0.1:1433, so the API on the host can reach it with localhost,1433. This time we passed the connection string as an environment variable; with Visual Studio the same value can go into user secrets:

export ConnectionStrings__Default="Server=localhost,1433;Database=BookstoreDb;User Id=bookstore_app;Password=$(cat secrets/db-app-password.txt);TrustServerCertificate=True"
dotnet run --project src/Bookstore.Api --launch-profile http
      Now listening on: http://localhost:5080
      Hosting environment: Development
      Catalog source: SqlBookSource

On the Angular side we added a proxy config so that ng serve on the host reaches the API from the same origin. In the Docker series, bookstore-web's public/config.json looked for the API at http://localhost:5080, so the browser went to another origin and needed CORS. Now the config says /api and the dev server forwards the request to the target:

// ng serve proxy: /api/books -> <target>/books, so the browser stays on one origin (no CORS in development).
// API_PROXY_TARGET is set by Compose (http://bookstore-api:8080); dotnet run on the host listens on 5080
export default {
  '/api': {
    target: process.env.API_PROXY_TARGET ?? 'http://localhost:5080',
    pathRewrite: { '^/api': '' },
  },
};

"options": { "proxyConfig": "proxy.conf.mjs" } was added to the serve target in angular.json. Because the config is an .mjs file, it can read the target from an environment variable: localhost:5080 by default on the host, the address Compose gives inside the container. The nginx image in production is not affected; there config.json is written when the container starts.

Model (b): Compose Watch

Watch is defined by a develop.watch section added to a service. Each rule watches a host path and runs an action on a change:

  • sync: copies the changed files into the running container; the container does not restart. For tools with their own hot reload (the Angular dev server, dotnet watch).
  • rebuild: builds the image again and recreates the container, the same as up --build.
  • sync+restart: copies and restarts the container; for files read at startup, like an nginx config.
  • sync+exec: copies and runs a command in the container (for example a reload signal); restart only restarts.

For Bookstore.Api we chose rebuild:

  api:
    ...
    develop:
      watch:
        # A change in the API's code: build the image again and recreate the container
        - path: src/Bookstore.Api
          action: rebuild
          ignore:
            - bin/
            - obj/

We wrote ignore explicitly, but it was not actually needed: the build context's .dockerignore (**/bin/, **/obj/) applies to watch as well. We saw this without looking for it: while watch was on, dotnet run on the host created the bin/ and obj/ folders and no rebuild was triggered. Without this setting, using models (a) and (b) at the same time would mean an endless rebuild loop.

For Angular we defined a separate service, because the web service in production serves the compiled files with nginx, not the dev server. We added a stage called dev to bookstore-web's Dockerfile; it is not on the path to the final stage, so the production build never runs it:

# dev: ng serve for Compose Watch (docker compose --profile dev watch); not part of the final image
FROM node:26-alpine AS dev
ENV NG_CLI_ANALYTICS=false
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
COPY --chown=node:node . .
EXPOSE 4200
CMD ["npx", "ng", "serve", "--host", "0.0.0.0", "--port", "4200"]
  # Angular dev server: docker compose --profile dev up --watch web-dev
  web-dev:
    build:
      context: ./web
      target: dev
    profiles: [dev]
    environment:
      API_PROXY_TARGET: http://bookstore-api:8080
    ports:
      - "4200:4200"
    depends_on:
      api:
        condition: service_healthy
    develop:
      watch:
        # Source files are copied into the running container; ng serve rebuilds and reloads the browser
        - path: web/src
          target: /app/src
          action: sync
        - path: web/public
          target: /app/public
          action: sync
        # New packages: build the dev image again (npm ci runs inside it)
        - path: web/package-lock.json
          action: rebuild

--host 0.0.0.0 is required: by default the dev server only listens on the container's own localhost and does not see requests arriving at the published port. node_modules lives inside the image, installed for Linux with npm ci; watch only syncs src and public.

Running Watch

docker compose --profile dev watch
 Image composeseries-web-dev Built
 Image composeseries-api Built
 Container composeseries-db-1 Running
 Container composeseries-api-1 Recreate
 Container composeseries-api-1 Recreated
 Container composeseries-web-dev-1 Created
 ...
 Container composeseries-api-1 Healthy
 Container composeseries-web-dev-1 Starting
 Container composeseries-web-dev-1 Started
Watch enabled

docker compose watch first does what up does (build, create, start), then switches to watching and shows only watch events in the terminal; for application logs use docker compose logs -f in another terminal. docker compose up --watch does the same in one terminal, together with the logs. The API being Recreated stands out: even adding the develop section changed the service definition, so Compose recreated the container.

We changed the heading in app.html:

Syncing service "web-dev" after 2 changes were detected
web-dev-1  | Application bundle generation complete. [0.101 seconds] - 2026-11-14T13:55:59.762Z
web-dev-1  |
web-dev-1  | Component update sent to client(s).

The file was copied into the container, the dev server compiled the change in about 0.1 seconds and sent a component update to the browser; the heading changed without a page reload. "2 changes" comes from the editor writing the file to a temporary file and renaming it. Then the experiment for the first question, the log message in the API:

Rebuilding service(s) ["api"] after changes were detected...
 Image composeseries-api Building
 Image composeseries-api Built
service(s) ["api"] successfully built
 Container composeseries-api-1 Recreate
 Container composeseries-api-1 Recreated
 Container composeseries-api-1 Starting
 Container composeseries-api-1 Started 

The new log line came 5 seconds after saving the file. The reason is the Dockerfile layout we set up in the Docker series: the csproj is copied and restored first, the code afterwards; on a code change the restore layer comes from the cache and only dotnet publish runs (2.0 s in the build log). In a Dockerfile that puts restore in the same layer as the code, the same change would download the packages again every time.

Faster is possible too: running dotnet watch run in the SDK image and giving it the code with sync brings hot reload into the container. The price is the 1.3 GB SDK image entering the development environment and the application running in an environment different from the chiseled image in production; a 5-second rebuild is enough for us, so we did not take that path.

Watch's Limits and File Permissions

Stopping watch (Ctrl+C) does not stop the containers: when docker compose watch ended, all four services kept running. The watching ends, the system stays up.

Synchronization goes one way: from the host into the container. Files created inside the container (for example .angular/cache) do not come back to the host; that is a good thing, because the bind mount problem of "the container wrote a file as root and I cannot delete it on the host" does not exist here. Compose's documentation describes watch only for services with build:; when we gave a sync+restart rule to a Redis service defined with an image, Compose 5.7.1 still copied the file and restarted the container. rebuild, by its nature, needs a build: definition.

The answer to the second question is in the file permissions. When Compose writes files into the container, it makes the container's user the owner, but for deletions and folder operations it runs commands inside the container (which is why the image must have stat, mkdir and rmdir). In a small experiment we COPYed files into a root-owned folder and ran as USER nobody; adding a new file worked, but when we deleted a file on the host:

rm: can't remove '/app/src/index.txt': Permission denied
time="2026-11-14T14:00:28Z" level=warning msg="Error handling changed files: deleting paths in f043855b5cc4c4e6747c06a0ad15133cd925fafe1672658d568a2f2fc3aa518f: exit code 1"

The deleted file stayed in the container, and a dev server would keep compiling it. That is why the dev stage of bookstore-web has COPY --chown=node:node and RUN chown node:node /app: the container's user must own the target folder.

initial_sync and Watch's Starting State

Watch only sees changes that happen while it is running. If you changed something in web/src while watch was off and the container was not recreated, the container keeps running with the old files. Adding initial_sync: true to a rule makes watch compare the files in the existing container with the ones on the host when it starts and copy the difference (since Compose 2.39.4; for sync-type actions). In Bookstore the web-dev container is not recreated from the image on every watch start, so the setting is worth adding; we add it in the next post when we move the dev environment into its own file.

In Production

The develop section does nothing in production; unless the watch command runs, Compose ignores it. Still, the dev server definition and port 4200 should not sit in the file that goes to production; in the next post we move everything dev-specific into compose.override.yaml. The dev stage can stay in the Dockerfile: the production build does not touch it on the way to final and it adds nothing to CI time. On Windows, keeping the repo on WSL's own file system (under ~/) rather than on the Windows file system (/mnt/c/...) speeds up both sending the build context and watch's file events; for files under C:\, every operation goes through the file sharing between Windows and Linux.

Common Pitfalls

  • Syncing the host's node_modules into the container: native modules installed on Windows do not work in a Linux container; put node_modules in .dockerignore and install it inside the image with npm ci.
  • Starting the dev server without --host 0.0.0.0: the port looks published, but the browser cannot connect.
  • Not setting the target folder's ownership for a non-root user: adding files works, deleting fails with Permission denied and the container drifts away from the host.
  • Putting restore in the same Dockerfile layer as the code: every rebuild downloads the packages again and the watch loop grows to minutes.
  • Thinking the containers stop when watch stops: after docker compose watch ends, the services keep running; shut them down with docker compose --profile dev down.
  • Watching bin/ and obj/: a build on the host triggers rebuilds over and over; leave them out with .dockerignore or ignore.

A Small Experiment

★☆☆ Add a sync+restart rule for web/nginx/default.conf to the production web service (target: /etc/nginx/conf.d/default.conf). Add a header to the nginx config and check it with curl -I localhost:8080. How do you show that the config is not permanent when the container is recreated?

★★☆ Instead of rebuild, write an api-dev service for Bookstore.Api that runs dotnet watch run in the SDK image and gets src/Bookstore.Api via sync. How many seconds does the same log change take this time? Which changes (for example a new NuGet package) still need a rebuild?

★★★ Mix models (a) and (b): the API runs on the host with dotnet run, Angular in the container with web-dev. Which address should API_PROXY_TARGET get? Try host.docker.internal on Docker Desktop and extra_hosts: ["host.docker.internal:host-gateway"] on Linux, and compare which interface the API must listen on with post 12 of the Docker series.

Now production-like definitions, the dev server, watch rules and development ports sit side by side in the same compose.yaml. In the next post we split them into dev, CI and production-like environments with compose.override.yaml, files layered with -f, include and extends, and see where the merge rules surprise you.