Mengubah cara mengelola aplikasi multi-kontainer dari deretan docker run menjadi satu file deklaratif: memahami Compose Specification, image vs build, depends_on + healthcheck, dan DNS antar service, serta merakit stack web + API + PostgreSQL + Redis dengan satu docker compose up -d.

Setelah di episode 9 sebelumnya kita membedah jaringan — custom bridge, embedded DNS di 127.0.0.11, dan komunikasi antar kontainer lewat nama — pada episode kali ini kita menjawab pertanyaan yang menggantung sejak akhir episode itu: bagaimana mengelola banyak kontainer secara rapi? Sejauh ini setiap kontainer lahir dari satu perintah docker run yang panjang: volume, network, env, port, healthcheck. Kalikan dengan lima layanan — web, API, database, cache, reverse proxy — dan perintahnya menjadi puluhan baris yang mustahil diingat, apalagi dibagikan ke tim dan direview.
Ini bukan sekadar masalah kenyamanan. Di tim, apa yang tidak terdokumentasi dianggap tidak ada. Serangkaian docker run yang hanya ada di kepala seorang engineer adalah single point of failure: orang itu cuti, lalu tidak ada yang tahu bagaimana stack berjalan. Compose mengubah itu: seluruh aplikasi menjadi kode yang bisa di-commit, di-review, dan direproduksi identik di mesin siapa pun. Ini juga fondasi dari segala yang kita bangun setelahnya — hingga deployment Swarm di episode 16-17 tetap memakai file Compose.
Di episode ini kita akan memahami apa itu Compose dan Compose Specification, membedah struktur compose.yaml — services, image/build, ports, environment, env_file, volumes, networks, depends_on, healthcheck — lalu menguasai CLI Compose v2 dan merakit satu stack nyata: web + API + PostgreSQL + Redis. Kita mulai dari konsep, karena yang menentukan bukan menghafal sintaks, melainkan memahami apa yang sedang kalian deskripsikan.
Docker Compose adalah alat untuk mendefinisikan dan menjalankan aplikasi multi-kontainer dalam satu file — compose.yaml (nama lama docker-compose.yml juga tetap didukung). Cara berpikirnya berbeda total dari docker run: di docker run kalian mengetik langkah-langkah (imperatif); di Compose kalian menulis keadaan akhir yang diinginkan (deklaratif) — dan Docker yang memutuskan bagaimana mencapai keadaan itu.
Bandingkan dua pendekatan untuk stack kecil yang sama:
docker network create webnet
docker volume create pgdata
docker run -d --name postgres \
--network webnet -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:16-alpine
docker run -d --name redis --network webnet redis:7-alpine
docker run -d --name api --network webnet \
-e DATABASE_URL=postgres://postgres:secret@postgres:5432/app \
-e REDIS_URL=redis://redis:6379 -p 3000:3000 my-api:1.0
docker run -d --name web --network webnet -p 8080:80 my-web:1.0Kedua blok menghasilkan keadaan yang sama, tapi yang satu dijalankan dengan lima perintah dan harus diingat urutannya, yang lain dijalankan dengan satu docker compose up -d dan terdokumentasi selamanya. Perhatikan juga bahwa di Compose, network webnet dan volume pgdata dideklarasikan sebagai entitas tingkat atas — tidak perlu membuatnya secara manual.
compose.yaml mengikuti Compose Specification — dokumen standar yang dikelola bersama oleh komunitas, bukan milik satu vendor. Konsekuensi praktisnya: file yang kalian tulis hari ini bisa dijalankan oleh Docker Compose, dan oleh tool lain yang mematuhi spesifikasi yang sama. Inilah mengapa field-field seperti services, volumes, dan networks konsisten di seluruh dokumentasi resmi.
Struktur inti file Compose terdiri dari tiga kunci top-level yang paling sering dipakai:
services — deskripsi setiap kontainer yang akan dijalankan (web, api, db, dan seterusnya).volumes — deklarasi named volume yang dipakai bersama antar service.networks — deklarasi network yang menghubungkan service-service tersebut.Service adalah bintang utamanya. Setiap entri di bawah services setara dengan satu docker run — dan hampir semua flag docker run punya padanan field di Compose. Trik paling cepat untuk menguasai Compose adalah terus bertanya: "field ini menggantikan flag docker run yang mana?"
Setiap service harus tahu image mana yang dipakai. Ada dua cara — dan keduanya bisa digabungkan:
image: <nama> — ambil image yang sudah jadi dari registry (misal postgres:16-alpine). Ini untuk layanan pihak ketiga: database, cache, reverse proxy.build: ... — bangun image dari Dockerfile saat up dijalankan. Ini untuk aplikasi kalian sendiri.Untuk service yang di-build, field build mendukung opsi yang berpadanan dengan docker build:
services:
api:
build:
context: ./api
dockerfile: Dockerfile.prod
target: production
args:
NODE_ENV: production
image: my-api:1.0
ports:
- "3000:3000"context — direktori build context (apa yang dikirim ke daemon; ingat episode 5).dockerfile — nama Dockerfile alternatif jika bukan Dockerfile.target — stage mana yang di-build. Ini kekuatan multi-stage build episode 7: kalian membangun image production tanpa membawa tool build-nya.args — setara --build-arg; hanya berlaku saat build, tidak masuk runtime.Perhatikan kombinasi build + image: Docker membangun dari context, lalu menandai hasilnya my-api:1.0. Jika hanya image tanpa build, service tinggal di-pull; jika hanya build tanpa image, image dibangun tanpa tag eksplisit. Memakai keduanya memberi keuntungan: image bernama rapi sehingga bisa di-push ke registry (episode 12).
Tiga field berikut menangani hal-hal yang paling sering salah dipahami pemula.
ports — mem-publish port kontainer ke host, setara -p. Sintaks pendek "3000:3000" berarti host:3000 → kontainer:3000. Sintaks panjang memisahkan semuanya eksplisit:
services:
web:
build: ./web
ports:
- "8080:80"
api:
build: ./api
ports:
- target: 3000
published: 3000
protocol: tcpSatu hal krusial: port host hanya bisa dipakai satu service. Jika web mem-publish 8080:80 dan service lain juga mem-publish 8080, up akan gagal dengan "port is already allocated". Di produksi, pola yang benar justru membatasi siapa yang mem-publish port — hanya reverse proxy yang berhadapan dengan dunia luar (episode 20), sementara API dan database hanya terlihat di network internal.
environment — variabel env yang di-inject saat runtime, setara -e. Nilainya bisa literal, atau merujuk env host dengan ${VAR} — mekanisme interpolation yang akan kita bedah tuntas di episode 11.
env_file — membaca variabel dari sebuah file, setara --env-file. Cocok untuk memindahkan banyak variabel keluar dari file Compose:
services:
api:
build: ./api
env_file:
- .env.api
environment:
NODE_ENV: production
LOG_LEVEL: infoIngat hierarki di antara keduanya: env_file memuat variabel dari file, environment menetapkan langsung di file Compose dan menang jika terjadi konflik nama. Keduanya juga berbeda dari .env — file .env dipakai Compose untuk interpolasi nilai di dalam file Compose itu sendiri, bukan untuk disuntikkan ke kontainer. Ini salah satu sumber kebingungan terbesar di ekosistem Compose; kita bedah perbandingannya secara utuh di episode 11.
Dua hal yang kita bangun manual di episode 8 dan 9 sekarang dideklarasikan:
services:
postgres:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- dbnet
api:
build: ./api
depends_on:
postgres:
condition: service_healthy
networks:
- dbnet
- webnet
volumes:
pgdata:
networks:
dbnet:
webnet:volumes di service: pgdata:/var/lib/postgresql/data memasang named volume pgdata ke direktori data PostgreSQL. Named volume dideklarasikan sekali di level atas (volumes: pgdata:), bisa dipakai banyak service. Ini persis pelajaran episode 8: tanpa ini, data database lenyap saat stack di-recreate.networks di service: service bisa bergabung ke satu atau lebih network. Perhatikan api ada di dua network — pola zona episode 9: database hanya di dbnet, web dan api di webnet. Kalian mendesain keamanan lewat topologi network ini.Jika tidak mendeklarasikan networks, Compose otomatis membuat network default default dan menautkan semua service ke dalamnya — semua service saling terlihat. Untuk prototipe itu praktis; untuk produksi, deklarasikan secara eksplisit agar zonanya jelas.
Masalah paling klasik di multi-kontainer: race condition saat startup. API butuh database yang sudah siap menerima koneksi — tapi "sudah start" tidak berarti "sudah siap". Kontainer PostgreSQL bisa saja sudah berjalan, sementara server-nya masih recovery. Jika API mencoba koneksi pada detik itu, hasilnya connection refused, dan tanpa retry, aplikasi crash.
depends_on mengatur urutan, tapi ada dua bentuk dengan kekuatan berbeda:
# Bentuk sederhana: hanya urutan start — TIDAK menunggu kesiapan
services:
api:
build: ./api
depends_on:
- postgres
- redis
# Bentuk lengkap: menunggu status sehat
services:
api:
build: ./api
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthyBentuk pertama hanya memastikan postgres dan redis dibuat dan mulai sebelum api — sama sekali tidak menjamin database sudah menerima koneksi. Bentuk kedua menunggu sampai status healthcheck service menjadi healthy. Untuk itu, service tujuan harus mendefinisikan healthcheck:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10stest — perintah yang dijalankan; pg_isready adalah tool bawaan PostgreSQL untuk mengecek kesiapan. Exit code 0 = sehat.interval — jarak antar pemeriksaan (5 detik).timeout — batas satu pemeriksaan sebelum dianggap gagal (3 detik).retries — berapa kali gagal berturut-turut sebelum status unhealthy (5).start_period — masa tenggang: selama 10 detik pertama kegagalan tidak dihitung, memberi waktu database booting.Dengan pasangan healthcheck + condition: service_healthy, urutan menjadi siap, baru lanjut — bukan sekadar start dulu. Inilah yang membedakan stack yang rawan crash-loop saat boot dari stack yang tenang setiap kali di-up.
Compose v2 adalah perintah bawaan docker compose (bukan lagi docker-compose terpisah yang sudah usang). Rangkaian perintah inti yang wajib dikuasai:
docker compose up -d
docker compose ps
docker compose logs -f api
docker compose down
docker compose down -vdocker compose up -d — membangun (jika ada build), membuat network/volume, lalu menjalankan seluruh service di background. -d = detach; tanpa -d, log semua service menyatu di terminal.docker compose ps — status tiap service: running, exited, atau health.docker compose logs -f — ikuti log; bisa dibatasi satu service (-f api) atau dengan --tail 50.docker compose down — menghentikan dan menghapus kontainer + network (bukan image, bukan volume).docker compose down -v — seperti di atas plus menghapus named volumes. Hati-hati: ini menghapus data database secara permanen.docker compose stop/start — menghentikan dan menghidupkan tanpa menghapus apa pun (kontainer tetap ada).docker compose restart api — restart satu service.docker compose exec api sh — masuk ke kontainer service api, setara docker exec.docker compose build — membangun ulang image secara eksplisit, berguna saat Dockerfile berubah.Important
Setiap kali kalian mengubah compose.yaml, perubahan tidak berlaku otomatis ke kontainer yang sudah berjalan. Kalian harus menjalankan ulang docker compose up -d — Compose mendeteksi perubahan konfigurasi dan me-recreate service yang terdampak. Ini sumber kebingungan nomor satu: "saya ubah env, kenapa tidak berefek?" — jawabannya selalu: belum di-up ulang.
Sekarang kita rakit contoh yang akan menemani kita sepanjang sisa series: web (Nginx menyajikan statik), API (Node.js), PostgreSQL, dan Redis — lengkap dengan healthcheck dan depends_on:
services:
web:
build:
context: ./web
ports:
- "8080:80"
depends_on:
api:
condition: service_healthy
networks:
- webnet
api:
build:
context: ./api
dockerfile: Dockerfile.prod
target: production
environment:
DATABASE_URL: postgres://postgres:secret@postgres:5432/app
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
networks:
- webnet
- dbnet
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
networks:
- dbnet
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- dbnet
volumes:
pgdata:
networks:
webnet:
dbnet:Perhatikan alur depends_on yang membentuk rantai kesiapan: postgres dan redis harus healthy → api baru start; api harus healthy → web baru start. Stack ini tidak akan crash saat boot hanya karena koneksi database belum siap — persis masalah yang kita singgung di awal.
Coba perhatikan baris paling menarik di file di atas:
environment:
DATABASE_URL: postgres://postgres:secret@postgres:5432/app
REDIS_URL: redis://redis:6379Tidak ada IP di mana pun. postgres dan redis adalah nama service — dan ini bekerja berkat pelajaran episode 9. Compose membuat user-defined bridge network (webnet, dbnet), dan pada network itu setiap service otomatis terdaftar sebagai nama DNS. Embedded DNS di 127.0.0.11 me-resolve nama itu ke IP kontainer terkait.
Buktikan dari dalam kontainer api:
docker compose exec api getent hosts postgres
docker compose exec api getent hosts redis172.19.0.2 postgres
172.19.0.3 redisIP-nya bahkan mungkin berbeda dari docker network inspect sebelumnya — dan itu tidak masalah. Karena aplikasi selalu menyebut postgres, bukan 172.19.0.2, maka ketika postgres di-recreate (IP berubah), aplikasi tetap bekerja tanpa perubahan konfigurasi. Ini adalah pelajaran terpenting episode 9 yang sekarang menjadi nyata di Compose: jangan pernah menulis IP, tulis nama service.
Satu catatan: nama service berbeda dari nama kontainer. Compose menamai kontainer otomatis (<direktori>-<service>-1); yang terdaftar sebagai DNS adalah nama service — itu yang harus kalian tulis di kode aplikasi. Set container_name manual sebaiknya dihindari karena membuat service tidak bisa di-scale (dua replika tidak bisa berbagi satu nama kontainer).
depends_on tanpa condition: service_healthy. Hanya mengatur urutan start, bukan kesiapan. API yang start sebelum database siap akan crash-loop. Gunakan bentuk lengkap + healthcheck di setiap dependency.
docker compose down -v menghapus data. -v menghapus named volumes — termasuk pgdata. Satu kali salah ketik, seluruh data database lenyap. Pahami kapan memakai down (aman) vs down -v (berisiko).
Port host bentrok. Dua service yang mem-publish port host yang sama membuat up gagal. Batasi siapa yang berhadapan dengan host.
Service tidak bisa saling memanggil. Jika API tidak bisa menjangkau postgres, kemungkinan besar keduanya tidak berada di network yang sama, atau nama yang dipakai salah. Pastikan service berada di satu network dan memakai nama service, bukan IP.
Ubah compose.yaml tapi lupa up ulang. Perubahan konfigurasi tidak diterapkan ke kontainer berjalan. Setelah mengedit, selalu jalankan docker compose up -d.
Semua service default di satu network. Tanpa deklarasi networks, semua service masuk network default dan saling terlihat — database bisa diakses dari web. Di produksi, pisahkan zona (pola webnet/dbnet di atas).
Pada episode 10 ini kita telah mengubah cara bekerja dari perintah ke deklarasi: memahami bahwa Docker Compose mengubah multi-kontainer menjadi satu file deklaratif yang terdokumentasi dan reproducible, membedah Compose Specification — services (dengan image atau build + context/dockerfile/target/args), ports, environment, env_file, volumes, networks — memahami perbedaan krusial antara depends_on sederhana dan depends_on dengan condition: service_healthy, menguasai CLI Compose v2 (up -d, ps, logs -f, down -v, stop/start/restart, exec), merakit stack web + API + PostgreSQL + Redis lengkap dengan healthcheck, dan membuktikan bahwa DNS antar service memakai nama, bukan IP.
Inti yang harus kalian bawa:
docker run = imperatif. Deklaratif terdokumentasi, bisa direview, bisa direproduksi.image untuk layanan jadi, build untuk aplikasi sendiri; kombinasikan agar image bernama rapi.depends_on harus berpasangan dengan healthcheck + condition: service_healthy untuk menunggu kesiapan, bukan sekadar urutan.down -v menghapus volume — waspadai data.up ulang.Sekarang kalian punya satu file yang mendeskripsikan seluruh aplikasi. Tapi file ini masih statis: nilainya hardcoded, dan tidak ada cara memakai konfigurasi berbeda untuk development vs production. Di episode 11 selanjutnya kita akan melepas keterbatasan itu: Advanced Docker Compose Features — interpolasi .env, multi-file compose (compose.prod.yaml), profiles untuk service opsional, extends dan YAML anchors, serta validasi dengan docker compose config. Pastikan stack episode 10 ini masih hidup — kita akan mengembangkannya. Sampai jumpa di episode 11!