Membebaskan compose.yaml dari nilai hardcoded: interpolasi ${VAR} lewat .env, pemisahan development vs production dengan multi-file compose, profiles untuk service opsional, reuse konfigurasi lewat extends dan YAML anchors, serta validasi konfigurasi akhir dengan docker compose config.

Setelah di episode 10 sebelumnya kita mengubah seluruh stack — web, API, PostgreSQL, Redis — menjadi satu file deklaratif compose.yaml, pada episode kali ini kita menghadapi keterbatasan berikutnya: file itu masih statis. Tag image ditulis tangan, password database menempel di file, dan tidak ada cara memakai konfigurasi berbeda untuk mesin development vs server produksi. Di dunia nyata, perbedaan ini selalu ada: di development kita ingin bind mount agar kode bisa hot-reload, mem-publish port debug, dan menyalakan tool bantu seperti phpMyAdmin; di produksi kita ingin image versi tertentu, resource limit, dan tidak ada satu pun tool dev yang menyala.
Menguasai fitur-fitur lanjutan Compose bukan soal hafalan sintaks — ini soal membangun satu source of truth untuk seluruh environment. Tim yang baik tidak menyalin compose.yaml lalu mengubah-ubahnya manual di setiap mesin; mereka menulis satu basis konfigurasi yang bisa dikomposisikan: base untuk semua, override untuk development, file tambahan untuk production, dan variable untuk hal yang benar-benar berbeda antar environment. Hasilnya: tidak ada lagi konfigurasi yang "jauh" dari kode, dan tidak ada lagi "loh kok jalan di laptop saya?" yang tidak bisa dijelaskan.
Di episode ini kita akan membedah tiga tempat menyimpan variabel (.env, env_file, environment), menguasai interpolasi ${VAR}, memisahkan development dan production dengan multi-file compose, menyalakan dan mematikan service opsional lewat profiles, menghilangkan duplikasi dengan extends dan YAML anchors, dan menutup dengan docker compose config — cermin yang menunjukkan konfigurasi akhir sebelum satu pun kontainer dibuat.
Kebingungan terbesar di ekosistem Compose berakar pada tiga hal yang namanya mirip tapi perannya beda. Mari kita pisahkan dengan tegas:
| Nama | Dibaca oleh | Tujuan | Masuk kontainer? |
|---|---|---|---|
.env | Compose saat parsing file | Menyediakan nilai untuk interpolasi ${VAR} di dalam compose.yaml | Tidak otomatis |
env_file | Kontainer saat runtime | Menyuntikkan variabel ke environment kontainer (setara --env-file) | Ya |
environment | Kontainer saat runtime | Menetapkan variabel langsung di file Compose | Ya |
Model mental yang menyelamatkan kalian: .env dibaca Compose, env_file dan environment dibaca kontainer. .env tidak pernah, dengan sendirinya, menghasilkan variabel di dalam kontainer — ia hanya menyediakan bahan untuk interpolasi. Sebaliknya, env_file dan environment menghasilkan variabel yang benar-benar kalian lihat saat menjalankan env di dalam kontainer.
Analogi dapurnya: .env adalah catatan resep yang dibaca juru masak (Compose) sebelum mulai; env_file dan environment adalah bahan yang ditaruh di meja (kontainer). Catatan resep boleh saja memuat bahan, tapi bahan tidak otomatis pindah ke meja hanya karena tertulis di catatan — kecuali juru masak sengaja menaruhnya di sana.
${VAR}: File Compose yang DinamisInterpolasi adalah kemampuan Compose mengganti ${VAR} di dalam file Compose dengan nilai dari shell environment atau file .env. Ini yang membuat satu file bisa beradaptasi. Perhatikan bagaimana nilai yang sama bisa "diputar" di tempat yang berbeda:
services:
api:
image: my-app:${TAG:-latest}
ports:
- "${HOST_PORT:-3000}:3000"
environment:
NODE_ENV: ${NODE_ENV:-development}
DATABASE_URL: postgres://${DB_USER}:${DB_PASS}@postgres:5432/appTAG=1.2.0
HOST_PORT=8080
NODE_ENV=production
DB_USER=app
DB_PASS=super-secretSaat docker compose up dijalankan, Compose mengganti ${TAG} menjadi 1.2.0, ${HOST_PORT} menjadi 8080, dan seterusnya. Hasil akhirnya bisa kalian periksa tanpa menjalankan apa pun:
docker compose configservices:
api:
environment:
DATABASE_URL: postgres://app:super-secret@postgres:5432/app
NODE_ENV: production
image: my-app:1.2.0
networks:
default: null
ports:
- mode: ingress
published: 8080
target: 3000Perhatikan: .env di atas memang memuat DB_PASS, dan nilainya tidak muncul di kontainer begitu saja — ia muncul karena compose.yaml secara eksplisit menyebut ${DB_PASS} di environment. Inilah nuansa yang membedakan orang yang paham Compose dari yang menebak-nebak.
Interpolasi bukan sekadar ${VAR}. Ada empat bentuk yang masing-masing menjawab pertanyaan berbeda:
${VAR} — substitusi langsung; jika tidak ada nilai, menjadi string kosong.${VAR:-default} — pakai default jika VAR kosong atau tidak disetel. Paling sering dipakai untuk nilai opsional.${VAR-default} — pakai default hanya jika VAR tidak disetel; string kosong tetap dipertahankan.${VAR:?error message} — wajib. Jika VAR kosong atau tidak disetel, Compose berhenti dan menampilkan pesan error. Ini adalah pengaman terbaik untuk nilai yang kritis (password, versi).services:
api:
image: my-app:${APP_VERSION:?APP_VERSION wajib disetel}
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
SECRET_KEY: ${SECRET_KEY:?Secret key dibutuhkan untuk produksi}Kebiasaan yang sangat disarankan: untuk variabel yang kritis, jangan beri default senyap — beri ${VAR:?pesan}. Kegagalan yang berisik jauh lebih baik daripada berhasil dengan nilai kosong yang baru ketahuan di tengah malam. Untuk variabel yang nyaman (log level, port), default senyap dengan :- adalah pilihan tepat.
Ketika variabel yang sama didefinisikan di banyak tempat, ada hierarki yang sudah ditetapkan. Untuk sumber interpolasi (nilai yang dipakai Compose mengganti ${VAR}), urutannya dari tertinggi:
export TAG=...).--env-file (jika dipakai)..env di direktori proyek (default, jika --env-file tidak dipakai).:- di dalam file Compose.Untuk variabel yang akhirnya masuk ke dalam kontainer, prioritasnya (tertinggi ke terendah):
docker compose run -e KEY=value (CLI).environment atau env_file yang nilainya hasil interpolasi dari shell/.env.environment di file Compose.env_file di file Compose.ENV di dalam image (Dockerfile).Dua konsekuensi praktis: (1) kalian bisa menimpa nilai .env dari shell tanpa mengubah file — ini yang dimanfaatkan CI/CD untuk meng-inject secret; (2) environment selalu mengalahkan env_file jika namanya sama — jadi jangan mendefinisikan variabel yang sama di keduanya, karena hanya akan membingungkan.
Sekarang kita masuk ke pemisahan environment. Compose mendukung beberapa file yang di-merge. Secara default, saat docker compose up dijalankan di sebuah direktori, Compose membaca dua file: compose.yaml (basis) dan — jika ada — compose.override.yaml (otomatis di-merge). File override biasanya berisi penyesuaian untuk development lokal yang tidak seharusnya mengotori file basis:
services:
api:
build:
context: ./api
volumes:
- ./api:/app
- /app/node_modules
ports:
- "9229:9229"
environment:
NODE_ENV: development
DEBUG: "*"File override di atas menambahkan bind mount untuk hot-reload, mem-publish port debugger Node (9229), dan memaksa mode development — semuanya tanpa menyentuh compose.yaml. Tim bisa meng-commit compose.yaml sebagai kontrak bersama, sementara setiap engineer memakai override pribadinya sendiri. (File override biasanya masuk .gitignore bila berisi path lokal.)
Note
Ingat aturan merge dasar (kita bedah rincinya sebentar lagi): environment adalah map sehingga di-merge per kunci — NODE_ENV: development menimpa production di file basis tanpa menghapus variabel lain. ports adalah list yang digabung (appended) — port 9229 ditambahkan di samping port yang sudah ada, bukan menggantikan.
Untuk production, pola umumnya adalah file ketiga yang di-merge secara eksplisit dengan flag -f:
docker compose -f compose.yaml -f compose.prod.yaml up -dservices:
api:
image: ghcr.io/my-org/my-api:${APP_VERSION}
deploy:
resources:
limits:
cpus: "0.50"
memory: 512M
restart: unless-stopped
postgres:
volumes:
- pgdata-prod:/var/lib/postgresql/data
volumes:
pgdata-prod:Poin penting yang sering mengejutkan: begitu kalian memakai -f, Compose berhenti mencari file default dan override otomatis. docker compose -f compose.yaml -f compose.prod.yaml up -d memakai persis dua file itu — compose.override.yaml diabaikan. Ini justru bagus: produksi tidak boleh kebetulan mendapat tweak development milik engineer lokal. Alternatif lain, daftar file bisa disetel lewat environment variable COMPOSE_FILE (misal di .bashrc atau CI).
Aturan main yang sehat: compose.yaml berisi yang benar-benar umum; compose.override.yaml berisi tweak development; file production dipilih eksplisit dengan -f. Jangan pernah membuat production bergantung pada keberadaan file yang tidak kalian sebut.
Agar multi-file tidak jadi bom waktu, kalian harus tahu persis bagaimana dua file digabung. Aturannya konsisten dan bisa diringkas:
image, restart, mem_limit) — file yang disebut belakangan menggantikan seluruhnya.environment, labels, build.args) — di-merge per kunci: kunci yang sama diambil dari file belakangan, kunci yang berbeda dipertahankan dari file awal.ports, volumes, secrets, configs yang punya aturan keunikan: entri di-merge berdasarkan kunci uniknya (misalnya mount path untuk volumes), sehingga path yang sama digantikan, yang baru ditambahkan.command, entrypoint, dan healthcheck.test selalu diganti, tidak di-append — kalian tidak ingin dua command menumpuk.Konsekuensi yang paling sering membuat orang tersandung: karena ports di-append, maka untuk mengganti port di override, kalian harus menulis ulang port lama — atau memakai penanda !override untuk menggantikan total:
services:
api:
ports: !override
- "8443:443"Tanpa !override, 8443:443 akan menambah port yang sudah ada di file basis — bukan menggantinya. Kalau kalian memakai !override, file basis perlu menyebutnya agar jelas: ini fitur Compose yang relatif baru, jadi cek dulu versi Compose kalian (gunakan docker compose version).
Beberapa service tidak selalu diinginkan. Tool bantu development seperti phpMyAdmin, Mailhog, atau debugger bukan bagian dari production, tapi sangat berguna di lokal. Alih-alih menghapus dan menulis ulang, gunakan profiles:
services:
api:
image: my-api:1.0
ports:
- "3000:3000"
db-admin:
image: phpmyadmin:5
profiles: [dev-tools]
ports:
- "8081:80"
depends_on:
- db
mailhog:
image: mailhog/mailhog
profiles: [dev-tools]
ports:
- "8025:8025"Aturannya sederhana: service tanpa profiles selalu start; service yang punya profiles hanya start ketika profile itu diaktifkan:
docker compose up -d
docker compose --profile dev-tools up -d
COMPOSE_PROFILES=dev-tools docker compose up -ddocker compose up -d — hanya api (dan db di balik layar) yang start.docker compose --profile dev-tools up -d — service ber-profile ikut start.COMPOSE_PROFILES=dev-tools — cara yang sama lewat environment variable, cocok untuk CI.docker compose --profile "*" up — mengaktifkan semua profile (berguna untuk menguji semua service).Ini pola yang sangat bersih: satu file Compose memuat seluruh arsitektur, dan profiles menjadi "saklar" apakah tool pendukung ikut menyala. Jangan bingung ketika sebuah service "tidak muncul" di ps — cek dulu apakah ia ber-profile dan profile-nya belum diaktifkan.
Ketika beberapa service berbagi konfigurasi, ada dua alat untuk menghindari salin-tempel: extends dan YAML anchors. Keduanya menyelesaikan masalah yang mirip dengan pendekatan berbeda.
extends — mewarisi seluruh definisi service dari service lain (bisa dari file yang sama atau file terpisah):
services:
base:
image: node:20-alpine
environment:
NODE_ENV: production
restart: unless-stopped
api:
extends: base
command: ["node", "server.js"]
ports:
- "3000:3000"
worker:
extends: base
command: ["node", "worker.js"]api dan worker mewarisi image, environment, dan restart dari base — lalu menambahkan bagian yang berbeda. Hasilnya: perubahan environment cukup dilakukan sekali. Catatan: base sendiri tidak ikut dijalankan kecuali didefinisikan secara eksplisit sebagai service (perilaku yang justru kita inginkan di sini).
YAML anchors — fitur murni YAML untuk berbagi blok dalam satu file. Anchor ditandai &nama (definisi), dipakai dengan *nama (nilai), dan bisa di-merge ke dalam map dengan <<:
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
api:
image: my-api:1.0
logging: *default-logging
worker:
image: my-worker:1.0
logging: *default-loggingBlok x-logging adalah extension field (awalan x- disarankan dan diabaikan Compose) yang kita definisikan sekali, lalu *default-logging menyematkannya ke beberapa service. Dengan <<, kalian bisa menggabungkan seluruh map ke dalam service — pola fragmen:
x-base: &base
restart: unless-stopped
networks: [backend]
services:
api:
<<: *base
image: my-api:1.0
worker:
<<: *base
image: my-worker:1.0Kapan memilih yang mana? extends lebih cocok untuk mewarisi seluruh service (terutama antar file di monorepo), karena ia paham struktur service Compose. Anchors lebih cocok untuk blok konfigurasi kecil yang dibagikan dalam satu file (logging, restart, network). Aturan praktis: mulai dengan anchor untuk blok kecil, naik ke extends begitu kebutuhan melintasi file.
Semua fitur di atas — interpolasi, multi-file, profiles — digabung secara ajaib oleh Compose sebelum kontainer dibuat. Lalu bagaimana kalian memastikan hasilnya benar sebelum mengeksekusi apa pun? Gunakan docker compose config:
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose config --services
docker compose config --profiles
docker compose config --environmentdocker compose config — memvalidasi file, melakukan interpolasi dan merge, lalu mencetak konfigurasi final. Jika ada kesalahan YAML, kunci yang hilang, atau nilai yang tidak valid, perintah ini gagal dengan pesan error — tanpa menyentuh satu pun kontainer.--services — daftar service yang benar-benar akan dibuat (setelah mempertimbangkan profiles).--profiles — daftar profile yang tersedia.--environment — variabel dan nilai yang dipakai untuk interpolasi.Ini adalah "dry run" terbaik yang dimiliki Compose. Biasakan alur ini di CI sebelum deploy: jalankan docker compose config sebagai langkah validasi. Jika file Compose rusak, kalian mengetahuinya dalam hitungan detik di pipeline — bukan di server produksi saat up gagal di tengah malam.
Mengira .env otomatis masuk kontainer. .env hanya bahan interpolasi. Untuk memasukkan variabel ke kontainer, pakai env_file atau environment yang menyebut ${VAR} secara eksplisit.
Mendefinisikan variabel sama di environment dan env_file. environment selalu menang — duplikasi ini hanya membuat tim bingung membaca konfigurasi mana yang benar-benar berlaku.
Memakai -f tapi mengira override otomatis tetap di-load. Begitu -f dipakai, compose.override.yaml tidak lagi dibaca. Ini fitur, bukan bug — tapi hanya berguna jika kalian tahu.
Mengira list di-merge dengan cara "replace". ports, volumes, dan list lain di-append kecuali memakai !override. Kalau tidak sengaja, port produksi bisa "menumpuk" dengan port development. Gunakan docker compose config untuk memeriksa hasil akhir.
Menyimpan secret di .env dan men-commit-nya. .env mudah ter-scan oleh tool keamanan dan sering bocor. .env adalah tempat konfigurasi; untuk secret sungguhan gunakan secret management (Swarm secrets nanti di episode 17) atau inject dari CI/CD — dan pastikan .env ada di .gitignore.
Service ber-profile "hilang". Bukan hilang — profile-nya tidak aktif. Periksa docker compose config --profiles dan --services sebelum menebak.
${VAR} yang tidak disetel menjadi string kosong senyap. Untuk nilai kritis, wajib pakai ${VAR:?pesan} agar kegagalan terdengar.
Pada episode 11 ini kita telah membebaskan Compose dari file statis: memahami perbedaan tegas antara .env (dibaca Compose untuk interpolasi), env_file, dan environment (dibaca kontainer) beserta hierarki prioritasnya, menguasai interpolasi ${VAR} dengan bentuk default :- dan wajib :?, memisahkan development vs production dengan multi-file compose (compose.override.yaml otomatis vs compose.prod.yaml dengan -f), memahami aturan merge (scalar diganti, map di-merge, list di-append, dan !override untuk mengganti total), menyalakan service opsional dengan profiles, membunuh duplikasi dengan extends dan YAML anchors (&, *, <<), serta memvalidasi semuanya dengan docker compose config.
Inti yang harus kalian bawa:
.env untuk konfigurasi yang di-interpolasi; env_file/environment untuk variabel yang masuk kontainer.--env-file > .env > default — inilah yang dipakai CI untuk override.compose.yaml, tweak development di compose.override.yaml, file production dipanggil eksplisit dengan -f.profiles adalah saklar untuk service opsional — service tanpa profile selalu start.docker compose config sebelum up — dry run yang menyelamatkan produksi.Sekarang kalian bisa mengelola stack di banyak environment dari satu kode. Tapi ada satu pertanyaan yang belum kita jawab: dari mana image-image itu berasal? Sejauh ini kita menarik postgres:16-alpine, nginx, dan teman-temannya dari registry tanpa pernah bertanya bagaimana registry bekerja — dan bagaimana kalian mendistribusikan image buatan kalian sendiri. Di episode 12 selanjutnya kita akan masuk ke Docker Registry & Image Distribution: tata nama image, docker login, docker tag, push dan pull, digest immutable, serta membangun private registry sendiri dengan TLS dan basic auth. Sampai jumpa di episode 12!