Chapter Workflow Development menutup dengan satu keputusan: katalog-web — aplikasi web Node.js milik Budi yang selama ini menumpang repositori katalog — kini pindah rumah menjadi repositori tersendiri, dan seluruh toolchain-nya akan hidup di dalam container, bukan di laptop. Konsekuensinya menarik: editor dan git tetap di host, tetapi mulai hari ini mesin Budi boleh saja tidak memasang Node.js sama sekali, karena setiap npm install dan node server.js dijalankan dari dalam container. Jembatan yang menyatukan dua dunia itu adalah bind mount source code — mekanisme yang kita kenal pada Chapter Bind Mount sebagai jendela dua arah ke filesystem host, kini dipakai untuk satu tujuan paling produktifnya: menyunting berkas di editor dan melihatnya langsung dibaca container tanpa build ulang. Chapter ini membangun pola itu dari nol lewat sesi container interaktif, menabrak masalah klasik node_modules, lalu menaklukkannya dengan trik volume yang menjadi fondasi seluruh workflow section ini.
Repositori katalog-web #
Aplikasi katalog-web sengaja dirapikan bentuknya sebelum pindah rumah: seluruh berkas milik aplikasi — manifest dependensi dan kode — hidup di dalam satu folder src/, sementara tiga berkas pendamping di akar repositori mengurus container. Struktur lengkapnya:
katalog-web/
├── .dockerignore
├── Dockerfile
├── compose.yaml
└── src/
├── package-lock.json
├── package.json
└── server.jsBerkas package-lock.json belum ada pada commit pertama — berkas itu lahir sendiri lewat sesi interaktif di bagian berikutnya. Isi server.js adalah aplikasi Express paling sederhana yang tetap setia pada kebiasaan proyek katalog: satu rute ucapan selamat datang dan satu rute daftar produk yang membaca PostgreSQL lewat pool koneksi:
import express from "express";
import pg from "pg";
const app = express();
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
app.get("/", (_req, res) => {
res.send("Halo dari katalog-web");
});
app.get("/produk", async (_req, res) => {
const { rows } = await pool.query("SELECT nama, harga FROM produk");
res.json(rows);
});
app.listen(3000, () => console.log("katalog-web mendengarkan di port 3000"));Manifest dependensinya menegaskan pembagian peran yang akan menempati satu chapter penuh di depan:
{
"name": "katalog-web",
"type": "module",
"scripts": {
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4",
"pg": "^8"
},
"devDependencies": {
"nodemon": "^3"
}
}Mari kita telaah dua blok di atas sebelum melangkah:
- Pool
pgbersifat malas — objekpg.Pooltidak membuka koneksi saat dibuat, melainkan menunggu kueri pertama; karena ituserver.jssanggup menyala meskiDATABASE_URLtidak diisi, dan rute/tetap dijawab tanpa database. expressdanpgberada didependencies— keduanya dibutuhkan aplikasi saat berjalan, di dev maupun produksi.nodemonberada didevDependencies— aplikasi yang sedang berjalan tidak pernah memanggil nodemon; nodemon hanya dibutuhkan manusia yang sedang mengembangkan. Konsekuensi dari perbedaan ini kita bedah tuntas pada Chapter Development Dependencies.
Sesi Interaktif dengan Toolchain di Container #
Budi belum menyentuh Dockerfile — eksperimen pertama justru memakai image Node.js polos, terminal interaktif, dan satu bind mount:
$ cd katalog-web
$ docker run -it --rm -v $(pwd)/src:/app -w /app node:22-alpine shOutput yang Dihasilkan:
/app $Prompt berubah menjadi /app $ — sejak baris ini, kita duduk di dalam container. Mari kita telaah flag-flag yang mengantarkan kita ke sana:
-it— terminal interaktif; stdin kita disambungkan ke shell sehingga kita bisa mengetik langsung, kebiasaan yang sama saat memakaidocker compose execpada Chapter Menjalankan Perintah dalam Container.--rm— container dibuang begitu sesi berakhir; sesi eksperimen memang tidak dimaksudkan untuk meninggalkan jejak.-v $(pwd)/src:/app— bind mount: isi foldersrc/di host tampil apa adanya di/appdi dalam container, dua arah dan tanpa salinan, persis mekanisme Chapter Bind Mount.-w /app— working directory container di-set ke/app, sehingga perintah berikutnya berlaku seolah kitacd /applebih dulu.
Sekarang momen kebenarannya: memasang dependensi dari dalam container, meski host tidak memiliki Node.js:
/app $ npm installOutput yang Dihasilkan:
added 68 packages, and audited 69 packages in 2s
14 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilitiesLalu menjalankan aplikasinya di latar belakang dan mengetuknya dari dalam container:
/app $ node server.js &Output yang Dihasilkan:
[1] 13
katalog-web mendengarkan di port 3000/app $ wget -qO- http://localhost:3000/Output yang Dihasilkan:
Halo dari katalog-webAplikasi hidup penuh di dalam container tanpa satu byte Node.js terpasang di host. Dari terminal kedua di host, Budi memeriksa isi src/:
$ ls src/Output yang Dihasilkan:
node_modules
package-lock.json
package.json
server.jsMari kita telaah apa yang barusan terjadi pada berkas host:
node_modulesmuncul di host —npm installberjalan di dalam container, tetapi tulisannya mendarat di host karena bind mount bekerja dua arah: folder/appdi container dan foldersrc/di host adalah wilayah disk yang sama.package-lock.jsonikut lahir — manifest terkunci yang dihasilkan npm tersimpan sebagai berkas host biasa, siap di-commit; perjalanan balik ini adalah sisi bind mount yang justru kita inginkan untuk berkas karya tangan.- Host kini “memiliki” 68 paket yang bukan miliknya — dan inilah masalah yang harus kita tangani sebelum pola ini layak dipakai harian.
Bukti terakhir bahwa berkas host dan berkas container adalah benda yang sama: Budi menyunting teks ucapan selamat datang dari editor di host — cukup lewat sed untuk latihan ini — lalu memeriksanya dari dalam container yang sedang berjalan:
$ sed -i 's/Halo dari katalog-web/Halo dari katalog-web versi pengembangan/' src/server.js/app $ grep "res.send" server.jsOutput yang Dihasilkan:
res.send("Halo dari katalog-web versi pengembangan");Tidak ada build, tidak ada restart, tidak ada satu perintah Docker pun di antara keduanya — suntingan di host terbaca seketika dari dalam container karena keduanya memang berkas yang sama. (Catatan kecil: perilaku seketika ini berlaku untuk bind direktori seperti src/; bind berkas tunggal punya keanehan inode yang sudah kita bedah pada Chapter Bind Mount.) Sesi eksperimen ditutup dengan exit — container lenyap karena --rm.
Masalah node_modules dan Trik Volume #
node_modules yang bocor ke host bukan sekadar kerapian. Isinya adalah ribuan berkas kecil hasil npm, dan yang lebih berbahaya: binari terkompilasi untuk lingkungan container. Folder itu lahir di atas node:22-alpine, sehingga paket berbinari native di dalamnya dikompilasi untuk musl — libc milik Alpine — bukan untuk glibc Ubuntu milik Budi, apalagi untuk Windows milik Siti. Bila folder host yang sama kemudian dibaca Node.js di host (misalnya oleh tooling editor), kecelakaan “module not found” atau binari yang menolak berjalan tinggal menunggu giliran.
Docker punya jawaban yang elegan: menutup satu subpath dengan volume. Budi membersihkan bekas dahulu, lalu menjalankan ulang sesi dengan satu flag tambahan:
$ rm -rf src/node_modules
$ docker run -it --rm -v $(pwd)/src:/app -v /app/node_modules -w /app node:22-alpine shOutput yang Dihasilkan:
/app $Flag kedua -v /app/node_modules hanya berisi path tanpa tanda titik dua — bentuk yang kita kenal dari Chapter Volume sebagai volume anonim. Karena path-nya lebih dalam daripada bind mount /app, volume itu menutupi subfolder /app/node_modules: kernel memasang bind mount untuk /app terlebih dahulu, lalu memasang volume di atas subpath-nya. Sesi berlanjut seperti sebelumnya:
/app $ npm installOutput yang Dihasilkan:
added 68 packages, and audited 69 packages in 2s
found 0 vulnerabilities/app $ ls node_modules | head -4Output yang Dihasilkan:
accepts
body-parser
bytes
call-bind-1.0.2Dependensi terpasang penuh di dalam container. Kini pemeriksaan yang sama dari host:
$ ls src/Output yang Dihasilkan:
package-lock.json
package.json
server.jsMari kita telaah hasil yang berbeda ini:
node_modulestidak pernah mendarat di host — instalasi menulis ke/app/node_modulesdi dalam container, yang kini adalah volume milik Docker, bukan wilayah bind mount host.- Dependensi terisolasi per container — binari musl tinggal di dalam container alpine; host bebas dari binari asing, dan dua container berbasis image berbeda boleh memasang dua isi
node_modulesyang berbeda tanpa saling menginjak. - Berkas karya tetap dua arah — hanya subpath
node_modulesyang ditutup;server.js,package.json, danpackage-lock.jsontetap berpindah dua arah seperti sebelumnya. - Volume anonim mati bersama container
--rm— begitu sesi berakhir, dependensi ikut terbuang; untuk keseharian kita butuh bentuk yang lebih awet, dan itulah pekerjaan compose di bagian berikutnya.
Pola compose untuk Hari Kerja #
Sesi interaktif bagus untuk eksperimen, tetapi hari kerja menuntut ritual satu perintah. Seluruh pola barusan kini dibakukan ke dalam Dockerfile dan compose.yaml. Dockerfile memakai dua panggung dari teknik Multi-Stage Build, dan panggung dev sengaja tidak menyalin kode — kode akan hadir lewat bind mount:
FROM node:22-alpine AS base
WORKDIR /app
COPY src/package*.json ./
FROM base AS dev
RUN npm ci
USER node
CMD ["node", "server.js"]Mari kita telaah keputusan-keputusannya:
COPY src/package*.json ./— manifest dan lockfile disalin lebih dulu, terpisah dari kode; urutan ini menjaga layernpm citetap ter-cache selama manifest tidak berubah, disiplin Chapter Build Cache. Lockfile sudah aman hadir karena lahir di host pada sesi interaktif tadi.RUN npm ci— memasang dependensi persis mengikuti lockfile; pada chapter ini ia berjalan dengan perilaku bawaan npm yang memasang devDependencies juga, sehingga nodemon tersedia — bendera-bendera--include=devdan--omit=devmenanti pembahasan khususnya di Chapter Development Dependencies.- Panggung dev tidak memuat
COPY src/— kode tidak dibekukan ke dalam image dev karena kode memang tidak akan dibaca dari image: bind mount akan menutup/appdengan isi host. USER node— proses dev tetap turun dari root, kebiasaan yang tidak boleh dikompromikan hanya karena ini lingkungan pengembangan, sesuai Chapter Workdir dan User.
compose.yaml menerjemahkan seluruh sesi manual ke bentuk deklaratif:
services:
web:
build:
context: .
target: dev
image: katalog-web:dev
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://katalog:rahasia123@db:5432/katalog
volumes:
- ./src:/app
- node_modules:/app/node_modules
depends_on:
- db
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: katalog
POSTGRES_PASSWORD: rahasia123
POSTGRES_DB: katalog
volumes:
- db-data:/var/lib/postgresql/data
volumes:
node_modules:
db-data:Deklarasi build: {context: ., target: dev} membangun panggung dev — bentuk panjang kunci build yang kita kenal dari Chapter Image, Build, dan Pull — dan image: katalog-web:dev menempelkan nama pada hasilnya, melanjutkan nama yang sudah dipakai proyek katalog sebelumnya. Stack dinyalakan dengan build:
$ docker compose up -d --buildOutput yang Dihasilkan (dipotong):
[+] Building 13.8s (12/12) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
...
=> [3/5] COPY src/package*.json ./ 0.1s
=> [4/5] RUN npm ci 9.6s
=> [5/5] USER node 0.0s
=> exporting to image 0.3s
=> => naming to docker.io/library/katalog-web:dev 0.0s
[+] Running 5/5
✔ Network katalog-web_default Created 0.1s
✔ Volume "katalog-web_db-data" Created 0.0s
✔ Volume "katalog-web_node_modules" Created 0.0s
✔ Container katalog-web-db-1 Started 1.6s
✔ Container katalog-web-web-1 Started 0.4s$ curl localhost:8080Output yang Dihasilkan:
Halo dari katalog-web versi pengembanganTeks yang disunting Budi pada sesi manual tadi ikut terbawa — bind mount memang membaca isi host saat ini, bukan isi snapshot build. Verifikasi terakhir menutup lingkaran — dependensi hadir di dalam container tanpa host pernah memasang apa pun:
$ docker compose exec web ls node_modules | head -4Output yang Dihasilkan:
accepts
body-parser
bytes
call-bind-1.0.2$ docker volume lsOutput yang Dihasilkan (dipotong):
DRIVER VOLUME NAME
local data-web
local katalog-web_db-data
local katalog-web_node_modules
local katalog_db-data
local latihan-volume
local pgdata
local toko_db-data
...Mari kita telaah tiga mount yang kini bekerja serentak pada stack:
- Bind mount
./src:/app— kode milik host; suntingan editor tampil seketika di container, dua arah. - Volume bernama
node_modules:/app/node_modules— dependensi milik container; nama aslinyakatalog-web_node_moduleskarena prefix project dari Chapter Volume dan Bind Mount berlaku, dan sebagai volume bernama ia awet melewatidocker compose down. Volume ini lahir kosong saatuppertama, lalu diisi pra-isi dari isi image di path yang sama — mekanisme “pra-isi volume kosong” yang sudah kita buktikan pada chapter tersebut — sehingga 68 paket hasilnpm cidi image langsung tersedia tanpa instalasi ulang setiap hari. - Volume
db-datauntuk servicedb— data PostgreSQL milik container, tidak berhubungan langsung dengan kode, tetapi melengkapi pembagian wilayah: kode di host, sisanya di Docker.
Bind untuk Dev dan COPY untuk Build #
Pola hari kerja kini utuh, dan satu pertanyaan pasti muncul: kalau kode selalu hadir lewat bind mount, mengapa Dockerfile masih repot-repot COPY? Jawabannya: keduanya melayani dua dunia yang berbeda. Bind mount adalah dunia development — berkas mengalir langsung dari host saat container berjalan. COPY adalah dunia build — berkas dibekukan menjadi layer image agar image bisa didistribusikan ke mesin tanpa folder host. Image produksi kelak dibangun dari COPY, bukan dari bind mount, dan supaya dua dunia itu tidak saling mengotori, .dockerignore berdiri di perbatasan:
**/node_modules
.git
dist| Aspek | Bind mount saat dev | COPY saat build |
|---|---|---|
| Kapan berlaku | selama container berjalan | sekali saat docker build |
| Sumber berkas | folder host saat ini | snapshot build context |
| Perubahan terlihat | seketika, dua arah | hanya setelah rebuild |
| Ikut serta dalam image | tidak — tidak membeku | ya — menjadi layer image |
| Peran | iterasi harian | pengemasan untuk distribusi |
Pola lengkapnya kini berdiri: kode hidup di host, toolchain hidup di container, dependensi terkunci di volume, dan build tetap membekukan snapshot bersih. Tapi satu kejengkelan tersisa — Budi menyimpan suntingan di editor, berkas di container sudah baru, dan curl localhost:8080 masih setia menjawab teks lama. Proses Node di dalam container memegang kode versi lama di memorinya, dan memaksanya membaca ulang kode adalah pekerjaan Chapter Hot Reload.
Referensi #
- Bind Mounts — mekanisme bind dua arah dan perilakunya terhadap path host.
- docker run — CLI Reference
— definisi flag
-v,-w,-it, dan--rmuntuk sesi container interaktif. - Volumes in Compose — pola volume bernama, prefix project, dan kombinasi bind mount dengan volume di compose.yaml.