Skip to main content
DevOps dari nol · Pelajaran 4 dari 8 · 12 menit

Multi-Stage Build Docker: Memperkecil Image Next.js Nyata

Belajar multi-stage build Docker dari Dockerfile nyata: stage bernama, COPY --from, user non-root, --target, dan kenapa hanya stage akhir yang dikirim.

Oleh Matthews WongDitinjau pada

Poin utama

Multi-stage build Docker adalah Dockerfile dengan beberapa instruksi FROM: setiap FROM memulai stage baru, dan COPY --from hanya membawa file yang Anda pilih dari satu stage ke stage berikutnya. Perkakas build tertinggal di stage awal, dan hanya stage terakhir yang menjadi image yang dijalankan, sehingga ukurannya lebih kecil dan tidak membawa source code maupun dev dependency.

Setelah pelajaran ini, Anda bisa

  • Memecah Dockerfile menjadi beberapa stage bernama dan hanya menyalin hasil build ke stage terakhir.
  • Menjelaskan apa yang dibawa output standalone Next.js, dan kenapa public serta .next/static harus disalin sendiri.
  • Menjalankan image akhir sebagai user non-root, dan mem-build stage yang tidak pernah dikirim dengan --target.
Lihat cara kerjanya

Ikuti diagramnya langkah demi langkah

Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.

Cara multi-stage build hanya mengirim yang dijalankan

Image single-stage mengirim semua yang dipakai saat build. Dengan stage deps, builder, dan runner, node_modules pindah ke builder, tiga folder hasil build pindah ke runner, dan hanya runner yang dikirim.

Langkah 1 dari 7. Di kiri, image single-stage: satu FROM mem-build sekaligus mengirim semuanya. Di kanan, aplikasi yang sama dalam tiga stage. Setiap FROM memulai stage baru, dan hanya runner yang dikirim.

Tombol: ← → langkah · Home / End · K putar atau jeda

  1. Di kiri, image single-stage: satu FROM mem-build sekaligus mengirim semuanya. Di kanan, aplikasi yang sama dalam tiga stage. Setiap FROM memulai stage baru, dan hanya runner yang dikirim.

  2. Dengan satu stage, semua yang dibutuhkan saat build ikut terkirim: source, dev dependency, cache unduhan npm, dan build cache Next. Tidak satu pun dibutuhkan untuk menjalankan aplikasi.

  3. Stage deps menginstall paket. Builder menyalin node_modules lewat COPY --from=deps, jadi perubahan source tidak membuat layer npm ci di-build ulang.

    RUN --mount=type=cache,target=/root/.npm npm ci COPY --from=deps /app/node_modules ./node_modules
  4. Builder menyalin source, termasuk public/, lalu menjalankan next build. Dengan output: standalone, Next.js menulis server.js minimal dan hanya node_modules yang terlacak dipakai.

    COPY . . RUN npm run buildTebak langkah berikutnya Runner mulai dari nol. Apa yang diambilnya dari builder? (Seluruh builder, termasuk source · Hanya tiga folder hasil build · node_modules dan source code)
  5. Runner mulai dari base image yang bersih dan menyalin tiga folder dari builder. Source code, node_modules lengkap, dan perkakas build tertinggal.

    COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
  6. Sebelum CMD, runner membuat system user dengan uid 1001 lalu beralih ke user itu, jadi proses server tidak berjalan sebagai root di dalam container.

    RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs USER nextjsTebak langkah berikutnya Dari tiga stage itu, mana yang di-push dan dijalankan? (Hanya runner · Ketiganya, ditumpuk jadi satu image)
  7. Hanya runner yang di-push dan dijalankan. Tanpa source dan dev dependency, image-nya lebih kecil dari image single-stage, walaupun base image tetap menentukan batas bawahnya.

Apa itu multi-stage build di Docker?

Multi-stage build adalah Dockerfile dengan lebih dari satu instruksi FROM. Setiap FROM memulai stage baru di atas base image yang bersih, dan stage bisa diberi nama dengan AS, misalnya FROM node:20-alpine AS builder. Stage tanpa nama diberi nomor mulai dari 0, tetapi nama tetap benar walaupun urutannya berubah, jadi pakailah nama.

Baca selengkapnya (1)

Stage berikutnya mengambil file dari stage sebelumnya dengan COPY --from ditambah nama stage, misalnya COPY --from=deps, dan hanya path yang disebut yang ikut. Dokumentasi Docker merangkumnya dalam satu kalimat: Anda menyalin artefak dari satu stage ke stage lain dan meninggalkan semua yang tidak Anda inginkan di image akhir. Stage yang ditinggalkan boleh seberat apa pun, karena tidak ada yang mengunduhnya.

Kenapa multi-stage build membuat image lebih kecil?

Mem-build aplikasi butuh jauh lebih banyak hal daripada menjalankannya: compiler, paket tipe, linter, seluruh source code, dan cache unduhan npm. Di image single-stage semuanya ikut terkirim, karena image yang mem-build aplikasi adalah image yang Anda jalankan. Situs ini membagi pekerjaannya menjadi tiga: deps menginstall paket, builder meng-compile, dan runner hanya menerima tiga folder, tidak lebih.

Baca selengkapnya (2)

Tiga folder itu berasal dari output standalone Next.js. Dengan output standalone, next build melacak file apa saja yang dimuat setiap halaman, lalu menulis server.js minimal beserta node_modules yang dibutuhkan saja ke .next/standalone. Server itu tidak menyalin public dan .next/static secara default, itulah sebabnya runner menyalin keduanya sendiri.

Multi-stage membuang perkakas build, tetapi base image yang menentukan batas bawahnya. Pada aplikasi uji yang kecil, image multi-stage hanya lebih kecil puluhan megabyte, karena node:22-alpine sendiri sudah mengisi sebagian besar image. Pilih base image dengan sengaja, lalu ukur dengan docker image ls, jangan percaya angka orang lain begitu saja.

Bagaimana stage akhir berjalan sebagai user non-root?

Runner membuat system group dan user dengan id 1001, menyalin hasil build dengan --chown=nextjs:nodejs, lalu beralih dengan USER nextjs. Sejak baris itu, semua instruksi berikutnya di stage tersebut dan server yang berjalan memakai user itu. Panduan best practice Docker singkat saja soal ini: jika sebuah service bisa berjalan tanpa hak istimewa, pakai USER untuk beralih ke user non-root.

Baca selengkapnya (1)

Urutannya penting. COPY tanpa --chown membuat file dimiliki uid dan gid 0, yaitu root. Itu tidak masalah untuk public, yang hanya dibaca server, tetapi apa pun yang harus ditulis aplikasi perlu dimiliki user runtime sebelum USER berlaku.

Kenapa Dockerfile ini punya stage yang tidak pernah dikirim?

docker build --target diikuti nama stage akan mem-build sampai stage itu lalu berhenti, dan BuildKit hanya mem-build stage yang dibutuhkan target tersebut. Situs ini memakainya untuk cache-export: stage yang dibangun FROM scratch, image kosong, yang hanya berisi cache kompilasi Next. CI mem-build-nya dengan target: cache-export dan local exporter, yang menulis file ke folder alih-alih membuat image, lalu menyerahkannya ke actions/cache untuk run berikutnya.

Baca selengkapnya (1)

Akui juga biayanya. Dockerfile menyebut stage ini memakai ulang semua layer builder, jadi biayanya hampir nol. Pada 2026-10-03 BuildKit ternyata sudah membuang layer-layer itu: step ini menjalankan ulang npm ci dan seluruh build, lalu gagal saat mengekspor cache sebesar 572 MB, dan kegagalan itu membuat deploy dilewati. Sekarang step ini bersifat best-effort, dengan continue-on-error.

Cara situs ini melakukannya

Konfigurasi nyata di balik pelajaran ini

Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.

Dockerfile:13-23
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# This carries .next/cache in with it — see the negation in .dockerignore. That
# cache is what makes `next build` incremental instead of compiling ~1,486 pages
# from cold on every deploy.
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Typechecking and linting are disabled in next.config.ts and run exactly once,
# in CI's `verify` job, which gates this build through `needs: verify`.
RUN npm run build

Stage builder. Stage ini mulai dari base image baru, mengambil node_modules dari deps, lalu seluruh source. Komentar terakhir adalah trade-off yang disengaja: type checking hanya berjalan sekali di CI, jadi next build di lokal tidak lagi menangkap error tipe. next.config.ts menyebut syarat keluarnya: jika job verify tidak lagi menjadi gerbang build, dua setelan itu harus dihapus dalam perubahan yang sama.

Dockerfile:58-66
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Bagian akhir runner, satu-satunya stage yang dikirim. Hanya tiga salinan: public tanpa --chown karena hanya dibaca, lalu server standalone dan file statis yang dimiliki user aplikasi. Di atas baris ini stage tersebut menyetel HOSTNAME=0.0.0.0, sehingga server.js mendengarkan di semua interface dalam container dan port mapping Docker bisa menjangkaunya.

Dockerfile:25-34
# Nothing but Next's compilation cache, so CI can pull it back out of the build
# and hand it to actions/cache for the next run. Without this the cache is a
# one-way street: the restore would warm the build context, the build would run
# inside Docker, and the runner's copy would never be updated — a cache step
# that looks like an optimisation and silently does nothing.
#
# This is a separate target, never part of the runner image. Building it reuses
# every layer from `builder`, so it costs close to nothing.
FROM scratch AS cache-export
COPY --from=builder /app/.next/cache /

Stage yang ada hanya untuk diekspor. Posisinya sengaja sebelum runner: dalam sebuah uji coba, build tanpa --target menghasilkan stage terakhir, jadi stage khusus CI yang diletakkan paling akhir akan menjadi image yang dikirim. Baris terakhir komentarnya adalah bagian yang optimis; insiden di atas menunjukkan kapan itu tidak berlaku.

.github/workflows/deploy.yml:142-150
      - name: Extract Next.js build cache
        id: extract-next-cache
        continue-on-error: true
        uses: docker/build-push-action@v6
        with:
          context: .
          target: cache-export
          outputs: type=local,dest=/tmp/next-cache
          cache-from: type=gha

Cara CI mem-build stage itu. target memilih stage-nya, dan outputs: type=local menulis file-nya ke folder di runner alih-alih mem-push image. continue-on-error adalah pelajaran dari 2026-10-03: image sudah di-push pada titik ini, jadi ekspor cache yang gagal tidak boleh menahan deploy.

Yang sering rusak

Kesalahan yang benar-benar membuang waktu

  • Halaman tanpa style: public dan .next/static terlupa

    Jika hanya .next/standalone yang disalin, container tetap start, health check lolos, dan setiap halaman tampil sebagai teks polos, karena setiap request stylesheet berbalas 404. server.js dari standalone tidak menyalin public atau .next/static. Salin keduanya, dan letakkan static di ./.next/static, bukan di sebelahnya.

  • COPY --from tanpa --chown, lalu USER

    File yang disalin tanpa --chown dimiliki root. Setelah USER beralih ke akun tanpa hak istimewa, aplikasi bisa membacanya tetapi tidak bisa menulis di sebelahnya, dan error-nya baru muncul saat runtime. Berikan path yang perlu ditulis kepada user runtime di COPY yang sama, atau buat path itu sebelum USER.

  • Menyalin seluruh node_modules ke runner

    Dockerfile setengah jadi yang umum membuang source tetapi tetap menyalin node_modules dari builder, sehingga semua dev dependency ikut terkirim lagi. Dengan output standalone hal itu tidak perlu: hasil tracing sudah memasukkan modul yang dipakai server.js ke .next/standalone.

  • Stage khusus CI yang ternyata tidak gratis

    Target pembantu hanya murah selama build cache masih menyimpan layer yang dibutuhkannya. Saat cache itu hilang, mem-build target tersebut menjalankan ulang semua stage sebelumnya. Jadikan step seperti ini best-effort jika berjalan setelah image di-push, dan jangan pernah meletakkannya paling akhir di Dockerfile.

Single-stage atau multi-stage: apa yang berubah
Baris FROMSatuSatu per stage
Yang dikirimSemua sisa proses buildHanya yang disalin stage terakhir
Source dan dev deps di productionYaTidak
Output tambahan untuk CITidak bisaTarget terpisah, di-build dengan --target
Yang harus benarSedikitSetiap path dan pemilik di COPY --from
Coba sendiri

Lakukan di komputer Anda sendiri

Anda hanya butuh Docker dan Node. Build server TypeScript kecil yang sama dengan dua cara, bandingkan ukurannya, lalu ekspor satu stage ke folder.

  1. Buat file proyeknya, lalu jalankan npm install sekali untuk membuat package-lock.json.

    // 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
    node_modules
    dist
    out
    .git
  2. Tulis Dockerfile multi-stage: deps, builder, stage khusus ekspor, dan runner yang berjalan sebagai uid 1001.

    # syntax=docker/dockerfile:1
    FROM node:22-alpine AS deps
    WORKDIR /app
    COPY package.json package-lock.json ./
    RUN --mount=type=cache,target=/root/.npm npm ci
    
    FROM node:22-alpine AS builder
    WORKDIR /app
    COPY --from=deps /app/node_modules ./node_modules
    COPY . .
    RUN npm run build
    
    FROM scratch AS dist-export
    COPY --from=builder /app/dist /
    
    FROM node:22-alpine AS runner
    WORKDIR /app
    ENV NODE_ENV=production
    RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 -G app app
    COPY --from=builder --chown=app:app /app/dist ./dist
    USER app
    EXPOSE 3000
    CMD ["node", "dist/server.js"]
  3. Simpan aplikasi yang sama sebagai single stage di Dockerfile.single.

    FROM node:22-alpine
    WORKDIR /app
    COPY . .
    RUN npm ci && npm run build
    CMD ["node", "dist/server.js"]
  4. Build keduanya dan bandingkan kolom ukurannya. Lalu periksa user yang dipakai runner.

    docker build -t devops-lab:v1 .
    docker build -f Dockerfile.single -t devops-lab:single .
    docker image ls devops-lab
    docker run --rm devops-lab:v1 id
  5. Build stage ekspor saja ke sebuah folder. Tidak ada image yang dibuat; folder out hanya berisi server.js.

    docker build --target dist-export --output type=local,dest=./out .
    ls out
Kuis singkat

Tiga pertanyaan sebelum lanjut

Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.

  1. 1.Runner menyalin folder data dengan COPY --from=builder tanpa --chown, lalu beralih ke USER nextjs. Saat runtime, aplikasi crash ketika menulis ke sana. Kenapa?
  2. 2.Sebuah Dockerfile punya stage deps, builder, dan runner, tetapi runner juga menjalankan COPY --from=builder /app/node_modules ./node_modules. Dengan output standalone, apa yang dibatalkan baris itu?
  3. 3.CI mem-build stage pembantu dengan --target setelah image di-push, dan biasanya hanya butuh beberapa detik. Suatu hari build cache sudah kehilangan layer builder. Apa yang terjadi?
Pikirkan dulu

Jawab dalam hati, lalu cek

  1. 1.Apakah source code TypeScript situs ini ada di dalam image yang dikirim?Lihat jawabannya

    Jawaban:Tidak. Hanya runner yang dikirim, dan runner hanya menyalin public, .next/standalone, dan .next/static dari builder. Source-nya tertinggal di stage builder, yang tidak pernah di-pull siapa pun.

  2. 2.Container berjalan dan health check lolos, tetapi semua halaman tampil tanpa style. Apa yang terlupa di Dockerfile?Lihat jawabannya

    Jawaban:Menyalin .next/static, dan biasanya public juga. server.js dari standalone tidak menyalinnya secara default, jadi setiap file CSS dan JavaScript ber-hash berbalas 404.

  3. 3.Apa yang dihasilkan FROM scratch AS cache-export, dan kenapa letaknya sebelum runner?Lihat jawabannya

    Jawaban:Filesystem yang hanya berisi .next/cache, yang diekspor CI ke folder dengan --target dan local exporter. Letaknya sebelum runner karena build tanpa --target menghasilkan stage terakhir, dan stage itu harus menjadi image yang dikirim.

Pelajari lebih dalam

Bahasan mendalam terkait di situs ini