Build CoreOrdered learning track

Compose File Deep Dive: Services, Build, Environment, Profiles, Extensions

Learn Docker, Containerization, Docker Compose, Docker Swarm - Part 016

Deep dive into Compose file semantics: services, build, image, command, entrypoint, environment, env_file, ports, volumes, networks, profiles, extension fields, anchors, overrides, validation, and maintainable file design.

15 min read2920 words
PrevNext
Lesson 1635 lesson track0719 Build Core
#docker#containerization#docker-compose#compose-file+2 more

Part 016 — Compose File Deep Dive: Services, Build, Environment, Profiles, Extensions

Target pembelajaran: setelah part ini, kita mampu membaca, menulis, mereview, dan men-debug Compose file sebagai kontrak aplikasi yang terstruktur, bukan sebagai YAML improvisasi.

Part 015 membahas Compose application model. Part ini membahas file semantics: bagaimana compose.yaml merepresentasikan model tersebut, bagaimana Compose melakukan interpolation/merge, dan bagaimana membuat file yang tetap maintainable saat service bertambah.

Docker Compose menggunakan Compose Specification sebagai format untuk mendefinisikan aplikasi multi-container. Elemen utamanya meliputi services, networks, volumes, configs, dan secrets. Compose V2 modern menggunakan command docker compose, bukan binary legacy docker-compose.


1. Compose File sebagai Kontrak, Bukan Script

Script menjelaskan langkah:

docker network create app
docker volume create pgdata
docker run ...
docker run ...

Compose file menjelaskan desired model:

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    networks:
      - backend

  postgres:
    image: postgres:16-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - backend

networks:
  backend: {}

volumes:
  postgres-data: {}

Perbedaan penting:

ScriptCompose file
imperativedeclarative-ish
urutan command dominanmodel dominan
sulit di-merge/reviewbisa divalidasi dan direview
cleanup manualproject lifecycle
dependency tersebardependency terlihat

Compose tetap bukan orchestrator penuh, tetapi file-nya adalah architecture artifact.


2. Minimal Valid Compose File

Minimal:

services:
  hello:
    image: hello-world

Jalankan:

docker compose up

Namun file production-grade biasanya memiliki:

name: myapp

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    environment:
      APP_ENV: development
    networks:
      - backend

networks:
  backend: {}

2.1 version Field

Compose file modern tidak perlu field version lama seperti:

version: "3.8"

Compose Specification modern tidak memerlukan pola versi lama itu sebagai cara utama memahami fitur. Untuk seri ini, gunakan file tanpa version kecuali ada alasan kompatibilitas spesifik dengan tool lama.

Preferred:

name: myapp
services:
  api:
    image: example/api

3. Naming Convention: compose.yaml, compose.override.yaml, and Project Name

Gunakan:

compose.yaml

Untuk override lokal default:

compose.override.yaml

Compose secara umum bisa membaca base file dan override file. Dalam repo serius, struktur umum:

.
├── compose.yaml
├── compose.override.yaml
├── compose.dev.yaml
├── compose.test.yaml
├── compose.ci.yaml
├── compose.swarm.yaml
└── .env.example

3.1 Suggested Contract

compose.yaml          # canonical application graph
compose.override.yaml # local developer override, usually gitignored or lightweight
compose.dev.yaml      # shared dev variant
compose.test.yaml     # integration/e2e test variant
compose.ci.yaml       # CI-specific deterministic settings
compose.swarm.yaml    # stack deploy candidate, discussed later

3.2 Project Name Rule

Untuk ad-hoc local:

name: case-platform

Untuk CI, prefer override lewat env:

COMPOSE_PROJECT_NAME="case-platform-${CI_PIPELINE_ID}" docker compose -f compose.yaml -f compose.ci.yaml up -d

Jangan hardcode project name yang menyebabkan parallel CI collision.


4. Top-Level services

services adalah elemen utama. Setiap key di bawah services adalah service name.

services:
  api:
    image: ghcr.io/acme/api:1.4.2

  worker:
    image: ghcr.io/acme/worker:1.4.2

Service name harus:

  • stabil;
  • pendek;
  • meaningful;
  • DNS-friendly;
  • tidak mengandung environment yang cepat berubah.

Good:

services:
  case-api:
  adjudication-worker:
  postgres:
  redis:

Bad:

services:
  my-api-container-new-local-test2:

Service name akan menjadi DNS alias di network Compose. Jadi service name adalah bagian dari runtime contract.


5. image vs build

5.1 image

services:
  api:
    image: ghcr.io/acme/case-api:1.4.2

Gunakan image ketika artifact sudah dibangun.

Production-like:

services:
  api:
    image: ghcr.io/acme/case-api@sha256:2f1a...

Digest pinning meningkatkan traceability karena tag bisa berubah.

5.2 build

Short syntax:

services:
  api:
    build: ./services/api

Long syntax:

services:
  api:
    build:
      context: ./services/api
      dockerfile: Dockerfile
      target: runtime
      args:
        APP_VERSION: ${APP_VERSION:-local}
    image: ghcr.io/acme/case-api:${APP_VERSION:-local}

Long syntax lebih reviewable untuk stack serius.

5.3 Build Context Rule

Bad:

services:
  api:
    build:
      context: .
      dockerfile: services/api/Dockerfile

Jika root repo besar, context bisa mengirim terlalu banyak file dan membocorkan secrets.

Better:

services:
  api:
    build:
      context: ./services/api
      dockerfile: Dockerfile

Jika butuh shared files, desain context secara eksplisit atau gunakan build system yang jelas. Jangan menjadikan root repo sebagai context default tanpa alasan.

5.4 Image+Build Together

services:
  api:
    build:
      context: ./services/api
    image: ghcr.io/acme/case-api:local

Ini berguna karena hasil build diberi tag. Tanpa image, Compose tetap bisa build image, tetapi tag/name-nya lebih Compose-generated.

Rule:

Untuk service yang dibuild, berikan image tag eksplisit agar artifact mudah diinspeksi, dipush, dan dipakai ulang.


6. command and entrypoint

Dockerfile sudah punya ENTRYPOINT dan CMD. Compose bisa override.

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    command: ["serve", "--port", "8080"]
services:
  migration:
    image: ghcr.io/acme/api:1.4.2
    command: ["migrate", "up"]

6.1 Command Override Pattern

Jika satu image punya beberapa mode runtime:

services:
  api:
    image: ghcr.io/acme/case-service:1.4.2
    command: ["api"]

  worker:
    image: ghcr.io/acme/case-service:1.4.2
    command: ["worker"]

  migration:
    image: ghcr.io/acme/case-service:1.4.2
    command: ["migrate", "up"]
    profiles:
      - ops

Ini valid jika image memang didesain sebagai multi-mode runtime. Namun jangan membuat image menjadi dumping ground untuk semua tool.

6.2 Entrypoint Override Risk

services:
  api:
    entrypoint: ["sh", "-c"]
    command: ["java -jar app.jar"]

Ini bisa merusak signal handling, PID 1 semantics, dan graceful shutdown. Override entrypoint hanya jika benar-benar paham konsekuensinya.

Better:

services:
  api:
    command: ["java", "-jar", "app.jar"]

Atau desain Dockerfile entrypoint yang benar.


7. environment, env_file, .env, and Interpolation

Ini bagian yang sering membingungkan.

7.1 environment

Mapping syntax:

services:
  api:
    environment:
      APP_ENV: development
      DB_HOST: postgres
      DB_PORT: "5432"
      FEATURE_X_ENABLED: "false"

List syntax:

services:
  api:
    environment:
      - APP_ENV=development
      - DB_HOST=postgres

Prefer mapping untuk reviewability.

7.2 env_file

api.env:

APP_ENV=development
DB_HOST=postgres
DB_PORT=5432

Compose:

services:
  api:
    env_file:
      - ./api.env

Ini memasukkan variable ke container environment.

7.3 .env for Compose Interpolation

.env:

API_TAG=1.4.2
API_PORT=8080

Compose:

services:
  api:
    image: ghcr.io/acme/api:${API_TAG}
    ports:
      - "${API_PORT}:8080"

.env di sini mengisi template Compose file, bukan otomatis berarti semua variable masuk ke container.

7.4 Interpolation Defaults

services:
  api:
    image: ghcr.io/acme/api:${API_TAG:-local}
    environment:
      APP_ENV: ${APP_ENV:-development}

Useful, tetapi jangan terlalu banyak default tersembunyi untuk setting kritikal.

For critical values:

services:
  api:
    image: ghcr.io/acme/api:${API_TAG:?API_TAG is required}

Ini membuat Compose gagal jika API_TAG kosong/tidak ada.

7.5 Environment Precedence Mental Model

Tanpa menghafal semua detail edge case, prinsip aman:

  1. Semakin dekat ke service declaration, semakin eksplisit.
  2. Jangan definisikan variable yang sama di banyak tempat tanpa alasan.
  3. Gunakan docker compose config untuk melihat resolved model.
  4. Untuk value sensitif, jangan pakai environment literal jika ada opsi secret/file.

Command wajib:

docker compose config

Jika hasil resolved model mengejutkan, file layering/interpolation perlu dirapikan.


8. ports vs expose

8.1 ports

Publish container port ke host.

services:
  api:
    ports:
      - "8080:8080"

Format:

HOST_PORT:CONTAINER_PORT

Host access:

localhost:8080

Service-to-service access tetap menggunakan service DNS dan container port.

api:8080

8.2 Bind Address

Untuk bind hanya ke localhost host:

services:
  api:
    ports:
      - "127.0.0.1:8080:8080"

Ini lebih aman daripada bind ke semua interface jika hanya dipakai lokal.

8.3 expose

services:
  api:
    expose:
      - "8080"

expose mendokumentasikan port internal, tetapi tidak publish ke host. Pada network Compose, service lain tetap bisa mengakses port yang didengarkan oleh process walaupun tidak ada expose, asalkan network memungkinkan.

Rule:

ports adalah ingress dari host. expose adalah dokumentasi/metadata internal intent.

8.4 Port Anti-Pattern

Bad:

services:
  postgres:
    ports:
      - "5432:5432"

Jika DB hanya dipakai api, jangan publish.

Better:

services:
  postgres:
    networks:
      - data

9. volumes Syntax

9.1 Short Syntax

services:
  postgres:
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro

volumes:
  postgres-data: {}

Short syntax praktis, tetapi bisa ambigu untuk kasus kompleks.

9.2 Long Syntax

services:
  postgres:
    volumes:
      - type: volume
        source: postgres-data
        target: /var/lib/postgresql/data
      - type: bind
        source: ./init.sql
        target: /docker-entrypoint-initdb.d/init.sql
        read_only: true

volumes:
  postgres-data: {}

Long syntax lebih jelas untuk code review.

9.3 Bind Mount for Source Code

services:
  api:
    volumes:
      - type: bind
        source: ./services/api/src
        target: /app/src

Untuk dev hot reload, bind mount berguna. Untuk production-like run, hindari bind mount source code.

9.4 Read-Only Mount

services:
  nginx:
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

Prefer :ro untuk config file.

9.5 Top-Level Volume Options

volumes:
  postgres-data:
    labels:
      com.acme.owner: platform
      com.acme.purpose: local-db

External volume:

volumes:
  postgres-data:
    external: true
    name: shared-postgres-data

Use external carefully. External object is outside project lifecycle. docker compose down -v will not manage it the same way as project-created volume.

Rule:

External volume berarti lifecycle state tidak lagi sepenuhnya dimiliki Compose project.


10. networks Syntax

10.1 Default Network

Jika tidak didefinisikan, Compose membuat default network.

services:
  api:
    image: example/api
  postgres:
    image: postgres:16

10.2 Explicit Network

services:
  api:
    networks:
      - backend
      - data

  postgres:
    networks:
      - data

networks:
  backend: {}
  data:
    internal: true

10.3 Aliases

services:
  postgres:
    networks:
      data:
        aliases:
          - db

networks:
  data: {}

Now postgres may be reachable as postgres and db on data network.

Use aliases sparingly. Too many aliases hide architecture.

10.4 External Network

networks:
  shared-proxy:
    external: true

Use for integration with external reverse proxy or shared infrastructure network.

Risk:

  • cross-project coupling;
  • unclear ownership;
  • cleanup ambiguity;
  • security boundary blur.

Rule:

Prefer project-local networks unless there is explicit cross-project integration.


11. depends_on, healthcheck, and Restart Preview

Basic:

services:
  api:
    depends_on:
      - postgres

Health-aware:

services:
  api:
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20

Part 017 akan masuk lebih dalam, tetapi prinsipnya:

  • depends_on membantu startup order;
  • healthcheck memberi signal readiness/liveness container-level;
  • aplikasi tetap harus handle dependency failure setelah startup.

11.1 Healthcheck Example for HTTP

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 20s

Pastikan tool yang dipakai (wget, curl) ada di image. Minimal image sering tidak punya tool ini.

Alternative: sediakan health binary atau endpoint check native.


12. restart Policy

Compose service bisa punya restart policy lokal:

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    restart: unless-stopped

Common values:

no
always
on-failure
unless-stopped

Untuk local dev, restart policy kadang mengganggu karena container crash terus dan memenuhi logs.

Untuk single-host service sederhana, restart policy bisa berguna.

Rule:

Jangan gunakan restart policy untuk menyembunyikan crash loop. Gunakan untuk recovery dari failure transient yang sudah dipahami.


13. profiles

Profiles mengaktifkan subset service.

services:
  api:
    image: example/api

  postgres:
    image: postgres:16

  adminer:
    image: adminer:4
    profiles:
      - debug
    ports:
      - "8081:8080"

Run default:

docker compose up -d

Run with debug:

docker compose --profile debug up -d

Multiple profiles:

docker compose --profile debug --profile observability up -d

Or:

COMPOSE_PROFILES=debug,observability docker compose up -d

13.1 Profile Design

Good:

services:
  grafana:
    profiles: ["observability"]

  k6:
    profiles: ["loadtest"]

  adminer:
    profiles: ["debug"]

Bad:

services:
  postgres:
    profiles: ["db"]

Jika service inti disembunyikan profile, default up tidak merepresentasikan aplikasi.


14. configs and secrets

14.1 Configs

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    configs:
      - source: api-config
        target: /etc/app/config.yml

configs:
  api-config:
    file: ./config/api.yml

Use for non-sensitive config file.

14.2 Secrets

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    secrets:
      - db-password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db-password

secrets:
  db-password:
    file: ./secrets/db-password.txt

Many official images support _FILE environment variables for secret file path. Check image documentation.

14.3 Secret Anti-Pattern

Bad:

services:
  api:
    environment:
      DB_PASSWORD: ${DB_PASSWORD}

This can still leak through config output, process environment, debugging tools, logs, and history.

Better:

services:
  api:
    secrets:
      - db-password

For serious production, integrate with platform secret manager or Swarm/Kubernetes secret model. Compose local secrets are useful but not a full enterprise secret management system.


15. Resource Controls in Compose

Compose supports resource-related settings in different contexts. There is a distinction between local Compose runtime settings and deploy settings intended for orchestrators such as Swarm.

Local-style example:

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    mem_limit: 512m
    cpus: 1.0

Swarm deploy-style example:

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

Important:

  • not every deploy setting applies the same way in local Compose;
  • Swarm stack deployment interprets deploy more directly;
  • always verify behavior with docker compose config, docker inspect, and runtime metrics.

Rule:

Treat resource settings as testable runtime constraints, not documentation comments.


16. develop and Watch-Oriented Local Workflow Preview

Modern Compose has added development workflow features such as file watch/sync in current Docker tooling. Since support can vary by Docker/Compose version, do not make core architecture depend on it unless team baseline is standardized.

Classic bind mount approach:

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

Watch-style approach may be useful for:

  • rebuild on dependency file change;
  • sync source directory;
  • avoid full repo bind mount;
  • improve Docker Desktop filesystem performance.

Engineering rule:

Dev convenience must not mutate the canonical runtime contract. Keep dev-specific workflow in override/profile when possible.

Part 018 will cover development workflows more deeply.


17. Extension Fields: x-*

YAML and Compose allow extension fields using x-*. These are ignored by Compose as top-level custom fields but can be reused with anchors.

Example:

x-common-env: &common-env
  APP_ENV: development
  LOG_LEVEL: info

services:
  api:
    image: ghcr.io/acme/api:local
    environment:
      <<: *common-env
      SERVICE_NAME: api

  worker:
    image: ghcr.io/acme/worker:local
    environment:
      <<: *common-env
      SERVICE_NAME: worker

This reduces duplication.

17.1 Common Service Template

x-service-defaults: &service-defaults
  restart: unless-stopped
  networks:
    - backend
  environment:
    APP_ENV: development
    LOG_LEVEL: info

services:
  api:
    <<: *service-defaults
    image: ghcr.io/acme/api:local
    ports:
      - "8080:8080"

  worker:
    <<: *service-defaults
    image: ghcr.io/acme/worker:local

networks:
  backend: {}

17.2 Anchor Risk

YAML anchors can improve maintainability, but they can also hide meaning.

Bad:

services:
  api:
    <<: *everything

If reviewers must chase anchors across hundreds of lines, readability suffers.

Rule:

Use anchors for boring repetition, not for hiding service-specific behavior.

Good candidates:

  • common labels;
  • common logging options;
  • common environment defaults;
  • common network attachment;
  • common restart policy.

Bad candidates:

  • ports;
  • volumes;
  • privileged/security settings;
  • secrets;
  • service-specific commands;
  • dependency graph.

Security-sensitive settings should stay visible.


18. Multiple Compose Files and Merge Behavior

Compose can combine files:

docker compose -f compose.yaml -f compose.dev.yaml up -d

Mental model:

base model + override model = resolved model

Always inspect final result:

docker compose -f compose.yaml -f compose.dev.yaml config

18.1 Base File

# compose.yaml
services:
  api:
    image: ghcr.io/acme/api:${API_TAG:?API_TAG is required}
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
    networks:
      - backend
      - data

  postgres:
    image: postgres:16-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - data

networks:
  backend: {}
  data:
    internal: true

volumes:
  postgres-data: {}

18.2 Dev Override

# compose.dev.yaml
services:
  api:
    build:
      context: ./services/api
    image: ghcr.io/acme/api:local
    ports:
      - "8080:8080"
    volumes:
      - ./services/api/src:/app/src
    environment:
      APP_ENV: development

Command:

docker compose -f compose.yaml -f compose.dev.yaml up -d --build

18.3 CI Override

# compose.ci.yaml
services:
  api:
    image: ghcr.io/acme/api:${API_TAG:?API_TAG is required}
    environment:
      APP_ENV: test

  test-runner:
    image: ghcr.io/acme/api:${API_TAG:?API_TAG is required}
    command: ["test"]
    depends_on:
      api:
        condition: service_started
    networks:
      - backend
    profiles:
      - test

Command:

COMPOSE_PROJECT_NAME="case-ci-${CI_PIPELINE_ID}" \
  docker compose -f compose.yaml -f compose.ci.yaml --profile test up --abort-on-container-exit --exit-code-from test-runner

18.4 Merge Risk

Lists can be replaced or merged depending field semantics. Do not guess. Use:

docker compose config

Review the resolved output in CI when making structural changes.


19. extends and Reuse Across Files

Compose supports ways to reuse service configuration, including extends in supported contexts. Use carefully.

Example pattern:

# common-services.yaml
services:
  api-base:
    image: ghcr.io/acme/api:local
    environment:
      LOG_LEVEL: info
    networks:
      - backend
# compose.yaml
services:
  api:
    extends:
      file: common-services.yaml
      service: api-base
    ports:
      - "8080:8080"

networks:
  backend: {}

Risk:

  • file navigation complexity;
  • hidden inherited settings;
  • confusing merge result;
  • security-sensitive fields inherited invisibly.

Rule:

Prefer simple base+override files and x-* anchors before introducing cross-file inheritance.


Compose can express runtime hardening.

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    user: "10001:10001"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp

20.1 user

services:
  api:
    user: "10001:10001"

Runs process as UID/GID. Image must support file permissions.

20.2 read_only

services:
  api:
    read_only: true
    tmpfs:
      - /tmp

Useful for stateless service. App must write only to approved writable paths.

20.3 Capabilities

services:
  api:
    cap_drop:
      - ALL

Add only what is needed:

services:
  network-tool:
    cap_add:
      - NET_ADMIN

Do not add capabilities casually.

20.4 Privileged Mode

services:
  bad:
    privileged: true

Treat as high-risk. Requires strong justification.

20.5 Docker Socket Mount

services:
  dangerous:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

This often grants effective control over Docker daemon. Treat as host-level trust.

Rule:

A Compose file with privileged: true or Docker socket mount deserves security review.


21. Logging Fields

Compose can configure logging driver options:

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

For local dev, default logging may be enough. For long-running single-host stacks, log rotation matters.

Risk:

  • unbounded logs fill disk;
  • noisy service hides important logs;
  • logs contain secrets;
  • structured log fields lost.

Rule:

Logging config is part of reliability, not decoration.


22. Labels for Metadata and Automation

x-common-labels: &common-labels
  com.acme.owner: platform
  com.acme.system: enforcement-case

services:
  api:
    image: ghcr.io/acme/api:1.4.2
    labels:
      <<: *common-labels
      com.acme.service: api
      com.acme.tier: backend

Use labels for:

  • reverse proxy routing;
  • log routing;
  • ownership;
  • policy classification;
  • cleanup automation;
  • observability dimensions.

Do not put secrets in labels. Labels are metadata and often visible through inspect APIs.


23. Full Advanced Compose Example

name: case-platform

x-common-labels: &common-labels
  com.acme.system: case-platform
  com.acme.owner: platform-team

x-common-env: &common-env
  APP_ENV: ${APP_ENV:-development}
  LOG_LEVEL: ${LOG_LEVEL:-info}

x-service-defaults: &service-defaults
  restart: unless-stopped
  networks:
    - backend
  labels:
    <<: *common-labels

services:
  gateway:
    <<: *service-defaults
    image: nginx:1.27-alpine
    ports:
      - "127.0.0.1:${GATEWAY_PORT:-8080}:80"
    volumes:
      - type: bind
        source: ./infra/nginx/default.conf
        target: /etc/nginx/conf.d/default.conf
        read_only: true
    depends_on:
      case-api:
        condition: service_started
    labels:
      <<: *common-labels
      com.acme.service: gateway
      com.acme.tier: edge

  case-api:
    <<: *service-defaults
    build:
      context: ./services/case-api
      dockerfile: Dockerfile
      target: runtime
      args:
        APP_VERSION: ${APP_VERSION:-local}
    image: ghcr.io/acme/case-api:${APP_VERSION:-local}
    environment:
      <<: *common-env
      SERVICE_NAME: case-api
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_NAME: cases
      DB_USER: cases
      DB_PASSWORD_FILE: /run/secrets/postgres-password
      EVENT_BROKER_URL: nats://nats:4222
    secrets:
      - postgres-password
    configs:
      - source: case-api-config
        target: /etc/case-api/config.yml
    depends_on:
      postgres:
        condition: service_healthy
      nats:
        condition: service_started
    networks:
      - backend
      - data
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 20s
    labels:
      <<: *common-labels
      com.acme.service: case-api
      com.acme.tier: backend

  worker:
    <<: *service-defaults
    build:
      context: ./services/worker
    image: ghcr.io/acme/case-worker:${APP_VERSION:-local}
    command: ["worker"]
    environment:
      <<: *common-env
      SERVICE_NAME: worker
      DB_HOST: postgres
      EVENT_BROKER_URL: nats://nats:4222
    secrets:
      - postgres-password
    depends_on:
      postgres:
        condition: service_healthy
      nats:
        condition: service_started
    networks:
      - backend
      - data
    labels:
      <<: *common-labels
      com.acme.service: worker
      com.acme.tier: backend

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: cases
      POSTGRES_USER: cases
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
    secrets:
      - postgres-password
    volumes:
      - type: volume
        source: postgres-data
        target: /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cases -d cases"]
      interval: 5s
      timeout: 3s
      retries: 20
    networks:
      - data
    labels:
      <<: *common-labels
      com.acme.service: postgres
      com.acme.tier: data

  nats:
    image: nats:2.10-alpine
    command: ["-js"]
    networks:
      - backend
    labels:
      <<: *common-labels
      com.acme.service: nats
      com.acme.tier: messaging

  adminer:
    image: adminer:4
    profiles:
      - debug
    ports:
      - "127.0.0.1:8081:8080"
    networks:
      - data
    labels:
      <<: *common-labels
      com.acme.service: adminer
      com.acme.tier: debug

networks:
  backend: {}
  data:
    internal: true

volumes:
  postgres-data:
    labels:
      com.acme.system: case-platform
      com.acme.purpose: postgres-local-data

configs:
  case-api-config:
    file: ./config/case-api.yml

secrets:
  postgres-password:
    file: ./secrets/postgres-password.txt

23.1 Review of the Example

Good properties:

  • only gateway is host-published by default;
  • host ports bind to 127.0.0.1, not all interfaces;
  • data network is internal;
  • Postgres state is in named volume;
  • secrets use file-based secret mount;
  • debug UI is behind profile;
  • common metadata is centralized;
  • service-specific labels remain visible;
  • build context is service-local;
  • resolved model can be inspected.

Potential issues to verify:

  • Does wget exist in case-api image for healthcheck?
  • Are file permissions correct for secret/config mounts?
  • Does restart: unless-stopped fit dev workflow?
  • Are anchors improving readability or hiding too much?
  • Does APP_VERSION:-local risk accidental local tag usage in CI?

24. Compose Validation and Inspection Loop

Use this loop constantly:

docker compose config

Check resolved file.

docker compose config --services

List services.

docker compose config --profiles

List profiles.

docker compose config --volumes

List volumes.

docker compose up -d --build

Run.

docker compose ps

Observe.

docker compose logs -f --tail=100

Inspect logs.

docker compose down -v --remove-orphans

Clean.

24.1 CI Guardrail

Add a validation step:

docker compose -f compose.yaml -f compose.ci.yaml config >/tmp/compose.resolved.yaml

Optionally archive resolved config as CI artifact. This helps debug environment-specific interpolation/merge issues.


25. Compose File Smells

25.1 Too Many Host Ports

Symptom:

ports:
  - "5432:5432"
  - "6379:6379"
  - "9200:9200"
  - "5672:5672"

Ask:

  • does host really need all of these?
  • can admin access be behind profile?
  • can internal services use DNS instead?

25.2 Secret-Like Values in Plain Env

Symptom:

JWT_SECRET: abc123
DB_PASSWORD: password
API_KEY: key

Fix:

  • use secrets;
  • use local uncommitted .env only for non-critical dev if unavoidable;
  • provide .env.example without real values.

25.3 container_name

services:
  api:
    container_name: api

This can break scaling, project isolation, and parallel stacks. Avoid unless integrating with a legacy tool that demands fixed name.

Prefer service DNS:

api

25.4 privileged: true

Requires explicit justification. Usually a smell.

25.5 Whole-Repo Bind Mount

volumes:
  - .:/app

Acceptable for quick prototype, risky for serious stack.

25.6 latest Tag

image: postgres:latest

Use versioned tags:

image: postgres:16-alpine

For production-like artifact, prefer digest or immutable release tag.

25.7 Hidden Required Profiles

If default docker compose up does not run the minimal useful app, profile design is wrong.

25.8 Overuse of YAML Anchors

If every service requires mental expansion of five anchors, readability is damaged.


26. Practical File Organization

For a medium service repo:

case-platform/
├── compose.yaml
├── compose.dev.yaml
├── compose.test.yaml
├── compose.ci.yaml
├── .env.example
├── config/
│   └── case-api.yml
├── secrets/
│   └── .gitkeep
├── infra/
│   └── nginx/
│       └── default.conf
└── services/
    ├── case-api/
    │   ├── Dockerfile
    │   └── src/
    └── worker/
        ├── Dockerfile
        └── src/

Git rules:

.env
secrets/*
!secrets/.gitkeep

.env.example:

APP_ENV=development
APP_VERSION=local
GATEWAY_PORT=8080
LOG_LEVEL=info

Do not put real secrets in .env.example.


27. Compose for Java/Spring-Like Service Example

Because the target reader is a software engineer, let us model a common Java service without repeating Java internals.

services:
  case-api:
    build:
      context: ./case-api
      dockerfile: Dockerfile
      target: runtime
    image: ghcr.io/acme/case-api:${APP_VERSION:-local}
    environment:
      SPRING_PROFILES_ACTIVE: docker
      SERVER_PORT: "8080"
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/cases
      SPRING_DATASOURCE_USERNAME: cases
      SPRING_DATASOURCE_PASSWORD_FILE: /run/secrets/postgres-password
    secrets:
      - postgres-password
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:8080"
    networks:
      - backend
      - data

Important points:

  • JDBC host is postgres, not localhost;
  • container port is 8080;
  • host published port is only for developer/browser;
  • password is file-based;
  • database readiness is modeled, but app still needs retry.

28. Advanced Review Checklist

Before approving a Compose file, review these dimensions.

28.1 Model

  • Is services graph clear?
  • Are networks named after boundaries, not technologies?
  • Are volumes owned by clear services?
  • Are configs/secrets explicit?
  • Are optional services behind profiles?

28.2 Runtime

  • Are images pinned enough for the environment?
  • Are build contexts minimal?
  • Are commands/entrypoints safe for signal handling?
  • Are healthchecks realistic?
  • Are restart policies intentional?

28.3 Security

  • Are ports minimized?
  • Are ports bound to localhost if only local?
  • Are secrets excluded from source and image?
  • Is Docker socket avoided?
  • Is privileged mode avoided?
  • Are read-only/config mounts marked :ro?
  • Is non-root runtime used where possible?

28.4 Operability

  • Can docker compose config resolve cleanly?
  • Can docker compose up -d run default app?
  • Can logs be followed per service?
  • Can project be cleaned safely?
  • Does CI use unique project name?
  • Are labels sufficient for ownership?

28.5 Maintainability

  • Are anchors used moderately?
  • Are overrides understandable?
  • Are environment variables centralized without hiding critical values?
  • Is .env.example accurate?
  • Are service-specific settings visible near the service?

29. Practice Lab: Compose File Refactoring

29.1 Starting File

Refactor this:

version: "3.8"
services:
  app:
    build: .
    container_name: app
    ports:
      - "8080:8080"
    environment:
      DB_HOST: localhost
      DB_PASSWORD: password
    volumes:
      - .:/app
  db:
    image: postgres:latest
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: password

29.2 Problems

  • legacy version field not needed;
  • vague service names;
  • root repo build context;
  • fixed container_name;
  • DB host wrong inside container;
  • secret in plain text;
  • whole repo bind mount;
  • latest tag;
  • DB port published unnecessarily;
  • no volume for Postgres state;
  • no healthcheck;
  • no networks;
  • no project name;
  • no cleanup boundary.

29.3 Refactored File

name: compose-refactor-lab

services:
  api:
    build:
      context: ./services/api
      dockerfile: Dockerfile
    image: ghcr.io/acme/api:local
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_PASSWORD_FILE: /run/secrets/postgres-password
    secrets:
      - postgres-password
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - backend
      - data

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
    secrets:
      - postgres-password
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20
    networks:
      - data

networks:
  backend: {}
  data:
    internal: true

volumes:
  postgres-data: {}

secrets:
  postgres-password:
    file: ./secrets/postgres-password.txt

29.4 Self-Correction

Run:

docker compose config

Check:

  • Does DB_HOST resolve to service name?
  • Is DB password still visible in resolved config?
  • Are only intended ports published?
  • Does Postgres have persistent volume?
  • Does default project cleanup remove only project-owned objects?

30. Kaufman Deliberate Practice: Compose File Fluency

Exercise 1 — Resolve Model

Take any Compose file. Run:

docker compose config

Explain every resolved field that differs from source.

Exercise 2 — Remove One Host Port

Remove a DB/cache published port. Update dependent service to use service DNS. Confirm app still works.

Exercise 3 — Convert Secret

Move one password from environment literal to secret file. Update app to read _FILE variant or file path.

Exercise 4 — Split Dev Override

Move bind mounts and debug ports from compose.yaml to compose.dev.yaml.

Exercise 5 — Add Profile

Add adminer, mailhog, or grafana behind a profile. Confirm default up remains clean.

Exercise 6 — Introduce and Remove Anchor

Use x-common-labels for common labels. Then decide whether readability improved. Remove anchor if it hides too much.

Exercise 7 — CI Project Isolation

Run the same stack twice with two project names:

COMPOSE_PROJECT_NAME=lab-a docker compose up -d
COMPOSE_PROJECT_NAME=lab-b docker compose up -d

Observe container/network/volume names.

Clean both:

COMPOSE_PROJECT_NAME=lab-a docker compose down -v
COMPOSE_PROJECT_NAME=lab-b docker compose down -v

31. Key Takeaways

  • Compose file is a model contract, not a command dump.
  • Use modern compose.yaml without legacy version unless compatibility requires it.
  • Prefer explicit name locally and unique COMPOSE_PROJECT_NAME in CI.
  • image is artifact reference; build is artifact construction.
  • Build contexts should be minimal and intentional.
  • Service DNS is the stable internal identity.
  • ports publish to host; they are not required for service-to-service communication.
  • Named volumes represent persistent state boundary.
  • Bind mounts are host coupling and should be constrained.
  • .env, env_file, and environment serve different purposes.
  • Secrets/configs are runtime dependencies, not image content.
  • Profiles are good for optional tooling.
  • Anchors and x-* fields reduce duplication but can hide important behavior.
  • Always use docker compose config to inspect the final resolved model.
  • Security-sensitive settings must remain visible and reviewed.

32. What Comes Next

Part 017 will focus on dependency health and startup order:

  • depends_on semantics;
  • healthcheck design;
  • readiness vs liveness;
  • startup ordering;
  • shutdown ordering;
  • restart and recovery;
  • resilient app retry;
  • dependency failure drills.
Lesson Recap

You just completed lesson 16 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.