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:

text
katalog-web/
├── .dockerignore
├── Dockerfile
├── compose.yaml
└── src/
    ├── package-lock.json
    ├── package.json
    └── server.js

Berkas 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:

js
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:

json
{
  "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:

  1. Pool pg bersifat malas — objek pg.Pool tidak membuka koneksi saat dibuat, melainkan menunggu kueri pertama; karena itu server.js sanggup menyala meski DATABASE_URL tidak diisi, dan rute / tetap dijawab tanpa database.
  2. express dan pg berada di dependencies — keduanya dibutuhkan aplikasi saat berjalan, di dev maupun produksi.
  3. nodemon berada di devDependencies — 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:

bash
$ cd katalog-web
$ docker run -it --rm -v $(pwd)/src:/app -w /app node:22-alpine sh

Output yang Dihasilkan:

text
/app $

Prompt berubah menjadi /app $ — sejak baris ini, kita duduk di dalam container. Mari kita telaah flag-flag yang mengantarkan kita ke sana:

  1. -it — terminal interaktif; stdin kita disambungkan ke shell sehingga kita bisa mengetik langsung, kebiasaan yang sama saat memakai docker compose exec pada Chapter Menjalankan Perintah dalam Container.
  2. --rm — container dibuang begitu sesi berakhir; sesi eksperimen memang tidak dimaksudkan untuk meninggalkan jejak.
  3. -v $(pwd)/src:/app — bind mount: isi folder src/ di host tampil apa adanya di /app di dalam container, dua arah dan tanpa salinan, persis mekanisme Chapter Bind Mount.
  4. -w /app — working directory container di-set ke /app, sehingga perintah berikutnya berlaku seolah kita cd /app lebih dulu.

Sekarang momen kebenarannya: memasang dependensi dari dalam container, meski host tidak memiliki Node.js:

bash
/app $ npm install

Output yang Dihasilkan:

text
added 68 packages, and audited 69 packages in 2s

14 packages are looking for funding
  run `npm fund` for details
found 0 vulnerabilities

Lalu menjalankan aplikasinya di latar belakang dan mengetuknya dari dalam container:

bash
/app $ node server.js &

Output yang Dihasilkan:

text
[1] 13
katalog-web mendengarkan di port 3000
bash
/app $ wget -qO- http://localhost:3000/

Output yang Dihasilkan:

text
Halo dari katalog-web

Aplikasi hidup penuh di dalam container tanpa satu byte Node.js terpasang di host. Dari terminal kedua di host, Budi memeriksa isi src/:

bash
$ ls src/

Output yang Dihasilkan:

text
node_modules
package-lock.json
package.json
server.js

Mari kita telaah apa yang barusan terjadi pada berkas host:

  1. node_modules muncul di host — npm install berjalan di dalam container, tetapi tulisannya mendarat di host karena bind mount bekerja dua arah: folder /app di container dan folder src/ di host adalah wilayah disk yang sama.
  2. package-lock.json ikut 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.
  3. 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:

bash
$ sed -i 's/Halo dari katalog-web/Halo dari katalog-web versi pengembangan/' src/server.js
bash
/app $ grep "res.send" server.js

Output yang Dihasilkan:

text
  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:

bash
$ rm -rf src/node_modules
$ docker run -it --rm -v $(pwd)/src:/app -v /app/node_modules -w /app node:22-alpine sh

Output yang Dihasilkan:

text
/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:

bash
/app $ npm install

Output yang Dihasilkan:

text
added 68 packages, and audited 69 packages in 2s

found 0 vulnerabilities
bash
/app $ ls node_modules | head -4

Output yang Dihasilkan:

text
accepts
body-parser
bytes
call-bind-1.0.2

Dependensi terpasang penuh di dalam container. Kini pemeriksaan yang sama dari host:

bash
$ ls src/

Output yang Dihasilkan:

text
package-lock.json
package.json
server.js

Mari kita telaah hasil yang berbeda ini:

  1. node_modules tidak pernah mendarat di host — instalasi menulis ke /app/node_modules di dalam container, yang kini adalah volume milik Docker, bukan wilayah bind mount host.
  2. 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_modules yang berbeda tanpa saling menginjak.
  3. Berkas karya tetap dua arah — hanya subpath node_modules yang ditutup; server.js, package.json, dan package-lock.json tetap berpindah dua arah seperti sebelumnya.
  4. 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:

dockerfile
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:

  1. COPY src/package*.json ./ — manifest dan lockfile disalin lebih dulu, terpisah dari kode; urutan ini menjaga layer npm ci tetap ter-cache selama manifest tidak berubah, disiplin Chapter Build Cache. Lockfile sudah aman hadir karena lahir di host pada sesi interaktif tadi.
  2. 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=dev dan --omit=dev menanti pembahasan khususnya di Chapter Development Dependencies.
  3. 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 /app dengan isi host.
  4. 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:

yaml
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:

bash
$ docker compose up -d --build

Output yang Dihasilkan (dipotong):

text
[+] 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
bash
$ curl localhost:8080

Output yang Dihasilkan:

text
Halo dari katalog-web versi pengembangan

Teks 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:

bash
$ docker compose exec web ls node_modules | head -4

Output yang Dihasilkan:

text
accepts
body-parser
bytes
call-bind-1.0.2
bash
$ docker volume ls

Output yang Dihasilkan (dipotong):

text
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:

  1. Bind mount ./src:/app — kode milik host; suntingan editor tampil seketika di container, dua arah.
  2. Volume bernama node_modules:/app/node_modules — dependensi milik container; nama aslinya katalog-web_node_modules karena prefix project dari Chapter Volume dan Bind Mount berlaku, dan sebagai volume bernama ia awet melewati docker compose down. Volume ini lahir kosong saat up pertama, 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 hasil npm ci di image langsung tersedia tanpa instalasi ulang setiap hari.
  3. Volume db-data untuk service db — 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:

text
**/node_modules
.git
dist
AspekBind mount saat devCOPY saat build
Kapan berlakuselama container berjalansekali saat docker build
Sumber berkasfolder host saat inisnapshot build context
Perubahan terlihatseketika, dua arahhanya setelah rebuild
Ikut serta dalam imagetidak — tidak membekuya — menjadi layer image
Peraniterasi harianpengemasan 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 --rm untuk sesi container interaktif.
  • Volumes in Compose — pola volume bernama, prefix project, dan kombinasi bind mount dengan volume di compose.yaml.