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.
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:
| Script | Compose file |
|---|---|
| imperative | declarative-ish |
| urutan command dominan | model dominan |
| sulit di-merge/review | bisa divalidasi dan direview |
| cleanup manual | project lifecycle |
| dependency tersebar | dependency 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
imagetag 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:
- Semakin dekat ke service declaration, semakin eksplisit.
- Jangan definisikan variable yang sama di banyak tempat tanpa alasan.
- Gunakan
docker compose configuntuk melihat resolved model. - 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:
portsadalah ingress dari host.exposeadalah 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_onmembantu startup order;healthcheckmemberi 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
deploysetting applies the same way in local Compose; - Swarm stack deployment interprets
deploymore 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.
20. Security-Related Service Fields
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: trueor 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
wgetexist incase-apiimage for healthcheck? - Are file permissions correct for secret/config mounts?
- Does
restart: unless-stoppedfit dev workflow? - Are anchors improving readability or hiding too much?
- Does
APP_VERSION:-localrisk 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
.envonly for non-critical dev if unavoidable; - provide
.env.examplewithout 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, notlocalhost; - 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
servicesgraph 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 configresolve cleanly? - Can
docker compose up -drun 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.exampleaccurate? - 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
versionfield 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_HOSTresolve 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.yamlwithout legacyversionunless compatibility requires it. - Prefer explicit
namelocally and uniqueCOMPOSE_PROJECT_NAMEin CI. imageis artifact reference;buildis artifact construction.- Build contexts should be minimal and intentional.
- Service DNS is the stable internal identity.
portspublish 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, andenvironmentserve 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 configto 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_onsemantics;- healthcheck design;
- readiness vs liveness;
- startup ordering;
- shutdown ordering;
- restart and recovery;
- resilient app retry;
- dependency failure drills.
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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.