Membuat Aplikasi Multi-Container

Membuat Aplikasi Multi-Container

Bitnesia Sep 12, 2026 9 EN

Sebuah aplikasi web yang siap pakai jarang cukup dengan satu service saja. Umumnya ada layanan aplikasi utama, database untuk menyimpan data, dan cache untuk mempercepat akses data yang sering dipakai berulang. Bab ini mempraktikkan cara mendefinisikan aplikasi semacam itu lewat compose.yaml, mulai dari mendeklarasikan setiap service yang membentuk aplikasi, menghubungkannya lewat network, menyimpan data lewat volume, sampai mengatur konfigurasi lewat environment variable. Contoh yang dipakai sepanjang bab ini adalah aplikasi dengan tiga service: app sebagai layanan utama, db sebagai database PostgreSQL, dan cache sebagai Redis, yang lazim ditemukan Developer maupun Sysadmin/DevOps Engineer di lapangan saat menyiapkan environment development maupun production.

17.1 Definisi Layanan

Elemen services adalah bagian inti dari compose.yaml karena di situlah setiap container yang membentuk aplikasi didefinisikan. Setiap key di bawah services mewakili satu layanan, lengkap dengan image atau instruksi build yang dipakai, port yang dibuka, serta dependensi terhadap layanan lain.

17.1.1 Struktur services dalam compose.yaml

Berikut definisi tiga service yang dipakai sebagai contoh sepanjang bab ini.

services:
  app:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine

  cache:
    image: redis:7-alpine

Instruksi build: . pada service app membangun image dari Dockerfile di direktori saat ini, sementara db dan cache langsung memakai image resmi dari Docker Hub lewat instruksi image. Compose Specification tetap mewajibkan instruksi image di setiap service, kecuali service tersebut sudah menyertakan instruksi build sebagai gantinya. Kalau kita menuliskan instruksi build dan image sekaligus pada satu service, Compose akan mencoba menarik image tersebut lebih dulu sesuai pull policy yang berlaku, baru membangunnya dari source kalau image belum tersedia.

17.1.2 Kondisi Startup dengan depends_on

Instruksi depends_on pada contoh di atas memastikan Compose menjalankan db dan cache lebih dulu sebelum app mulai berjalan. Namun, urutan start container tidak sama dengan kesiapan service di dalamnya. PostgreSQL butuh waktu inisialisasi sebelum benar-benar siap menerima koneksi, sementara depends_on versi singkat di atas cuma menunggu container-nya start, bukan menunggu service di dalamnya benar-benar siap.

Untuk kasus yang butuh jaminan kesiapan service, gunakan sintaks panjang depends_on dengan kondisi service_healthy, lalu kombinasikan dengan healthcheck pada service yang dituju.

services:
  app:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

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

  cache:
    image: redis:7-alpine

Kondisi service_healthy membuat app menunggu sampai healthcheck milik db berstatus healthy, sementara kondisi service_started pada cache cukup menunggu container-nya start seperti perilaku default. Di lapangan, Developer sering menemukan kasus aplikasi yang crash di awal startup karena mencoba konek ke database yang belum siap saat pertama kali menyusun multi-container app, dan kombinasi healthcheck dengan service_healthy ini jadi solusi yang jauh lebih andal dibanding sekadar menambah delay manual di script startup aplikasi.

17.1.3 Kebijakan restart

Selain urutan startup, setiap service idealnya juga punya kebijakan restart yang jelas lewat instruksi restart, terutama untuk service yang harus tetap hidup di production.

services:
  app:
    build: .
    restart: unless-stopped
    ports:
      - "3000:3000"

Nilai unless-stopped membuat Docker otomatis me-restart container kalau proses di dalamnya berhenti sendiri, kecuali kita menghentikan container tersebut manual lewat docker compose stop. Pilihan lain yang tersedia adalah "no" (default, tidak pernah restart otomatis), always, dan on-failure yang cuma restart kalau container keluar dengan exit code error. Sysadmin/DevOps Engineer yang mengelola service di production umumnya memilih unless-stopped atau always untuk service inti seperti database dan aplikasi utama, supaya service otomatis pulih setelah crash tanpa perlu intervensi manual.

17.2 Networks dalam Compose

Setiap service yang didefinisikan lewat Compose butuh cara untuk saling terhubung. Elemen networks mengatur bagaimana koneksi antar service ini terbentuk, termasuk kapan sebuah service perlu diisolasi dari service lain yang tidak berkepentingan.

17.2.1 Default Network Otomatis

Kalau kita tidak mendeklarasikan elemen networks sama sekali, Compose otomatis membuat satu default network bertipe bridge untuk seluruh project, dan setiap service yang tidak menyertakan networks secara eksplisit otomatis terhubung ke network default tersebut. Inilah sebabnya pada contoh compose.yaml di bagian sebelumnya, service app sudah bisa langsung menjangkau db dan cache cukup lewat nama service-nya sebagai hostname, tanpa konfigurasi network tambahan apa pun.

docker compose up -d
docker network ls

Perintah docker network ls di atas akan menampilkan network baru dengan nama mengikuti pola <nama-project>_default, sesuai nama direktori project atau nilai name yang ditentukan lewat opsi -p pada Docker Compose.

17.2.2 Custom Network dan Isolasi Service

Network default cocok untuk aplikasi sederhana, tapi aplikasi yang lebih kompleks sering butuh isolasi, misalnya database yang cuma boleh diakses oleh service aplikasi, bukan oleh service lain yang menghadap publik. Kita bisa memenuhi kebutuhan ini lewat custom network yang dideklarasikan di elemen tingkat atas networks.

services:
  app:
    build: .
    ports:
      - "3000:3000"
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    networks:
      - backend

  cache:
    image: redis:7-alpine
    networks:
      - backend

networks:
  frontend:
  backend:

Konfigurasi di atas menempatkan db dan cache hanya di network backend, sementara app terhubung ke frontend maupun backend sekaligus karena perlu menjangkau keduanya. Kalau kita menambahkan service lain di frontend saja (misalnya reverse proxy), service tersebut tidak akan bisa menjangkau db atau cache secara langsung karena tidak berada di network backend yang sama. Pola isolasi semacam ini jadi praktik umum bagi Sysadmin/DevOps Engineer untuk mengurangi attack surface, karena Attacker yang berhasil membobol service di frontend tidak otomatis punya jalur jaringan langsung ke database di backend.

17.2.3 Alias Network Antar Service

Selain nama service itu sendiri, sebuah service bisa diberi hostname tambahan pada network tertentu lewat opsi aliases. Fitur ini berguna saat aplikasi mengharapkan hostname tertentu yang berbeda dari nama service di Compose, misalnya saat migrasi dari environment lama yang sudah men-hardcode hostname database tertentu.

services:
  db:
    image: postgres:16-alpine
    networks:
      backend:
        aliases:
          - database
          - postgres-primary

networks:
  backend:

Konfigurasi di atas membuat service lain di network backend bisa menjangkau db lewat tiga hostname sekaligus: nama service db itu sendiri, database, atau postgres-primary. Verifikasi resolusi DNS antar service bisa dicek langsung dari dalam container lewat perintah exec.

docker compose exec app getent hosts database

Kalau perintah di atas gagal menampilkan alamat IP, periksa kembali apakah service yang dituju memang berada di network yang sama dengan alias yang benar, karena alias hanya berlaku pada network tempat kita mendefinisikan alias tersebut.

17.3 Volumes dalam Compose

Container yang di-restart atau dihapus kehilangan seluruh data yang tersimpan di dalam writable layer-nya, sehingga kita wajib menyimpan data yang perlu bertahan lebih lama, seperti data database, lewat volume. Compose memudahkan pengelolaan volume ini lewat elemen tingkat atas volumes yang bisa dipakai bersama oleh beberapa service sekaligus.

17.3.1 Named Volume untuk Data Persisten

Sebaiknya kita menyimpan data PostgreSQL pada contoh sebelumnya lewat named volume supaya tidak hilang setiap kali container db dihapus dan dibuat ulang.

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

volumes:
  db-data:

Kita mendeklarasikan named volume db-data di elemen tingkat atas volumes, lalu memasangnya ke service db lewat sintaks singkat <nama-volume>:<path-di-container>. Menurut Compose Specification, kita wajib mendeklarasikan volume yang direncanakan dipakai ulang oleh lebih dari satu service di elemen tingkat atas ini; path host yang cuma dipakai satu service saja bisa langsung kita tuliskan di dalam definisi service tanpa deklarasi tingkat atas. Kalau volume db-data belum ada saat kita menjalankan docker compose up, Docker otomatis membuatkannya.

17.3.2 Bind Mount dalam Compose

Selain named volume, Compose juga mendukung bind mount lewat sintaks yang sama, dengan path host sebagai sumbernya. Bind mount ini lazim dipakai Developer untuk keperluan live reload saat development, supaya perubahan kode di host langsung terlihat di dalam container tanpa perlu build ulang image.

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src
      - /app/node_modules

Baris pertama me-mount direktori ./src di host ke /app/src di dalam container, sehingga perubahan file source langsung terbaca oleh proses yang berjalan di container. Baris kedua yang cuma berisi path container tanpa path host disebut anonymous volume. Pola ini mencegah direktori node_modules hasil instalasi dependency di dalam image ikut tertimpa oleh isi direktori node_modules di host yang mungkin kosong atau berisi dependency versi berbeda.

Untuk kontrol yang lebih rinci, misalnya menjadikan sebuah mount bersifat read-only, gunakan sintaks panjang berbentuk mapping alih-alih string tunggal.

services:
  app:
    build: .
    volumes:
      - type: bind
        source: ./config
        target: /app/config
        read_only: true

17.3.3 Berbagi Volume Antar Service

Named volume yang sama juga bisa dipasang ke lebih dari satu service sekaligus, berguna saat beberapa service perlu mengakses data yang sama, misalnya service backup yang perlu membaca langsung data PostgreSQL tanpa lewat koneksi database.

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

  backup:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data:ro
    entrypoint: ["sleep", "infinity"]

volumes:
  db-data:

Opsi :ro di akhir path pada service backup memasang volume yang sama dalam mode read-only, sehingga service backup bisa membaca data tanpa risiko menulis atau merusak data yang sedang dipakai service db. Verifikasi isi volume yang dipakai bersama ini lewat perintah docker compose exec.

docker compose exec backup ls -la /var/lib/postgresql/data

Ingat bahwa menghapus volume lewat docker compose down -v bersifat destruktif dan menghapus seluruh data di named volume secara permanen, termasuk volume yang dipakai bersama beberapa service seperti contoh di atas. Pastikan sudah ada strategi backup data database yang berjalan di production sebelum menjalankan perintah tersebut di environment mana pun yang menyimpan data penting.

17.4 Environment Variable

Sebaiknya kita tidak meng-hardcode konfigurasi seperti kredensial database, mode aplikasi, atau alamat service lain langsung ke dalam image, supaya image yang sama bisa dipakai ulang di development, staging, maupun production cukup dengan mengganti konfigurasinya saja. Compose menyediakan beberapa cara untuk mengatur environment variable ini.

17.4.1 Instruksi environment dalam compose.yaml

Cara paling langsung adalah menuliskan environment variable lewat instruksi environment di dalam definisi service.

services:
  app:
    build: .
    environment:
      - NODE_ENV=production
      - DB_HOST=db
      - DB_PORT=5432
      - CACHE_HOST=cache

Instruksi environment di atas juga bisa kita tuliskan dalam bentuk mapping key-value alih-alih list; keduanya sama-sama sah menurut Compose Specification.

services:
  app:
    build: .
    environment:
      NODE_ENV: production
      DB_HOST: db
      DB_PORT: "5432"
      CACHE_HOST: cache

Contoh kedua sengaja membungkus nilai DB_PORT dengan tanda kutip karena YAML akan membaca angka tanpa kutip sebagai tipe numerik, bukan string, dan sebagian aplikasi butuh environment variable dalam bentuk string secara eksplisit.

17.4.2 Memisahkan Konfigurasi dengan env_file

Menuliskan banyak environment variable langsung di compose.yaml cepat membuat file itu berantakan, apalagi kalau variabel tersebut berisi data sensitif seperti password. Instruksi env_file memindahkan daftar variabel ini ke file terpisah.

services:
  db:
    image: postgres:16-alpine
    env_file:
      - ./db.env
# db.env
POSTGRES_USER=appuser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=appdb

Instruksi env_file juga menerima lebih dari satu file sekaligus dalam bentuk list, dan menurut Compose Specification, Compose memproses file-file tersebut berurutan, dengan file belakangan menimpa nilai dari file sebelumnya kalau ada key yang sama. Instruksi environment pada service selalu mengalahkan prioritas nilai yang berasal dari env_file, jadi environment efektif berfungsi sebagai override kalau kedua instruksi ini dipakai bersamaan pada satu service.

Karena file seperti db.env ini lazim berisi kredensial, jangan pernah menambahkannya ke version control. Tambahkan db.env ke file .gitignore dan sediakan file contoh seperti db.env.example berisi nama variabel tanpa nilai sensitif, supaya Developer lain di tim tetap tahu variabel apa saja yang perlu diisi tanpa ikut membocorkan kredensial asli.

17.4.3 File .env dan Variable Substitution

Berbeda dari env_file yang mengisi environment variable ke dalam container, file .env di direktori yang sama dengan compose.yaml berfungsi untuk variable substitution, yaitu mengisi nilai variabel yang dipakai di dalam compose.yaml itu sendiri sebelum Compose memproses file tersebut.

# .env
POSTGRES_PASSWORD=changeme
APP_PORT=3000
services:
  app:
    build: .
    ports:
      - "${APP_PORT}:3000"

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

Saat kita menjalankan docker compose up, Compose otomatis membaca file .env di direktori project, lalu mengganti setiap placeholder ${APP_PORT} dan ${POSTGRES_PASSWORD} dengan nilai dari file tersebut sebelum konfigurasi benar-benar dipakai. Cek hasil substitusi ini tanpa benar-benar menjalankan container lewat perintah docker compose config.

docker compose config

Perintah di atas menampilkan versi compose.yaml yang sudah final, lengkap dengan seluruh nilai variabel yang sudah disubstitusi, sehingga jadi cara paling praktis untuk memastikan tidak ada placeholder yang lupa diisi sebelum kita benar-benar menjalankan aplikasi. Kalau sebuah variabel yang dirujuk lewat ${...} tidak ditemukan baik di file .env maupun di environment shell, Compose akan mengisinya dengan string kosong dan menampilkan peringatan, bukan menghentikan proses dengan error, jadi kelalaian ini mudah lolos tanpa disadari kalau kita tidak memeriksa hasil docker compose config lebih dulu.