Skip to Content
Docker Advanced: Builds and Containers That Hold Up in Production
Advanced
0 likes
Courses
Docker Advanced: Builds and Containers That Hold Up in Production

Compose Profiles, Watch and Override Files

Use profiles for optional services, see how override files merge, avoid the ports trap with the override tag and sync edits with docker compose watch. After this, you can run one base Compose file in every environment.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already use one base Compose file with override files per environment, and you know that ports from two files are added together, skip to "Watch: edit and see it in the container".

One Compose file per environment sounds tidy until the three copies drift apart and nobody knows which one production really uses. Compose has three features that keep one base file honest: override files for what differs between environments, profiles for services you only sometimes need, and watch for the edit-and-check loop while developing. Each one is simple. The merging rules are where people get burned, so this lesson shows the merged result every time instead of guessing.

Everything was run with Compose v5.3.1 on Docker Desktop 4.85.0.

The project

A small website, built from a Dockerfile, plus a debug container that most people never need.

<h1>version one</h1>
FROM nginx:1.30-alpine
COPY site /usr/share/nginx/html
services:
  web:
    build: .
    ports:
      - "127.0.0.1:8471:80"
    develop:
      watch:
        - action: sync
          path: ./site
          target: /usr/share/nginx/html
        - action: rebuild
          path: Dockerfile
  debug:
    image: alpine:3.24
    command: ["sleep", "infinity"]
    profiles: ["debug"]

Profiles: services that start only when asked

A service with profiles: is left out unless you name one of its profiles. Compare the list of services with and without the profile:

Set-Location compose-lab
"default: " + ((docker compose config --services) -join ", ")
"with --profile debug: " + ((docker compose --profile debug config --services | Sort-Object) -join ", ")
default: web
with --profile debug: debug, web

Use profiles for tools: a database admin page, a test runner, a mail catcher. They stay in the same file, so they use the same network and names, and nobody has to remember a second file. A service with no profile always starts. Don't put your main services in profiles, or docker compose up starts nothing useful.

compose.override.yaml is read without asking

If a file called compose.override.yaml sits next to compose.yaml, Compose merges it in automatically. That is the place for developer-only settings.

services:
  web:
    environment:
      MODE: development
$cfg = docker compose config --format json | ConvertFrom-Json
"MODE = " + $cfg.services.web.environment.MODE
MODE = development

It is convenient and also dangerous. If someone deploys by copying the whole folder to a server and running docker compose up, the development override comes along. On servers, always list the files you mean with -f.

Production file and the ports trap

The production file changes the port. Here is the obvious version:

services:
  web:
    ports:
      - "8080:80"
$cfg = docker compose -f compose.yaml -f compose.prod.yaml config --format json | ConvertFrom-Json
$cfg.services.web.ports | ForEach-Object { "{0}:{1} -> {2}" -f $_.host_ip, $_.published, $_.target }
127.0.0.1:8471 -> 80
:8080 -> 80

Two ports, not one. Compose merges lists like ports, expose and volumes by adding them together, while single values like image or command are replaced. So production publishes the development port too, and the new one listens on every network interface because it has no 127.0.0.1 in front. Nobody intended that.

The fix is the !override tag, which replaces the list instead of adding to it:

services:
  web:
    ports: !override
      - "127.0.0.1:8080:80"
$cfg = docker compose -f compose.yaml -f compose.prod2.yaml config --format json | ConvertFrom-Json
$cfg.services.web.ports | ForEach-Object { "{0}:{1} -> {2}" -f $_.host_ip, $_.published, $_.target }
127.0.0.1:8080 -> 80

There is also !reset, which empties a value completely, for example ports: !reset [] for a service that must not publish anything in one environment.

⚠ Warning: Always check the merged result with docker compose -f ... -f ... config before you deploy. It costs one second and it shows exactly what Compose will run.

Watch: edit and see it in the container

docker compose watch builds and starts the services, then watches the paths you listed under develop.watch. sync copies changed files into the running container. rebuild builds the image again and replaces the container. There is also sync+restart for config files that the program reads only at start.

You run it in its own terminal and leave it there:

docker compose watch

Then, in a second terminal, edit a file and check the container:

Set-Content site\index.html "<h1>version two</h1>"
docker compose exec web cat /usr/share/nginx/html/index.html
before: <h1>version one</h1>
after:  <h1>version two</h1>
watch says: Watch enabled
watch says: Syncing service "web" after 1 changes were detected

The file changed inside the running container a few seconds after I saved it, without a rebuild and without a bind mount. That last part matters on Windows: a bind mount from C: is slow, and watch copies only what changed.

Watch is for development. It does not belong on a server. Nobody should be editing files on production and having them copied into containers.

Common mistakes

  • Expecting ports or volumes from an override to replace the base. They are added. Use !override or check config.
  • A .env file that sets COMPOSE_FILE or COMPOSE_PROFILES and that you forgot about. Compose reads it, and the project behaves differently on another machine.
  • Watching a folder with node_modules or build output inside. Add ignore: entries, or watch only the source folder.

💡 Tip: Keep a short deploy.ps1 or Makefile that runs docker compose -f compose.yaml -f compose.prod.yaml up -d. Typing the file list by hand on a server is how the override file ends up in production.

If you ran docker compose config on your production server right now, would every port in the output be one that you meant to publish?

About

Use profiles for optional services, see how override files merge, avoid the ports trap with the override tag and sync edits with docker compose watch. After this, you can run one base Compose file in every environment.

Duration
5 min
Last update
08/01/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.