Siklus hot reload dari Chapter Hot Reload berjalan mulus, tetapi diam-diam ia menagih satu utang konseptual: nodemon — alat yang menyala setiap hari di container dev — tercatat di devDependencies, dan pertanyaannya kini tidak bisa ditunda lagi, ke image mana sebenarnya dependensi semacam itu boleh dibekukan? Kalau seluruh dependensi dimasukkan ke satu image untuk semua keperluan, image produksi membawa muatan yang tidak pernah dipakainya: ukuran membengkak, waktu tarik memanjang, dan setiap paket tambahan adalah baris kode yang harus dijaga keamanannya padahal tidak memberi nilai pada produksi. Kalau sebaliknya dependensi dev dibuang, workflow development kehilangan alat-alatnya. Jawabannya bukan memilih salah satu, melainkan membangun dua wajah dari satu Dockerfile — panggung dev dan panggung runtime yang berbagi fondasi — dan chapter ini merakit keduanya untuk katalog-web lengkap dengan ritual npm ci yang berbeda di tiap panggung.

Siapa yang Membutuhkan devDependencies #

Mari kita hitung dulu isi kedua kolom package.json dari sudut pandang dua peran. Aplikasi yang berjalan — baik di laptop Budi maupun di server produksi — hanya membutuhkan dependencies: express untuk menerima permintaan dan pg untuk berbicara dengan database. Manusia yang sedang mengembangkan justru hidup dari devDependencies: nodemon untuk memuat ulang kode, linter untuk menjaga gaya kode, dan alat build untuk mengolah aset. Perbedaan peran ini adalah alasan npm memisahkan dua blok tersebut sejak awal, dan Docker melanjutkan logika yang sama ke tingkat image: image dev membawa seluruhnya, image produksi hanya membawa yang pertama.

Proyek katalog-web sendiri baru saja menumbuhkan kebutuhan build pertamanya: Budi menambahkan halaman muka sederhana berisi skrip klien src/public/app.js yang mengambil daftar produk dan menyusunnya menjadi tabel. Skrip klien itu perlu dibundel dan dipadatkan untuk produksi, dan untuk itu ia menambahkan esbuild ke devDependencies:

json
{
  "name": "katalog-web",
  "type": "module",
  "scripts": {
    "dev": "nodemon server.js",
    "build": "esbuild public/app.js --bundle --minify --outdir=dist"
  },
  "dependencies": {
    "express": "^4",
    "pg": "^8"
  },
  "devDependencies": {
    "esbuild": "^0.24",
    "nodemon": "^3"
  }
}

Satu baris di server.js menyesuaikan agar aset klien dilayani dari folder yang tepat di tiap lingkungan:

js
app.use(express.static(process.env.NODE_ENV === "production" ? "dist" : "public"));

Mari kita telaah tiga keputusan pada blok di atas:

  1. esbuild berada di devDependencies — aplikasi yang berjalan tidak pernah memanggil esbuild; ia hanya dipanggil sekali saat build untuk menghasilkan berkas statis di dist/.
  2. Script build adalah pekerjaan tahap build — hasilnya, folder dist/, adalah satu-satunya bagian dari esbuild yang pernah menyentuh produksi.
  3. Pemilihan folder aset mengikuti NODE_ENV — saat development, aset disajikan langsung dari public/ yang ter-bind mount sehingga suntingan skrip klien terlihat seketika; saat produksi, NODE_ENV=production mengalihkan penyajian ke dist/ hasil bundel.

Dockerfile dengan Empat Panggung #

Seluruh kebutuhan itu kini ditampung satu Dockerfile multi-stage — teknik dari Chapter Multi-Stage Build — dengan empat panggung yang masing-masing punya jabatan jelas:

dockerfile
FROM node:22-alpine AS base
WORKDIR /app
COPY src/package*.json ./

FROM base AS dev
RUN npm ci --include=dev
ENV PATH="/app/node_modules/.bin:${PATH}"
USER node
CMD ["node", "server.js"]

FROM base AS build
RUN npm ci --include=dev
COPY src/ ./
RUN npm run build

FROM base AS runtime
ENV NODE_ENV=production
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY src/server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Mari kita telaah jabatan tiap panggung:

  1. base — fondasi bersama — hanya WORKDIR dan salinan manifest berikut lockfile; panggung ini di-extend oleh ketiga panggung lain, sehingga semua instalasi dependensi berdiri di atas cache layer yang sama dari Chapter Build Cache.
  2. dev — seluruh dependensi untuk manusia — npm ci --include=dev memasang devDependencies secara eksplisit, PATH dibuka agar nodemon bisa dipanggil langsung, dan user turun ke node; kode sengaja tidak di-COPY karena selalu hadir lewat bind mount — keputusan yang kita bangun di Chapter Bind Mount Source Code.
  3. build — pengolah aset — memasang dependensi lengkap (esbuild adalah devDependency, jadi tahap build memang berhak memilikinya), menyalin source, lalu menjalankan npm run build yang melahirkan /app/dist.
  4. runtime — image produksi — npm ci --omit=dev hanya memasang dependencies, NODE_ENV=production mengalihkan penyajian aset ke dist, hasil build disalin dengan COPY --from=build, dan satu-satunya berkas aplikasi yang ikut adalah server.js.

compose.yaml tidak berubah satu baris pun — dan justru di situlah letak kelegaan pola ini:

yaml
  web:
    build:
      context: .
      target: dev

Deklarasi target: dev yang kita tulis sejak chapter kedua tetap menghentikan build pada panggung dev, sehingga penambahan dua panggung baru tidak menyentuh ritual harian sedikit pun — perilaku bentuk panjang kunci build yang kita kenal dari Chapter Image, Build, dan Pull.

Dua Bendera npm ci #

Perbedaan paling penting antara panggung-panggung di atas hidup pada satu kata di perintah instalasi. Tabel berikut merangkum tiga bentuknya:

Bentuk perintahYang dipasangPanggung pemakai
npm cidependencies + devDependencies — perilaku bawaanbentuk ringkas pada chapter sebelumnya
npm ci --include=devsama seperti bawaan, ditulis eksplisitdev dan build
npm ci --omit=devhanya dependenciesruntime

Mari kita telaah mengapa bentuk eksplisit layak dipilih meski perilakunya sama dengan bawaan: Dockerfile adalah dokumen yang dibaca manusia, dan --include=dev pada panggung dev membuat maksudnya terbaca tanpa perlu membuka dokumentasi npm — seseorang yang mengaudit image langsung tahu bahwa devDependencies memang sengaja hadir di sana. Bendera --omit=dev di panggung runtime bekerja ke arah sebaliknya dengan efek yang nyata: node_modules produksi tidak memuat nodemon, esbuild, maupun linter apa pun.

Membangun dan Membandingkan Kedua Image #

Saatnya membuktikan bahwa dua panggung itu menghasilkan dua image yang sungguh berbeda. Image dev sudah diperbarui lebih dulu — docker compose up -d --build setelah esbuild masuk ke manifest menjalankan npm ci --include=dev dengan manifest baru dan mengganti isi katalog-web:dev — dan image runtime kini dibangun eksplisit dengan flag --target:

bash
$ docker build --target runtime -t katalog-web:runtime .

Output yang Dihasilkan (dipotong):

text
[+] Building 21.7s (19/19) FINISHED
 => [internal] load build definition from Dockerfile                    0.1s
 ...
 => [base  1/3] FROM docker.io/library/node:22-alpine                    0.0s
 => [base  2/3] WORKDIR /app                                             0.0s
 => [base  3/3] COPY src/package*.json ./                                0.1s
 => [build 1/3] RUN npm ci --include=dev                                 0.0s (cached)
 => [build 2/3] COPY src/ ./                                            0.2s
 => [build 3/3] RUN npm run build                                       1.5s
 => [runtime 1/6] ENV NODE_ENV=production                               0.0s
 => [runtime 2/6] RUN npm ci --omit=dev                                 8.7s
 ...
 => [runtime 5/6] COPY src/server.js ./                                 0.0s
 => [runtime 6/6] EXPOSE 3000                                           0.0s
 => exporting to image                                                  0.4s
 => => naming to docker.io/library/katalog-web:runtime                  0.0s

Dua hal pada keluaran itu layak diperhatikan. Pertama, baris [build 1/3] RUN npm ci --include=dev 0.0s (cached): panggung build menulis instruksi yang identik dengan panggung dev di atas fondasi yang sama, dan karena compose baru saja membangun panggung dev dengan manifest yang sama, BuildKit menemukan layer itu di cache — instalasi tidak dijalankan dua kali. Duplikasi satu baris itu bukan pemborosan melainkan kunci struktur: panggung build membutuhkan dependensi lengkap tanpa mewarisi USER node dari panggung dev, sebab npm run build perlu menulis hasilnya ke /app/dist yang masih dimiliki root. Kedua, tidak ada satu baris [dev] pun pada keluaran — panggung runtime berakar pada base, bukan pada dev, sehingga BuildKit tidak perlu menyentuh panggung dev sama sekali ketika hanya runtime yang diminta. Perbandingan ukurannya kini terbaca langsung:

bash
$ docker images katalog-web

Output yang Dihasilkan:

text
REPOSITORY     TAG        IMAGE ID       CREATED         SIZE
katalog-web    dev        4b7e2c9a1d5f   5 minutes ago   174MB
katalog-web    runtime    8c3f1e6a9b2d   2 minutes ago   150MB

Selisih 24 MB itu adalah harga devDependencies — dan itu harga yang wajar untuk dibayar pada image dev. Audit lapisan memperkuat keyakinan bahwa image produksi benar-benar bersih:

bash
$ docker history katalog-web:runtime

Output yang Dihasilkan (dipotong):

text
IMAGE          CREATED         CREATED BY                                      SIZE
8c3f1e6a9b2d   2 minutes ago   CMD ["node" "server.js"]                        0B
<missing>      2 minutes ago   EXPOSE 3000                                     0B
<missing>      2 minutes ago   USER node                                       0B
<missing>      2 minutes ago   COPY src/server.js ./ # buildkit                 4.4kB
<missing>      2 minutes ago   COPY /app/dist ./dist # buildkit                 7.9kB
<missing>      2 minutes ago   RUN /bin/sh -c npm ci --omit=dev # buildkit      19.4MB
<missing>      2 minutes ago   ENV NODE_ENV=production                          0B
...
<missing>      5 minutes ago   COPY src/package*.json ./ # buildkit             2.2kB
<missing>      5 minutes ago   WORKDIR /app                                     0B

Riwayat layer — mekanisme yang kita kenal dari Chapter Image Inspect dan History — memperlihatkan satu-satunya layer dependensi pada image runtime: RUN npm ci --omit=dev sebesar 19.4MB, dan tidak ada satu baris pun yang menyebut esbuild atau nodemon.

Dengan dua image berdiri berdampingan, peta lengkapnya kini bisa dibaca dalam satu tabel:

AspekImage dev (katalog-web:dev)Image produksi (katalog-web:runtime)
Dependensi terpasangdependencies + devDependencieshanya dependencies
Ukuran174MB150MB
Berjalan sebagai usernodenode
Alat pengawas dan buildnodemon, esbuild tersediatidak ada
Sumber kode saat berjalanbind mount ./srcCOPY beku di layer
Commandditimpa nodemon server.js di composenode server.js

Baris user sengaja sama di kedua kolom: berjalan non-root bukan sesuatu yang boleh dikompromikan hanya karena lingkungan development terasa lebih “aman” — kebiasaan dari Chapter Workdir dan User berlaku sejak panggung dev. Baris ukuran adalah satu-satunya perbedaan yang memang seharusnya membengkak di kolom dev, karena di sanalah seluruh alat kerja manusia tinggal.

Tiga Mount yang Saling Menutupi #

Chapter Bind Mount Source Code memperkenalkan pola tiga mount secara praktis; dengan pemahaman baru tentang dependensi, pola itu kini bisa dibedah utuh. Stack dev katalog-web hidup di atas tiga penyangga yang saling menutupi dan saling melengkapi:

  1. Bind mount ./src:/app membawa kode dari host — dua arah, seketika, dan tidak pernah membeku ke image; inilah wilayah yang dikerjakan editor dan git.
  2. Volume node_modules:/app/node_modules menyimpan dependensi milik container — menutupi subpath dalam bind mount sehingga npm ci --include=dev di image dev tidak pernah bocor binari musl ke host, dan sebagai volume bernama katalog-web_node_modules ia awet melewati docker compose down.
  3. Volume db-data menyimpan data milik container database — PostgreSQL menulis cluster-nya di wilayah Docker, bukan di host, dan umurnya melebihi seluruh siklus hidup stack.

Pembagian wilayah tersebut punya satu konsekuensi operasional yang wajib dipahami: volume node_modules diisi sekali dari image saat pertama kali kosong, tetapi tidak pernah diisi ulang dengan sendirinya. Ketika package.json bertambah dependensi baru, rebuild image menghasilkan node_modules yang segar di dalam image — namun volume yang lama masih memegang isi yang lama, dan container tetap membaca dari volume.

bash
$ docker compose rm -sf web
$ docker volume rm katalog-web_node_modules
$ docker compose up -d --build

Ritual tiga baris itu membuang container web, membuang volume dependensi lama, lalu menyalakan ulang stack — volume yang baru lahir kosong segera diisi pra-isi dari image hasil rebuild, dan dependensi baru pun hadir.

Dua image dari satu Dockerfile kini berdiri rapi: panggung dev melayani hari kerja dengan seluruh alatnya, panggung runtime membawa aplikasi ramping yang bebas alat debug. Namun memisahkan image hanyalah setengah dari cerita — setiap image juga butuh siklus hidupnya sendiri: dari mana image dev dibagikan agar Siti tidak perlu membangun ulang, bagaimana image runtime diberi tag dan versi yang disiplin, serta bagaimana keduanya diuji dan dikirim lewat pipeline. Seluruh siklus hidup berpasangan dev-produksi itulah yang dirakit tuntas pada Chapter Development dan Production Image.

Referensi #