Docker Compose Local Development Setup yang Tidak Bikin Frustrasi

by Marcus Chen

Saya pernah bergabung di proyek yang punya docker-compose.yml sepanjang 400 baris. Tidak ada komentar. Tidak ada urutan yang masuk akal. Port-nya hardcoded semua. Setiap kali onboarding developer baru, butuh setengah hari hanya untuk membuat environment-nya jalan.

Itu bukan masalah Docker-nya. Itu masalah tidak ada yang pernah duduk serius memikirkan struktur dari docker compose local development setup mereka.

Artikel ini bukan tutorial "apa itu Docker Compose" — kalau kamu belum tahu itu, baca dulu dokumentasi resminya. Di sini saya mau berbagi pendekatan konkret yang saya pakai untuk proyek solo maupun tim kecil: file yang bisa dibaca manusia, environment yang reproducible, dan workflow yang tidak membuatmu ingin lempar laptop.

Kenapa Setup Default Docker Compose Itu Masalah

Docker Compose versi terbaru (v2, yang sudah bundled di Docker Desktop 4.x ke atas) sudah jauh lebih baik dari v1. Tapi mayoritas tutorial di internet masih mengajarkan pola lama: satu file docker-compose.yml raksasa, semua config di situ, depends_on yang tidak benar-benar menunggu service siap, dan secret yang di-hardcode langsung di YAML.

Masalah nyatanya:

  • Port collision — developer A pakai port 5432 untuk Postgres, developer B juga. Konflik.
  • State yang tidak konsisten — volume tidak di-prune, container lama masih jalan, data stale.
  • Tidak ada pemisahan antara dev dan CI — config yang sama dipakai di dua konteks berbeda, padahal kebutuhannya beda.
  • Secret bocorPOSTGRES_PASSWORD=password123 langsung di file yang masuk ke Git.

Semua ini bisa diatasi dengan struktur yang lebih disiplin.

Struktur File yang Saya Pakai

Ini layout direktori yang saya pakai untuk proyek backend Node.js + Postgres + Redis:

project-root/
├── docker-compose.yml          # base config, shared services
├── docker-compose.override.yml # dev-specific overrides (gitignored atau tidak)
├── docker-compose.ci.yml       # untuk CI pipeline
├── .env.example                # template, masuk Git
├── .env                        # actual secrets, TIDAK masuk Git
└── services/
    ├── app/
    │   └── Dockerfile
    └── worker/
        └── Dockerfile

Konsepnya: docker-compose.yml adalah definisi apa yang ada. docker-compose.override.yml adalah bagaimana kita menjalankannya di lokal. Docker Compose secara otomatis merge dua file ini kalau kamu jalankan docker compose up tanpa flag tambahan.

Untuk CI, kamu eksplisit:

docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d

Anatomy docker-compose.yml yang Bersih

Ini contoh docker-compose.yml base yang saya anggap wajar:

# docker-compose.yml
version: "3.9"

services:
  app:
    build:
      context: ./services/app
      dockerfile: Dockerfile
    environment:
      NODE_ENV: ${NODE_ENV:-development}
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - backend

  cache:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    networks:
      - backend

volumes:
  postgres_data:
  redis_data:

networks:
  backend:
    driver: bridge

Beberapa hal yang sengaja saya lakukan:

  1. Tidak ada port yang di-expose di base file. Port mapping masuk ke override file. Ini mencegah konflik kalau ada yang jalankan beberapa proyek sekaligus.
  2. healthcheck di Postgres. depends_on tanpa condition: service_healthy itu bohong — container bisa "started" tapi Postgres belum siap terima koneksi.
  3. Semua credential dari environment variable. Tidak ada yang hardcoded.
  4. Network eksplisit. Bukan karena wajib, tapi karena lebih mudah di-debug kalau ada masalah network isolation.

Override File untuk Development Lokal

# docker-compose.override.yml
services:
  app:
    volumes:
      - ./services/app:/app          # hot reload
      - /app/node_modules            # jangan overwrite node_modules dari container
    ports:
      - "3000:3000"
    command: npm run dev             # nodemon atau tsx watch
    environment:
      DEBUG: "app:*"

  db:
    ports:
      - "5432:5432"                  # expose ke host untuk akses dari TablePlus/DBeaver

  cache:
    ports:
      - "6379:6379"

File ini boleh masuk Git atau tidak, tergantung tim. Kalau semua developer pakai setup yang sama, masukkan. Kalau ada yang pakai port berbeda atau butuh config personal, tambahkan ke .gitignore dan sediakan .override.example.yml.

Trik volume - /app/node_modules itu penting. Tanpa itu, volume mount dari host akan menimpa node_modules yang ada di dalam container — dan kalau host kamu macOS atau Windows, binary native module-nya bisa salah arsitektur.

File .env yang Benar

.env.example yang masuk Git:

# .env.example
NODE_ENV=development
POSTGRES_USER=appuser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=appdb

.env yang tidak masuk Git (tambahkan ke .gitignore):

# .env — JANGAN COMMIT
NODE_ENV=development
POSTGRES_USER=appuser
POSTGRES_PASSWORD=s3cur3p4ss!
POSTGRES_DB=appdb

Docker Compose otomatis membaca .env dari direktori yang sama dengan file compose-nya. Tidak perlu --env-file flag kecuali kamu mau file-nya di lokasi lain.

Satu hal yang sering diabaikan: jangan pakai nama variable yang bentrok dengan variable sistem. USER, HOME, PATH — itu sudah ada di environment host kamu. Kalau kamu set USER=postgres di .env, hal aneh bisa terjadi.

Workflow Sehari-hari yang Masuk Akal

Ini command yang saya pakai hampir setiap hari, bukan yang ada di tutorial:

# Mulai semua service, rebuild kalau ada perubahan Dockerfile
docker compose up --build -d

# Lihat log app saja, bukan semua service
docker compose logs -f app

# Masuk ke container database untuk debugging
docker compose exec db psql -U appuser -d appdb

# Reset database — hapus volume, recreate
docker compose down -v && docker compose up -d

# Lihat resource usage per container
docker stats $(docker compose ps -q)

Satu kebiasaan yang menghemat banyak waktu: jangan pernah jalankan docker compose down tanpa tahu konsekuensinya. down tanpa flag akan menghapus container dan network tapi bukan volume. down -v menghapus volume juga — data Postgres kamu hilang. Saya sudah pernah kena ini lebih dari sekali.

Menangani Service yang Butuh Waktu Lama Startup

depends_on dengan condition: service_healthy itu bagus, tapi tidak semua image punya healthcheck bawaan. Contoh untuk service yang tidak punya healthcheck:

services:
  app:
    depends_on:
      migration:
        condition: service_completed_successfully

  migration:
    image: your-app-image
    command: npm run migrate
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}

Pola ini menjalankan migration sebagai service terpisah yang harus selesai (service_completed_successfully) sebelum app utama start. Lebih bersih dari script entrypoint yang penuh sleep 5 && npm run migrate.

condition: service_completed_successfully baru tersedia di Docker Compose v2.1 ke atas. Kalau tim kamu masih pakai v1, ini tidak akan jalan.

Perbandingan Pendekatan: Satu File vs Multi-File

Aspek Satu File Multi-File (base + override)
Kesederhanaan awal ✅ Mudah ❌ Perlu setup lebih
Skalabilitas ❌ Cepat jadi berantakan ✅ Terstruktur
Reuse untuk CI ❌ Perlu duplikasi atau if-else ✅ File CI terpisah
Onboarding developer baru ❌ Harus baca semua ✅ Override jelas tujuannya
Debugging config ❌ Semua campur ✅ Jelas mana yang dev-only

Untuk proyek solo yang sederhana (satu app, satu database), satu file masih oke. Begitu ada lebih dari 3 service atau butuh CI yang berbeda, multi-file jauh lebih masuk akal.

Integrasi dengan Makefile

Ini opsional tapi saya selalu pakai. Makefile sebagai interface yang konsisten:

.PHONY: up down logs shell db-shell reset

up:
	docker compose up --build -d

down:
	docker compose down

logs:
	docker compose logs -f app

shell:
	docker compose exec app sh

db-shell:
	docker compose exec db psql -U $${POSTGRES_USER} -d $${POSTGRES_DB}

reset:
	@echo "WARNING: Ini akan menghapus semua data!"
	@read -p "Lanjut? (y/N): " confirm && [ "$$confirm" = "y" ]
	docker compose down -v
	docker compose up --build -d

Sekarang make up, make logs, make reset — semua orang di tim pakai interface yang sama. Tidak perlu ingat flag apa yang harus dipakai.

Kalau kamu tertarik dengan cara saya mengelola dotfiles dan tooling sehari-hari, ada pembahasan lebih detail di artikel tentang setup terminal dan shell untuk developer.

Kesimpulan

Docker compose local development setup yang baik bukan soal panjang atau pendeknya file YAML. Ini soal kejelasan: siapa yang baca file ini 6 bulan lagi (kemungkinan besar kamu sendiri) harus langsung mengerti apa yang terjadi.

Tiga hal yang bisa kamu lakukan besok:

  1. Pisahkan base file dan override file kalau kamu belum melakukannya.
  2. Tambahkan healthcheck ke database service dan pakai condition: service_healthy di depends_on.
  3. Pindahkan semua credential ke .env dan pastikan .env ada di .gitignore.

Kalau kamu mau setup yang lebih jauh — misalnya integrasi dengan Traefik untuk local HTTPS atau multi-project networking — itu topik yang lebih dalam. Tapi tiga langkah di atas sudah cukup untuk membuat docker compose local development setup kamu jauh lebih tidak menyebalkan dari rata-rata yang ada di luar sana.

Coba jalankan docker compose config setelah kamu merge semua file — itu akan menampilkan konfigurasi final yang sudah di-resolve. Berguna untuk debugging dan memastikan variable substitution-nya benar.