Ringkasan Utama
Cara deploy Docker Compose di production: tulis service dengan restart: unless-stopped, ikat port ke 127.0.0.1, simpan konfigurasi di file .env, pasang mem_limit dan health check, lalu jalankan dengan docker compose up -d --wait. Untuk update, ganti tag image, pull, dan up lagi. Untuk rollback, kembalikan tag lama.
Aplikasimu jalan di laptop dengan docker compose up. Kamu salin file-nya ke server, jalankan perintah yang sama, dan semuanya tampak beres. Sampai terminal ditutup dan aplikasinya ikut mati, atau server reboot dan tidak ada yang hidup lagi, atau kamu memindai server dan menemukan port aplikasi terbuka ke seluruh internet. File Compose yang cukup untuk development masih kekurangan sekitar lima baris yang dibutuhkan production.
Panduan ini membangun file itu dalam enam langkah, dengan pilihan yang sama seperti docker-compose.yml di balik situs ini: restart policy, port yang hanya di loopback, batas memori yang dipasangkan dengan batas heap Node, health check, dan rotasi log. Contoh di bawah ditulis ulang dengan nama placeholder, jadi tidak ada host atau secret asli. Setiap flag dicek ke dokumentasi Docker yang tercantum di akhir. Sisi CI (mem-build image dan mem-push ke registry) ada di panduan deploy Next.js ke VPS: cara deploy Next.js ke VPS dengan Docker dan GitHub Actions. Ikuti langkahnya berurutan, lalu pakai tabel error di akhir kalau ada yang tidak sesuai.
Ingin paham konsepnya dulu?
Tulisan ini panduan praktiknya. Lesson gratis kami menjelaskan kenapa tiap baris ada, lengkap dengan diagram interaktif container yang menyentuh batas memorinya: Docker Compose Production: Batas Memori, Health Check, Restart. Lesson itu juga punya kuis singkat untuk mengecek yang kamu ingat.
Yang kamu butuhkan
Siapkan empat hal ini sebelum mulai. Semua nama di bawah adalah placeholder, ganti dengan milikmu.
- Server Linux dengan Docker Engine dan plugin Compose terpasang, bisa diakses lewat SSH (contoh memakai user bernama deploy dan alamat 203.0.113.10).
- Image yang sudah di-push ke registry, misalnya ghcr.io/example-user/my-app dengan tag yang tidak pernah berubah, seperti hash commit pendek. Kalau registry-nya private, jalankan docker login di server satu kali.
- Aplikasi Node.js yang listen di port 3000 di dalam container dan menjawab /robots.txt atau URL ringan lainnya. Sesuaikan angkanya untuk bahasa lain.
- Satu folder di server untuk project ini, misalnya /home/deploy/my-app, yang berisi tepat dua file: docker-compose.yml dan .env.
Langkah 1: tulis service dengan restart policy dan port loopback
Mulai dari tiga baris yang biasanya dilewati file development. restart: unless-stopped menyalakan container lagi setelah exit apa pun dan setelah server reboot, tetapi membiarkannya mati kalau kamu sendiri yang menghentikannya. Baris ports menentukan siapa yang bisa menjangkau aplikasi: kalau alamat host dihilangkan, Docker mengikat ke semua interface, dan dokumentasinya sendiri memperingatkan hal ini bisa melewati aturan firewall host.
# /home/deploy/my-app/docker-compose.yml
services:
web:
image: ghcr.io/example-user/my-app:3f9c2a1
# Wrong: no policy = the app stays dead after a crash or a reboot
# Right: restart after any exit, but not after YOU stopped it
restart: unless-stopped
ports:
# Wrong: "3002:3000" binds to every interface (0.0.0.0)
# Right: host address first, then host port, then container port
- "127.0.0.1:3002:3000"
# Start it and check the PORTS column
cd /home/deploy/my-app
docker compose up -d
docker compose ps
curl -I http://127.0.0.1:3002Mengikat ke 127.0.0.1 berarti hanya server itu sendiri yang bisa menjangkau aplikasi, jadi harus ada reverse proxy di depannya. Itu tugas nginx, dibahas di panduan nginx reverse proxy untuk Docker dan Cloudflare. Sebelum nginx siap, uji dari server itu sendiri dengan curl.
Output yang diharapkan: docker compose ps menampilkan web dengan pemetaan 127.0.0.1:3002->3000/tcp, bukan 0.0.0.0. curl ke 127.0.0.1:3002 di server menjawab, sedangkan port yang sama di alamat publik server tidak menjawab dari luar.
Dokumentasi port publishing Docker memperingatkan bahwa port tanpa alamat host bisa dijangkau dari luar host. Aturan firewall buatanmu sendiri mungkin tidak menghentikannya, karena trafik ke port yang di-publish dirutekan sebelum aturan itu. Perbaiki baris ports-nya, jangan mengandalkan aturan.
Langkah 2: pindahkan konfigurasi ke .env dan cek hasil resolusinya
Jangan mengedit YAML di setiap rilis. Taruh nilai yang berubah (tag image, port host, secret aplikasi) di file .env di sebelah docker-compose.yml. File itu mengerjakan dua hal yang berbeda. Compose membaca .env di folder project untuk mengisi placeholder berbentuk dolar-kurung-kurawal di dalam YAML, dan baris env_file meneruskan variabel dari file yang sama ke dalam container. Pada sintaks interpolasi, tanda titik dua dan minus memberi nilai default: nilai dipakai kalau sudah diisi dan tidak kosong, kalau tidak, default yang berlaku.
# /home/deploy/my-app/.env (never commit this file)
IMAGE=ghcr.io/example-user/my-app:3f9c2a1
APP_PORT=3002
DATABASE_URL=postgres://app:CHANGE_ME@db.internal:5432/app
# docker-compose.yml, using those values
services:
web:
# Compose fills the variable from .env; after :- is the fallback
image: ${IMAGE:-ghcr.io/example-user/my-app:latest}
restart: unless-stopped
# env_file passes the SAME variables into the container itself
env_file: .env
environment:
NODE_ENV: production
ports:
- "127.0.0.1:${APP_PORT:-3002}:3000"
# Run these from the folder that holds docker-compose.yml
echo ".env" >> .gitignore
docker compose config
docker compose config --quiet && echo "file is valid"Selalu jalankan docker compose config sebelum start pertama. Perintah itu mencetak file setelah variabelnya terisi, jadi salah ketik terlihat sebagai nilai kosong, bukan container yang berperilaku aneh. Jaga .env tetap di luar git, dan baca panduan menyimpan secret Docker dengan aman sebelum memasukkan password asli: dokumentasi Docker sendiri menyarankan untuk tidak meneruskan nilai sensitif lewat environment variable.
Output yang diharapkan: Output config menampilkan image dengan tag yang kamu pin dan baris ports sebagai 127.0.0.1:3002:3000. Kalau ada variabel yang tidak ada di .env, Compose memperingatkan bahwa variabel itu belum di-set dan mengganti dengan nilai kosong.
Langkah 3: pasang batas memori, dan batas heap yang lebih rendah di image
Secara default container tidak punya batas resource, jadi satu kebocoran memori bisa membuat semua yang lain di server kekurangan. mem_limit memasang batas keras, dan saat container menyentuhnya kernel mematikan prosesnya. Docker lalu melaporkan exit code 137. Untuk aplikasi Node, pasangkan dengan --max-old-space-size yang membatasi heap V8. Pasangan ini penting karena heap bukan satu-satunya memori yang dipakai proses: runtime dan buffer native ada di luarnya.
# docker-compose.yml, inside services.web
mem_limit: 512m
cpus: "1.0"
# Dockerfile, in the final stage. Keep it clearly BELOW mem_limit:
# 384 MB is 75% of 512 MB, leaving room for memory outside the heap.
# Wrong: --max-old-space-size=512 (equal to the limit, the kernel wins first)
ENV NODE_OPTIONS="--max-old-space-size=384"
# After the next start, read real numbers once
docker stats --no-stream
# If a container died, ask Docker whether the OOM killer did it
docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' my-app-webSitus ini memasang mem_limit 512m dan batas heap 384, sekitar 75 persen, dan komentar di Dockerfile-nya mencatat pemakaian stabil terukur 130 MB. Batas heap itu pernah disetel sama dengan limit, yang akan membuat kernel mematikan container sebelum V8 sempat bereaksi. Pakai rasio yang sama sebagai titik awal, lalu amati angka sebenarnya dengan docker stats. Kebiasaan lain di sisi image ada di Docker containerization best practices.
Output yang diharapkan: docker stats --no-stream menampilkan web dengan kolom MEM USAGE / LIMIT, dan kolom limit menunjukkan batas yang kamu pasang. Tepat setelah start, pemakaian harus jauh di bawahnya.
Langkah 4: tambahkan health check dan batasi ukuran log
Health check adalah perintah yang dijalankan Docker di dalam container secara terjadwal. Statusnya dimulai sebagai starting, menjadi healthy saat pemeriksaan pertama lolos, dan menjadi unhealthy setelah gagal berturut-turut sebanyak yang dikonfigurasi. Pahami apa yang tidak dilakukannya: restart policy bereaksi pada proses yang sudah exit, dan proses yang hang belum exit, jadi container yang unhealthy tetap berjalan. Pemeriksaan ini adalah sinyal untukmu dan untuk --wait di langkah berikutnya, bukan perbaikan.
# docker-compose.yml, inside services.web
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/robots.txt"]
interval: 30s
timeout: 5s
retries: 3
# Failures during the first 20 s do not count: the app is still booting
start_period: 20s
logging:
driver: json-file
options:
# Wrong: no options = one log file that grows until the disk is full
# Right: 3 files of 10 MB each, oldest rotated away
max-size: "10m"
max-file: "3"Blok logging memperbaiki masalah yang lebih sepi. Driver json-file tidak punya batas ukuran secara default, jadi container yang banyak menulis log pelan-pelan memenuhi disk. Dengan max-size dan max-file, jumlah file log yang dirotasi menjadi terbatas; situs ini menyimpan tiga file masing-masing 10 MB.
Output yang diharapkan: Setelah start berikutnya, docker compose ps menampilkan (health: starting) dulu dan (healthy) setelah pemeriksaan pertama lolos. Kalau tidak pernah keluar dari starting atau berubah unhealthy, lihat tabel di bawah.
Perintah probe berjalan di dalam image, jadi harus ada di sana. wget bisa dipakai di image berbasis Alpine karena BusyBox membawanya; image minimal tanpa wget atau curl butuh probe lain, kalau tidak health check gagal terus padahal aplikasinya sehat.
Langkah 5: validasi, lalu jalankan pertama kali
Validasi dulu, baru jalankan. config --quiet hanya memvalidasi dan tidak mencetak apa pun kalau filenya benar. up -d berjalan di background. Menambahkan --wait membuat perintah menunggu sampai service running dan healthy, dengan --wait-timeout sebagai batas atas dalam detik. Tanpa itu, up -d selesai begitu container start, jadi rilis yang tidak pernah healthy tetap terlihat berhasil.
# 1. Validate only: silence means the file is fine
docker compose config --quiet
# 2. Start in the background and wait for running + healthy (max 120 s)
docker compose up -d --wait --wait-timeout 120
# 3. Check status, then the app's own log
docker compose ps
docker compose logs --tail 50 webKalau perintahnya gagal, jangan menebak. Baca dulu log container itu sendiri; baris berikut menunjukkan caranya. Restart policy bisa menyembunyikan crash loop karena container terus hidup kembali, jadi di lognya error yang sebenarnya.
Output yang diharapkan: Perintah pertama tidak mencetak apa pun. Perintah kedua selesai ketika web sudah healthy, atau berakhir dengan error kalau belum healthy dalam batas waktu. docker compose logs menampilkan baris startup aplikasi, misalnya alamat yang di-listen.
Langkah 6: update tanpa merusak, dan cara rollback
Update itu tiga langkah: arahkan IMAGE ke tag baru, pull, lalu jalankan up dengan --wait. Compose membuat ulang container karena image-nya berubah. Inilah alasan tag tidak boleh latest: dengan tag yang tidak berubah seperti hash commit, nilai lama di .env sekaligus menjadi rollback-mu. Catat tag lama sebelum menggantinya.
cd /home/deploy/my-app
# 1. Write down the tag that works right now
grep '^IMAGE=' .env
# 2. Point IMAGE at the new tag (never "latest")
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:7b1e9d4|' .env
# 3. Pull, then recreate only if the image changed; fail loudly if unhealthy
docker compose pull
docker compose up -d --wait --wait-timeout 120
docker compose ps
# Rollback: put the OLD tag back and run the same up
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/example-user/my-app:3f9c2a1|' .env
docker compose up -d --wait --wait-timeout 120
# Wrong: this removes containers AND, with -v, the volumes (database data)
# docker compose down -vJujur soal satu batasan. Dengan satu container, yang lama berhenti lalu yang baru start, jadi ada jeda singkat. Untuk banyak aplikasi kecil itu bisa diterima; kalau tidak, baca zero-downtime rolling deploy. Perlu diingat container_name juga membuat Compose tidak bisa menjalankan lebih dari satu salinan service itu, seperti yang dinyatakan dokumentasi Docker.
Output yang diharapkan: up --wait selesai setelah container baru healthy, dan docker compose ps menampilkan tag baru di kolom IMAGE. Setelah rollback, tag lama muncul lagi.
Jangan memakai docker compose down -v sebagai cara restart. down menghentikan dan menghapus container, jeda yang lebih panjang daripada up di tempat, dan flag -v juga menghapus volume, tempat database menyimpan datanya.
Error umum dan cara memperbaikinya
Cocokkan gejala di kolom pertama, pastikan penyebabnya, lalu terapkan perbaikannya. Baca selalu log container sebelum mengubah file.
| Gejala | Penyebab | Perbaikan |
|---|---|---|
| Port aplikasi terbuka dari internet lewat alamat IP | Baris ports tanpa alamat host, jadi terikat ke semua interface | Ubah menjadi 127.0.0.1:3002:3000, jalankan up -d lagi, cek docker compose ps |
| Compose memperingatkan variabel belum di-set, atau nama image kosong | File .env tidak ada di folder project, atau nama variabel salah ketik | Jalankan dari folder yang berisi docker-compose.yml, perbaiki namanya, pastikan dengan docker compose config |
| Container exit dengan kode 137 lalu restart berulang kali | Memorinya menyentuh mem_limit dan kernel mematikannya | Pastikan dengan docker inspect, turunkan batas heap di bawah limit atau naikkan limit-nya, dan cari kebocoran |
| Status tetap unhealthy tetapi container masih berjalan | Restart policy bereaksi pada exit, bukan pada pemeriksaan yang gagal | Baca hasil probe dengan docker inspect, perbaiki aplikasi atau URL probe, dan pakai up --wait agar deploy gagal dengan jelas |
| Selalu unhealthy, padahal curl di server berhasil | Tool probe, misalnya wget, tidak ada di dalam image | Jalankan probe manual dengan docker compose exec, lalu pindah ke tool yang benar-benar ada di image |
| Status menunjukkan Restarting dan tidak pernah tenang | Aplikasi crash saat boot, sering karena nilai di .env hilang atau salah | Baca docker compose logs --tail 50 web, perbaiki nilainya, jalankan up -d --wait lagi |
| Disk pelan-pelan penuh dan ada file log yang sangat besar di /var/lib/docker | json-file tidak punya batas ukuran secara default, dan max-file hanya berlaku kalau max-size diisi | Tambahkan blok logging dengan kedua opsi, lalu buat ulang container agar setelan baru berlaku |
| Conflict: nama container sudah dipakai | Container lama yang dibuat di luar Compose sudah memegang container_name itu | Hapus satu container itu sekali dengan docker rm -f, lalu jalankan up -d; sejak itu Compose yang memegang namanya |
| up --scale web=2 menolak berjalan | container_name diisi, jadi Compose tidak bisa menjalankan salinan kedua | Hapus container_name kalau butuh lebih dari satu salinan, dan pasang proxy di depannya |
Kalau tidak ada baris yang cocok, periksa dari dalam ke luar. Empat perintah ini menjawab sebagian besar pertanyaan, dengan urutan ini:
# 1. What state is it in, and is it restarting?
docker compose ps
# 2. What did the app say before it died or went unhealthy?
docker compose logs --tail 50 web
# 3. What did the health check see? (the last results)
docker inspect --format '{{json .State.Health}}' my-app-web
# 4. Is the config what you think it is?
docker compose configUntuk cara lain membaca output container, lihat panduan pemula membaca Docker logs.
Checklist production
- restart: unless-stopped sudah dipasang, dan saya tahu ia tidak bertindak pada status unhealthy.
- Setiap entri ports diawali 127.0.0.1: kalau hanya proxy yang boleh menjangkaunya.
- Tag image berasal dari .env dan tidak berubah, bukan latest, dan tag sebelumnya sudah dicatat.
- mem_limit sudah dipasang, dan batas heap Node jelas di bawahnya.
- Health check sudah ada, tool-nya ada di image, dan deploy memakai up --wait.
- Rotasi log sudah diatur dengan max-size dan max-file, dan .env tidak ada di git.
Belajar lebih dalam
Sekarang kamu punya file Compose yang selamat dari reboot, menyembunyikan portnya, membatasi memorinya, melaporkan kesehatannya, dan bisa di-rollback. Lesson gratis menelusuri file yang sama baris demi baris, lengkap dengan explainer yang bisa dijalankan langkah demi langkah dan kuis.
Buka lesson: Docker Compose Production
Berikutnya, pasang nginx di depan port loopback itu, lalu otomatiskan update-nya dengan CI.
Sumber dan dokumentasi resmi
- Docker Docs: referensi services di Compose file (restart, ports, mem_limit, healthcheck, container_name)
- Docker Docs: docker compose up (--wait, --wait-timeout, --remove-orphans)
- Docker Docs: interpolasi di file Compose (nilai default, .env)
- Docker Docs: start container otomatis (restart policy)
- Docker Docs: docker compose config

