Skip to main content
BlogDevOps

Cara Membuat Multi Stage Build Docker dari Dockerfile Biasa

Cara membuat multi stage build docker untuk pemula: pecah Dockerfile jadi beberapa stage, salin hanya yang dipakai, jalankan non-root, lalu ukur image.

Matthews Wong

Software Engineer

Oktober 202610 menit baca
Cara Membuat Multi Stage Build Docker dari Dockerfile Biasa

Ringkasan Utama

Cara membuat multi stage build Docker: beri nama stage lewat FROM node:22-alpine AS builder, build aplikasi di sana, lalu mulai stage baru dengan FROM dan ambil hanya hasil build memakai COPY --from=builder. Hanya stage terakhir yang menjadi image. Ukur sebelum dan sesudahnya dengan docker image ls, jangan menebak.

Dockerfile pertamamu jalan. Aplikasinya hidup di container, lalu kamu menjalankan docker image ls dan melihat kolom SIZE jauh lebih besar dari yang kamu kira. Wajar: di Dockerfile satu stage, image yang dipakai untuk mem-build aplikasi adalah image yang sama yang kamu kirim, jadi compiler, source code, dev dependency, dan cache unduhan ikut terbawa. Pemula biasanya tersangkut di tiga titik: tidak tahu cara memisahkan build dari runtime, salah menulis COPY --from, dan tidak punya cara untuk membuktikan bahwa ukurannya memang turun.

Panduan ini mengubah Dockerfile satu stage menjadi multi-stage dalam tujuh langkah, dengan perintah ukur di setiap tahap. Setiap perintah dan flag saya cocokkan dengan dokumentasi resmi Docker dan npm. Aplikasinya sengaja kecil, sebuah server TypeScript berisi satu file, supaya kamu bisa mencoba semuanya di laptop. Pola yang sama dipakai Dockerfile situs ini: stage deps, builder, dan runner, dengan hanya runner yang dikirim ke server.

Mau paham dulu apa yang sebenarnya dikirim image dan kenapa stage lain tertinggal? Ikuti pelajaran gratis Multi-Stage Build Docker: Memperkecil Image Next.js Nyata di jalur DevOps from zero, lengkap dengan penjelasan interaktif.

Yang kamu butuhkan

Tiga hal ini cukup. Kalau kamu belum pernah menulis Dockerfile sama sekali, baca dulu panduan belajar Docker dari nol, lalu kembali ke sini.

  • Docker terpasang di komputermu dan perintah docker jalan di terminal. Build di panduan ini memakai BuildKit, yaitu builder bawaan Docker saat ini.
  • Node.js dan npm terpasang, hanya untuk membuat file package-lock.json satu kali di langkah 1.
  • Satu folder kosong untuk latihan. Semua nama di bawah (devops-lab, runner, builder) adalah placeholder, ganti sesukamu.

Langkah 1: Siapkan aplikasi dan Dockerfile satu stage

Kita butuh gambaran sebelum perubahan, jadi mulai dari Dockerfile biasa yang mem-build dan menjalankan aplikasi di satu image. Buat file di bawah ini. File .dockerignore penting: ia membuat node_modules dan dist di komputermu tidak ikut tersalin oleh COPY . . di dalam image.

// package.json
{
  "name": "devops-lab",
  "version": "1.0.0",
  "private": true,
  "scripts": { "build": "tsc" },
  "devDependencies": { "@types/node": "22.7.5", "typescript": "5.6.3" }
}

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
    "outDir": "dist", "strict": true
  },
  "include": ["src"]
}

// src/server.ts
import { createServer } from "node:http";
createServer((req, res) => res.end("hello\n")).listen(3000, "0.0.0.0");

# .dockerignore  (keeps node_modules and dist out of "COPY . .")
node_modules
dist
out
.git
# Run once on your machine so the lockfile exists. "npm ci" refuses to run without it.
npm install

# Dockerfile.single: the "before" picture. One FROM, so the image that
# builds the app is the same image you ship.
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci && npm run build
CMD ["node", "dist/server.js"]

Simpan Dockerfile satu stage itu sebagai Dockerfile.single. Satu baris FROM berarti satu stage: semua yang dibutuhkan untuk mem-build, termasuk TypeScript dan seluruh node_modules, tetap ada di image akhir.

Langkah 2: Ukur ukuran image sebelum diubah

Jangan mengubah apa pun sebelum punya angka pembanding. Build image single-stage, lalu baca kolom SIZE. Docker menyebut kolom itu sebagai ukuran disk image. Angkamu akan berbeda dari angka orang lain, karena tergantung base image, arsitektur CPU, dan versi paket, jadi catat milikmu sendiri.

docker build -f Dockerfile.single -t devops-lab:single .

# The size column is the number to remember
docker image ls devops-lab
# REPOSITORY   TAG      IMAGE ID   CREATED   SIZE
# devops-lab   single   (id)       (time)    (your number)

# Or only the two columns you care about
docker image ls --format '{{.Tag}}  {{.Size}}' devops-lab

# One row per layer, with the instruction that made it
docker history devops-lab:single

Jalankan juga docker history. Perintah ini menampilkan satu baris per layer beserta instruksi yang membuatnya dan ukurannya. Cari layer dari COPY . . dan RUN npm ci: di situlah source code dan node_modules menumpuk, dan keduanya tidak kamu butuhkan untuk sekadar menjalankan hasil compile.

Langkah 3: Pecah jadi dua stage, builder dan runner

Sekarang ubah menjadi dua stage. Setiap FROM memulai stage baru di atas base image yang bersih, dan AS memberi nama pada stage itu. Stage bernama lebih aman daripada nomor (0, 1, ...), karena COPY tetap benar kalau urutan stage berubah. Stage runner tidak punya source maupun TypeScript. Ia mengambil satu folder dari builder lewat COPY --from.

# Dockerfile (version 1: two stages)
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM node:22-alpine AS runner
WORKDIR /app
# Reach back into the builder and take ONLY the compiled output
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

Dua hal yang perlu dipahami. Pertama, path setelah --from=builder dibaca dari root filesystem stage builder, bukan dari foldermu, jadi ia harus path absolut di dalam stage itu. Kedua, yang dikirim hanya stage terakhir. Stage builder tetap dibuat saat build, tetapi tidak ikut menjadi image yang kamu jalankan atau push.

Langkah 4: Pisahkan stage deps supaya cache terpakai

Tambahkan stage ketiga yang hanya menginstall paket. Ia menyalin dua file saja, package.json dan package-lock.json, lalu menjalankan npm ci. Builder kemudian mengambil node_modules-nya lewat COPY --from=deps. Dokumentasi npm menyebut npm ci memang untuk lingkungan otomatis seperti CI dan deployment, dan ia mewajibkan adanya package-lock.json.

# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
# Only the two files npm needs: this layer is rebuilt only when they change
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

Manfaatnya terasa di build berikutnya. Kalau kamu hanya mengubah src/server.ts, layer deps tidak berubah, sehingga npm ci tidak diulang. Ini juga susunan Dockerfile situs ini, dengan satu tambahan: di sana npm ci memakai cache mount untuk folder unduhan npm.

Kalau aplikasimu butuh paket runtime saat dijalankan (misalnya express), runner tetap perlu node_modules. Jangan menyalin seluruh node_modules dari builder. Buat stage tersendiri yang menjalankan npm ci dengan opsi --omit=dev (opsi omit tercantum di dokumentasi npm ci), lalu salin dari stage itu saja.

Langkah 5: Salin hanya yang dijalankan, dan jangan jalan sebagai root

Di sinilah pemula paling sering salah, jadi bandingkan tiga baris COPY di bawah. COPY tanpa --from membaca dari foldermu (build context), bukan dari stage lain. Dan menyalin seluruh node_modules dari builder membawa kembali semua dev dependency yang baru saja ingin kamu buang.

# Wrong: no --from, so Docker looks in YOUR folder, and "dist" is
# excluded by .dockerignore, so the build stops here with "not found"
COPY dist ./dist

# Wrong: ships every dev dependency again and undoes the whole point
COPY --from=builder /app/node_modules ./node_modules

# Right: name the stage, name the path inside that stage
COPY --from=builder /app/dist ./dist

Lalu tutup runner dengan user non-root. Panduan best practice Docker menyarankan USER non-root kalau service bisa jalan tanpa hak istimewa, dan menyarankan UID dan GID eksplisit. Perhatikan urutannya: COPY tanpa --chown membuat file dimiliki UID dan GID 0, yaitu root, jadi tambahkan --chown sebelum baris USER berlaku.

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# A fixed id (1001), because ids chosen automatically can differ between rebuilds
RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 -G app app
# Without --chown the files belong to root (UID and GID 0)
COPY --from=builder --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]

Stage runner ini baru berisi satu folder hasil build dan satu user. Kalau nanti aplikasimu perlu menulis ke sebuah folder, siapkan folder itu dan berikan ke user app di COPY atau RUN sebelum USER, karena setelah USER berlaku kamu sudah bukan root.

Langkah 6: Build ulang, bandingkan, lalu jalankan

Build versi multi-stage dengan tag lain, lalu bandingkan dua tag di docker image ls. Jalankan juga id di dalam container untuk membuktikan user-nya, dan curl untuk membuktikan aplikasi tetap hidup. Hasil yang diharapkan: uid=1001 dari perintah id, dan teks hello dari curl.

docker build -t devops-lab:v1 .
docker image ls devops-lab
# You now see two tags, single and v1. Compare the SIZE column yourself.

# Who does the container run as? Expect uid=1001
docker run --rm devops-lab:v1 id

# Does it still work?
docker run -d --name lab -p 3000:3000 devops-lab:v1
curl http://localhost:3000
# hello

docker rm -f lab

Jujurlah pada angkanya. Multi-stage membuang perkakas build, tetapi base image tetap menentukan batas bawah ukuran. Pada aplikasi sekecil ini selisihnya bisa tidak dramatis. Selisih besar baru terasa pada aplikasi nyata dengan banyak dev dependency, seperti Next.js, dan di sana pelajaran terkait di bawah menunjukkan caranya.

Jangan menyimpulkan multi-stage berhasil hanya karena build-nya hijau. Buktikan dengan tiga pengecekan: docker image ls membandingkan ukuran dua tag, docker run --rm nama-image id menunjukkan user bukan root, dan aplikasi menjawab di port-nya. Satu COPY --from yang salah path bisa menghasilkan image kecil yang tidak berjalan sama sekali.

Langkah 7: Build satu stage saja dengan --target

Kadang kamu ingin melihat isi sebuah stage tengah, misalnya builder, tanpa membangun sampai akhir. Pakai docker build --target diikuti nama stage: build berhenti di stage itu. Dokumentasi Docker menyebut BuildKit hanya mem-build stage yang dibutuhkan target, sedangkan builder lama memproses semua stage sampai target. Stage FROM scratch adalah image kosong, cocok untuk stage yang hanya ada untuk diekspor, dan exporter local menulis isinya ke folder biasa, bukan membuat image.

# Stop at the builder stage and tag it, to look inside
docker build --target builder -t devops-lab:builder .
docker run --rm devops-lab:builder ls /app

# Add this stage between builder and runner in the Dockerfile:
FROM scratch AS dist-export
COPY --from=builder /app/dist /

# Export its files to a folder. No image is created.
docker build --target dist-export --output type=local,dest=./out .
ls out
# server.js

Satu catatan dari Dockerfile situs ini. Build tanpa --target menghasilkan stage terakhir di file, jadi stage pembantu seperti cache-export saya letakkan sebelum runner, bukan sesudahnya, supaya runner tetap yang dikirim. Stage pembantu juga tidak selalu gratis: kalau cache build hilang, semua stage di atasnya dijalankan ulang.

Error umum dan cara memperbaikinya

Enam gejala yang paling sering muncul saat pertama kali menulis multi-stage Dockerfile, urut dari yang paling sering.

GejalaPenyebabPerbaikan
Build berhenti di baris COPY dengan pesan not found, padahal foldernya ada di komputermuCOPY tanpa --from membaca dari build context, dan folder itu dikecualikan .dockerignore atau memang hanya ada di stage builderTambahkan --from=builder dan tulis path absolut di dalam stage itu, misalnya COPY --from=builder /app/dist ./dist
Build gagal karena nama stage tidak ditemukan, atau image yang ditarik bukan yang kamu maksudNama setelah --from salah ketik atau tidak cocok dengan AS. Nama yang tidak dikenal bisa dianggap sebagai nama image dari registrySamakan nama persis dengan yang ada setelah AS, dan gunakan nama stage, bukan nomor
Image multi-stage hampir tidak lebih kecil dari yang single-stageSeluruh node_modules disalin dari builder, atau base image memang sudah mengisi sebagian besar ukuranSalin hanya hasil build, bandingkan docker history kedua tag, dan pertimbangkan base image yang lebih kecil
Container langsung mati dengan error module not found saat dijalankanRunner hanya menerima hasil compile, padahal aplikasi butuh paket runtime di node_modulesBuat stage yang menjalankan npm ci --omit=dev dan salin node_modules dari stage itu ke runner
Aplikasi gagal menulis file atau folder, dengan error permission deniedFile disalin tanpa --chown sehingga dimiliki root, sementara proses berjalan sebagai user app setelah USERTambahkan --chown=app:app pada COPY, atau buat folder yang bisa ditulis sebelum baris USER
Setiap perubahan kecil di source membuat npm ci berjalan ulang dari awalCOPY . . diletakkan sebelum npm ci, sehingga layer install ikut tidak validSalin dulu package.json dan package-lock.json, jalankan npm ci di stage deps, baru salin source di stage builder

Checklist sebelum kamu push image

  • Setiap stage punya nama lewat AS, dan setiap COPY --from memakai nama itu, bukan nomor.
  • Runner hanya berisi hasil build dan paket runtime, tanpa source, TypeScript, atau dev dependency.
  • Ukuran dua tag sudah dibandingkan dengan docker image ls dan angkanya kamu catat sendiri.
  • docker run --rm nama-image id menunjukkan user non-root dengan UID yang kamu tetapkan.
  • Aplikasi menjawab di port-nya, dan stage pembantu ditaruh sebelum stage yang dikirim.

Belajar lebih dalam

Penjelasan interaktif tentang apa yang dibawa tiap stage ada di pelajaran gratis Multi-Stage Build Docker: Memperkecil Image Next.js Nyata. Kalau aplikasimu Next.js, lihat bagaimana output standalone menentukan isi runner di ukuran image Docker standalone Next.js, dan kalau ingin base image yang lebih ramping, baca optimasi image Docker distroless.

Aturan yang bisa kamu bawa: build di satu stage, jalankan di stage lain, dan salin antar stage hanya apa yang benar-benar dijalankan. Lalu buktikan dengan angka dari docker image ls milikmu sendiri, bukan dari artikel.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu multi-stage build Docker dan kenapa dipakai?

Multi-stage build adalah Dockerfile dengan lebih dari satu instruksi FROM, dan setiap FROM memulai stage baru. Kamu mem-build aplikasi di stage awal lalu menyalin hanya hasilnya ke stage terakhir, yang menjadi satu-satunya image. Hasilnya image lebih kecil dan tidak membawa source code atau perkakas build.

Kenapa multi-stage Dockerfile saya error not found di COPY?

COPY tanpa --from membaca dari build context, bukan dari stage lain. Kalau folder itu dikecualikan .dockerignore atau hanya ada di stage builder, build berhenti dengan pesan not found. Tulis COPY --from=builder dan pakai path absolut di dalam stage itu.

Apakah multi-stage build pasti membuat image jauh lebih kecil?

Tidak selalu. Multi-stage membuang perkakas build dan source dari image akhir, tetapi base image tetap menentukan ukuran minimum. Penghematannya besar untuk aplikasi dengan banyak dev dependency dan kecil untuk aplikasi mungil. Ukur dengan docker image ls pada kedua tag, jangan percaya angka dari artikel.

Bisakah saya build satu stage saja dari multi-stage Dockerfile?

Bisa. Jalankan docker build --target diikuti nama stage dan build berhenti di stage itu. BuildKit hanya mem-build stage yang dibutuhkan target. Ini berguna untuk melihat isi stage builder atau untuk stage yang hanya mengekspor file.

Kenapa stage terakhir sebaiknya memakai USER non-root?

Kalau aplikasi berhasil dibobol, proses yang berjalan sebagai root di dalam container punya kuasa jauh lebih besar daripada user tanpa hak istimewa. Panduan best practice Docker menyarankan USER non-root dengan UID dan GID eksplisit. Ingat bahwa COPY membuat file milik root kecuali kamu menambahkan --chown.