Berkas katalog kini memuat hampir semua yang kita butuhkan — tetapi juga menumpuk dua kepentingan yang mulai saling mengganjal. Di satu sisi ada konfigurasi bersama yang dibaca seluruh tim: service, port, volume, healthcheck, gerbang dependensi. Di sisi lain ada kebiasaan personal Budi yang tertanam di berkas yang sama: command: node --watch src/server.js dan blok develop.watch yang hanya bermakna di mesin pengembang. Siti tidak pernah menjalankan watch di laptopnya, dan server produksi sama sekali tidak boleh menjalankan aplikasi dengan mode pemantau berkas. Menyunting satu berkas untuk setiap kebutuhan berarti berkas itu berhenti menjadi sumber kebenaran bersama; memelihara banyak salinan berarti perubahan kecil harus disalin ke mana-mana. Compose menutup section ini dengan mekanisme untuk keluar dari dilema itu: berkas override yang dimuat otomatis untuk personalisasi, aturan penggabungan yang bisa diprediksi, dan tag kendali untuk kasus yang menuntut pengecualian tegas.

compose.override.yaml Dimuat Otomatis #

Aturan dasarnya sederhana: ketika Compose dijalankan tanpa flag -f, ia memuat compose.yaml lalu mencari satu nama tambahan — compose.override.yaml — di folder yang sama, dan bila ditemukan, kedua berkas digabung menjadi satu konfigurasi. Pemilihan berkasnya mengikuti alur berikut:

Mari kita telaah diagram di atas: cabang kiri adalah mode harian — nama override yang istimewa dimuat tanpa diminta; cabang kanan adalah mode eksplisit — daftar berkas yang kita tulis sendiri menjadi satu-satunya yang dibaca, dan nama istimewa itu diabaikan. Budi memanfaatkan cabang kiri dengan memindahkan seluruh kebiasaan pengembangannya keluar dari compose.yaml. Service web pada berkas utama kembali bersih — build, ports, env_file, environment, restart, depends_on, healthcheck — sementara dua kunci pribadinya pindah ke compose.override.yaml yang baru dibuatnya:

yaml
services:
  web:
    command: node --watch src/server.js
    develop:
      watch:
        - path: ./src
          action: sync
          target: /app/src
          ignore:
            - node_modules/
        - path: ./package.json
          action: rebuild
        - path: ./Dockerfile
          action: rebuild

Kedua berkas itu kini menjalankan pembagian tugas yang tegas: compose.yaml milik semua orang, compose.override.yaml milik Budi. Hasil penggabungannya bisa diperiksa lewat perintah render yang sudah menjadi kebiasaan:

bash
$ docker compose config

Output yang Dihasilkan (dipotong):

text
name: katalog
services:
  db:
    ...
  web:
    build:
      context: /home/budi/katalog
    command:
      - node
      - --watch
      - src/server.js
    develop:
      watch:
        - action: sync
          ignore:
            - node_modules/
          path: /home/budi/katalog/src
          target: /app/src
        - action: rebuild
          path: /home/budi/katalog/package.json
        - action: rebuild
          path: /home/budi/katalog/Dockerfile
    environment:
      DATABASE_URL: postgres://katalog:rahasia123@db:5432/katalog
    ...

Mari kita telaah tiga normalisasi pada hasil render tersebut:

  1. command yang ditulis sebagai satu string menjadi daftar — renderer selalu mencetak perintah dalam exec form, memecah node --watch src/server.js menjadi tiga elemen; bentuk tulis bebas, bentuk hasil selalu daftar, persis perilaku environment yang kita saksikan pada Chapter Struktur compose.yaml.
  2. Path relatif dari kedua berkas diselesaikan lalu dicetak absolut — ./src pada override dan . pada build sama-sama diterjemahkan ke folder berkas utama, /home/budi/katalog; hasil gabungan tidak pernah menyimpan path relatif yang ambigu.
  3. environment tampil dengan nilai hasil interpolasi — rahasia123 tercetak polos, pengingat terakhir dari peringatan Chapter Variable Interpolation bahwa keluaran render bukan tempat untuk dipajang.

Uji ketegasan pembagian ini pun murah. Budi menyalakan stack dan menjalankan penyelarasan kedua:

bash
$ docker compose up -d

Output yang Dihasilkan:

text
[+] Running 4/4
 ✔ Network katalog_default     Running
 ✔ Container katalog-db-1      Running
 ✔ Container katalog-web-1     Running
 ✔ Container katalog-proxy-1   Running

Tidak ada satu pun baris Recreated — hasil penggabungan kedua berkas identik dengan konfigurasi lama, sehingga daemon tidak menemukan selisih apa pun untuk diwujudkan. Perpindahan command dan develop ke berkas baru tidak mengubah hasil akhir sedikit pun, dan itulah inti mekanismenya: override mengubah dari mana konfigurasi dibaca, bukan apa isinya.

Aturan Penggabungan Dua Berkas #

Penggabungan bukan penimpaan berkas demi berkas, melainkan peleburan per kunci konfigurasi. Aturannya mengikuti bentuk nilai tiap kunci:

Bentuk nilai pada kunciPerilaku penggabungan bawaanContoh kunci
Peta (mapping)digabung per kunci — deep merge; kunci yang ditulis ulang ditimpa nilai berkas belakanganenvironment, labels
Sequence umumdiganti total oleh berkas belakangancommand, env_file, volumes service
Sequence multi-nilaidigabungkan — union tanpa duplikatports, expose, dns, tmpfs
Skalarnilai berkas belakangan menangimage, restart, working_dir
Resource bernama level teratasdigabung per namavolumes, networks, configs, secrets

Tiga konsekuensi praktis dari tabel itu layak dihafal karena menentukan cara menulis override yang benar:

  1. Tulis environment dalam bentuk peta — peta digabung per kunci, sehingga override cukup menyebut variabel yang berubah; bentuk daftar akan mengganti total, dan seluruh variabel lain lenyap. Inilah pemenuhan janji TIP pada Chapter Environment dan env File yang menyuruh kita membiasakan bentuk peta sejak awal.
  2. volumes pada override harus daftar lengkap — sequence umum diganti total; override yang lupa mengulang db-data:/var/lib/postgresql/data akan membuat service db berjalan tanpa volume datanya, dan kehilangan itu tidak mengeluarkan satu galat pun.
  3. ports digabung, bukan diganti — menambah port baru pada override tidak menyingkirkan port dari berkas utama; keduanya diterbitkan bersamaan. Konsekuensi ini yang paling sering mengejutkan, dan menjadi alasan utama tag kendali pada bagian berikutnya ada.

Tag !reset dan !override #

Dua aturan penggabungan itu kadang bukan yang kita inginkan. Untuk dua titik friksi paling sering, Compose menyediakan tag khusus yang dituliskan langsung pada nilai: !reset menghapus sebuah kunci dari hasil penggabungan, dan !override memaksa sebuah sequence diganti total alih-alih digabung. Bentuk pemakaiannya:

yaml
services:
  web:
    build: !reset null
    ports: !override
      - "${BIND_IP:-0.0.0.0}:80:3000"

Mari kita telaah kedua tag di atas:

  1. build: !reset null — menghapus kunci build dari hasil merge seolah kunci itu tidak pernah ditulis di berkas mana pun; service akan sepenuhnya mengandalkan image dari registry. Tanpa tag ini, build dan image berdiri berdampingan — perilaku yang sah di development, tapi bencana di server yang tidak punya source code.
  2. ports: !override diikuti daftar nilai — mematikan aturan union untuk kunci ini; daftar pada berkas belakangan menjadi satu-satunya yang berlaku dan port dari berkas utama tidak pernah diterbitkan.
  3. Tag dibaca saat merge dan hilang dari hasil render — keluaran docker compose config hanya memuat nilai akhirnya; tag adalah instruksi bagi Compose, bukan bagian konfigurasi final.

Varian Produksi compose.prod.yaml #

Sekarang cerita lengkapnya. Budi ingin menguji varian produksi katalog pada server kecil — image ditarik dari registry, aplikasi melayani port standar 80, dan kebiasaan development ditinggalkan sepenuhnya. Draf pertamanya sederhana:

yaml
services:
  web:
    image: budi/katalog-web:${IMAGE_TAG:-latest}
    ports:
      - "${BIND_IP:-0.0.0.0}:80:3000"

Ia menjalankannya sebagai project terpisah — flag -p katalog-prod memberi identitas project yang berbeda dari stack development yang sedang menyala, persis pola -p katalog-uji yang kita praktikkan pada Chapter Service:

bash
$ docker compose -f compose.yaml -f compose.prod.yaml -p katalog-prod up -d web

Output yang Dihasilkan (dipotong):

text
[+] Building 9.1s (14/14) FINISHED
 ...
 => => naming to docker.io/budi/katalog-web:latest
 ✔ Container katalog-prod-db-1  Healthy
Error response from daemon: driver failed programming external connectivity on endpoint katalog-prod-web-1 (e6d94b2c8a15): Bind for 0.0.0.0:8080 failed: port is already allocated

Galat itu adalah konflik port lama kita, dan biang keroknya adalah aturan penggabungan ports yang baru saja kita pelajari. Mari kita telaah dua kesalahan draf pertama:

  1. ports digabung, bukan diganti — hasil merge memuat dua pemetaan: 8080:3000 dari compose.yaml dan 80:3000 dari draf; port host 8080 sedang dipegang katalog-web-1 milik project katalog, dan satu pintu host tidak boleh dipakai dua pemetaan — galat Bind for 0.0.0.0:8080 failed: port is already allocated yang kita kenal sejak Section Jaringan Container.
  2. build dari berkas utama masih aktif — output memuat baris build karena kunci itu tidak dihapus; di laptop Budi kebetulan tidak masalah karena Dockerfile hadir, tetapi di server tanpa source code, perintah yang sama akan gugur jauh sebelum menyentuh port.

Sisa kegagalan itu dibersihkan lebih dulu — docker compose -p katalog-prod down membongkar container db yang sempat menyala beserta networknya — lalu draf kedua menuntaskan keduanya:

yaml
services:
  web:
    build: !reset null
    image: budi/katalog-web:${IMAGE_TAG:-latest}
    ports: !override
      - "${BIND_IP:-0.0.0.0}:80:3000"
    restart: unless-stopped

  db:
    restart: unless-stopped

Hasil penggabungannya diverifikasi sebelum dijalankan:

bash
$ docker compose -f compose.yaml -f compose.prod.yaml config

Output yang Dihasilkan (dipotong):

text
services:
  web:
    environment:
      DATABASE_URL: postgres://katalog:rahasia123@db:5432/katalog
    image: budi/katalog-web:latest
    ports:
      - mode: ingress
        target: 3000
        published: "80"
        protocol: tcp
        host_ip: 0.0.0.0
    restart: unless-stopped
    ...

Mari kita telaah empat bukti pada hasil render tersebut:

  1. Kunci build tidak hadir sama sekali — !reset null menghapusnya dari hasil merge; service web kini murni bergantung pada image: budi/katalog-web:latest, dan di server sungguhan image itu ditarik dari registry tanpa satu berkas source pun.
  2. ports hanya memuat satu pemetaan — published: "80" dengan host_ip hasil interpolasi BIND_IP; port 8080 milik development tidak pernah diterbitkan — bukti !override mengalahkan aturan union.
  3. restart: unless-stopped — kebijakan bangkit otomatis untuk server yang boleh di-reboot, melunasi pola dari Chapter Restart Policy pada konteks produksi.
  4. command dan develop tidak hadir — keduanya kini hanya hidup di compose.override.yaml, dan berkas itu tidak ikut dimuat.

Poin keempat itu cukup penting untuk ditegaskan sendiri:

Eksekusi kedua pun berjalan mulus:

bash
$ docker compose -f compose.yaml -f compose.prod.yaml -p katalog-prod up -d web

Output yang Dihasilkan:

text
[+] Running 3/3
 ✔ Network katalog-prod_default  Created
 ✔ Container katalog-prod-db-1   Healthy
 ✔ Container katalog-prod-web-1  Started

Perintah ini hanya menyasar web — proxy tidak disebut dan tidak dinyalakan, pola target eksplisit yang kita kenal sejak Chapter Config dan Secret; db tetap ikut menyala karena gerbang depends_on menuntutnya sehat lebih dulu. Hasilnya bisa diuji dari luar:

bash
$ curl localhost:80

Output yang Dihasilkan:

text
<h1>Katalog Produk</h1>
<ul>
</ul>

Daftar produknya kosong — dan itu justru bukti isolasi yang bekerja: volume yang dipasang db project ini adalah katalog-prod_db-data, objek baru hasil initdb, sementara data development tetap tinggal aman di katalog_db-data milik project katalog. Prefix nama project memisahkan bukan hanya container dan network, melainkan seluruh kelas objek — persis aturan penamaan dari Chapter Volume dan Bind Mount.

Dua Project Berdampingan dengan -p #

Kedua project kini berbagi satu daemon, dan inventarisnya terbaca dalam satu tabel:

bash
$ docker compose ls

Output yang Dihasilkan:

text
NAME           STATUS        FILES
katalog        running(3)    /home/budi/katalog/compose.yaml
katalog-prod   running(2)    /home/budi/katalog/compose.yaml,/home/budi/katalog/compose.prod.yaml

Mari kita telaah keadaan berdampingan tersebut:

  1. Kolom FILES memperlihatkan asal konfigurasi tiap project — project katalog membaca satu berkas, sedangkan katalog-prod membaca gabungan dua berkas yang ditunjuk flag -f; satu folder yang sama memelihara dua wajah stack.
  2. Dua project berbagi satu ruang nomor port host — katalog memegang 8080 dan 8090, katalog-prod memegang 80; bila keduanya menuntut port yang sama, galat port is already allocated muncul sebagaimana tadi kita alami — jalan keluarnya adalah nilai port yang berbeda per varian, dan !override plus ${BIND_IP} adalah alat yang tepat untuk itu.
  3. -p bisa digantikan COMPOSE_PROJECT_NAME — variabel environment dari Chapter Variable Interpolation ini menyetel nama project tanpa flag, dan di server sungguhan Budi cukup menaruhnya di .env agar setiap perintah membawa identitas yang sama.

Sesi uji selesai; varian produksi dibongkar dengan perintah yang membawa identitas projectnya sendiri — docker compose -f compose.yaml -f compose.prod.yaml -p katalog-prod down — dan stack development di project katalog tetap menyala tanpa tersentuh.

Struktur Folder Final dan Kebijakan Commit #

Seluruh perjalanan section ini bermuara pada satu struktur folder yang final:

text
katalog/
├── compose.yaml
├── compose.override.yaml
├── compose.prod.yaml
├── .env
├── .env.example
├── .gitignore
├── Dockerfile
├── package.json
├── nginx.conf
├── db_password.txt
└── src/
    └── server.js

Nasib tiap berkas di repositori mengikuti pembagian perannya:

BerkasNasib di GitAlasan
compose.yaml, compose.prod.yamldi-commitkonfigurasi bersama seluruh tim dan seluruh lingkungan
Dockerfile, package.json, src/, nginx.confdi-commitaplikasi beserta resep buildnya
.env.example, .gitignoredi-committemplate nilai dan daftar pengecualian
compose.override.yamlTIDAK di-commitpersonalisasi per orang — tambahkan ke .gitignore
.env, db_password.txtTIDAK di-commitnilai per mesin dan rahasia — janji dari Chapter Environment dan env File dan Config dan Secret

Kebijakan “override tidak di-commit” punya pengecualian yang disengaja: bila konfigurasi development ingin dibagikan ke seluruh tim — watcher, port dev, service debug — simpan dengan nama lain seperti compose.dev.yaml dan muat eksplisit dengan docker compose -f compose.yaml -f compose.dev.yaml up -d, atau set COMPOSE_FILE=compose.yaml:compose.dev.yaml agar kombinasi itu menjadi bawaan shell. Nama yang tidak istimewa tidak pernah dimuat diam-diam, dan itu tepatnya yang kita inginkan: berkas yang mempengaruhi semua orang harus disebut dengan sengaja.

Section Docker Compose sampai di sini menuntaskan seluruh perjalanannya. Kita bermula dari deretan docker run dua belas baris yang panjang dan rapuh, lalu mengubahnya menjadi satu berkas deklaratif: service sebagai cetakbiru, image siap pakai dan hasil build sendiri, port dan network dengan DNS antar service, volume yang menjaga data tetap hidup, environment yang tersuntik dari .env, urutan start yang dijaga gerbang kesehatan, config dan secret sebagai berkas milik project, profiles untuk service yang hanya menyala saat dipanggil, interpolation agar satu berkas melayani banyak mesin, watch yang menekan siklus edit-uji menjadi hitungan detik, dan override yang memisahkan kebiasaan personal dari konfigurasi bersama. Stack katalog — tiga service, satu berkas utama, dua berkas pendamping — adalah bukti bahwa seluruh perangkat itu bekerja sebagai satu kesatuan. Namun katalog masih aplikasi latihan dua service sesungguhnya; aplikasi dunia nyata berdiri dengan arsitektur yang lebih kaya: reverse proxy sebagai satu-satunya pintu, beberapa aplikasi di belakangnya dengan database masing-masing, cache untuk menjawab pertanyaan yang berulang, dan antrian untuk pekerjaan yang tidak harus selesai saat itu juga. Merakit dan mengoperasikan arsitektur seperti itulah kerja Section Aplikasi Multi-Container — dimulai dari Chapter Arsitektur Multi-Container, lalu Reverse Proxy, Application dan Database, sampai Cache dan Queue, dengan seluruh perangkat Compose yang kini sudah lengkap di tangan kita.

Referensi #