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:
{
"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:
app.use(express.static(process.env.NODE_ENV === "production" ? "dist" : "public"));Mari kita telaah tiga keputusan pada blok di atas:
- esbuild berada di
devDependencies— aplikasi yang berjalan tidak pernah memanggil esbuild; ia hanya dipanggil sekali saat build untuk menghasilkan berkas statis didist/. - Script
buildadalah pekerjaan tahap build — hasilnya, folderdist/, adalah satu-satunya bagian dari esbuild yang pernah menyentuh produksi. - Pemilihan folder aset mengikuti
NODE_ENV— saat development, aset disajikan langsung daripublic/yang ter-bind mount sehingga suntingan skrip klien terlihat seketika; saat produksi,NODE_ENV=productionmengalihkan penyajian kedist/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:
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:
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.dev— seluruh dependensi untuk manusia —npm ci --include=devmemasang devDependencies secara eksplisit, PATH dibuka agar nodemon bisa dipanggil langsung, dan user turun kenode; kode sengaja tidak di-COPY karena selalu hadir lewat bind mount — keputusan yang kita bangun di Chapter Bind Mount Source Code.build— pengolah aset — memasang dependensi lengkap (esbuild adalah devDependency, jadi tahap build memang berhak memilikinya), menyalin source, lalu menjalankannpm run buildyang melahirkan/app/dist.runtime— image produksi —npm ci --omit=devhanya memasang dependencies,NODE_ENV=productionmengalihkan penyajian aset kedist, hasil build disalin denganCOPY --from=build, dan satu-satunya berkas aplikasi yang ikut adalahserver.js.
compose.yaml tidak berubah satu baris pun — dan justru di situlah letak kelegaan pola ini:
web:
build:
context: .
target: devDeklarasi 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 perintah | Yang dipasang | Panggung pemakai |
|---|---|---|
npm ci | dependencies + devDependencies — perilaku bawaan | bentuk ringkas pada chapter sebelumnya |
npm ci --include=dev | sama seperti bawaan, ditulis eksplisit | dev dan build |
npm ci --omit=dev | hanya dependencies | runtime |
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:
$ docker build --target runtime -t katalog-web:runtime .Output yang Dihasilkan (dipotong):
[+] 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.0sDua 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:
$ docker images katalog-webOutput yang Dihasilkan:
REPOSITORY TAG IMAGE ID CREATED SIZE
katalog-web dev 4b7e2c9a1d5f 5 minutes ago 174MB
katalog-web runtime 8c3f1e6a9b2d 2 minutes ago 150MBSelisih 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:
$ docker history katalog-web:runtimeOutput yang Dihasilkan (dipotong):
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 0BRiwayat 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:
| Aspek | Image dev (katalog-web:dev) | Image produksi (katalog-web:runtime) |
|---|---|---|
| Dependensi terpasang | dependencies + devDependencies | hanya dependencies |
| Ukuran | 174MB | 150MB |
| Berjalan sebagai user | node | node |
| Alat pengawas dan build | nodemon, esbuild tersedia | tidak ada |
| Sumber kode saat berjalan | bind mount ./src | COPY beku di layer |
| Command | ditimpa nodemon server.js di compose | node 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:
- Bind mount
./src:/appmembawa kode dari host — dua arah, seketika, dan tidak pernah membeku ke image; inilah wilayah yang dikerjakan editor dan git. - Volume
node_modules:/app/node_modulesmenyimpan dependensi milik container — menutupi subpath dalam bind mount sehingganpm ci --include=devdi image dev tidak pernah bocor binari musl ke host, dan sebagai volume bernamakatalog-web_node_modulesia awet melewatidocker compose down. - Volume
db-datamenyimpan 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.
$ docker compose rm -sf web
$ docker volume rm katalog-web_node_modules
$ docker compose up -d --buildRitual 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 #
- Multi-stage builds
— struktur panggung,
COPY --from, dan pemilihan--targetuntuk kebutuhan berbeda dari satu Dockerfile. - Dockerfile reference
— definisi instruksi
FROM ... AS,ENV,USER, danCOPY. - build — Compose file reference
— kunci
targetuntuk memilih panggung yang dibangun service. - docker image history — CLI Reference — audit layer sebagai bukti kebersihan image produksi.