Ukuran Docker Image Next.js Standalone: Dockerfile Nyata

Foto oleh Daniel Linsbauer via Wikimedia Commons (Public domain)
Opsi itu mengubah apa yang ditulis next build ke disk. Next.js melakukan tracing terhadap file yang benar-benar dibutuhkan setiap route lalu menyalin hanya file itu, bersama server.js minimal, ke folder .next/standalone. Dokumentasi Next.js menyebut folder tersebut bisa di-deploy sendiri tanpa menginstal node_modules, dan server.js bisa dijalankan menggantikan next start.
Hampir selalu karena public dan .next/static tidak pernah disalin ke dalam image. Output standalone sengaja tidak menyertakan keduanya, sebab dokumentasinya mengasumsikan CDN yang melayaninya. Tambahkan baris COPY untuk /app/public dan /app/.next/static di stage runner, dan letakkan static di dalam .next yang sudah terbuka, bukan di sebelahnya.
Tidak. Bagian node_modules hasil tracing sudah disalin ke .next/standalone oleh proses build, jadi stage runner tidak pernah menjalankan install. Anda bisa membuktikannya secara lokal dengan menjalankan node .next/standalone/server.js dari root repositori tanpa menginstal apa pun di folder tersebut.
Umumnya aman, tetapi ini pertukaran nyata dan bukan keuntungan gratis. README nodejs/docker-node mencatat image Alpine sekitar 25% lebih kecil dari Debian slim, tetapi memakai musl libc dan bukan glibc, dan aplikasi yang dibangun untuk Debian umumnya tidak jalan di sana. Pastikan setiap native dependency menerbitkan build musl sebelum berpindah.
Pakai outputFileTracingIncludes di next.config. Key-nya berupa glob route yang dicocokkan dengan picomatch dan value-nya berupa pola glob yang diselesaikan dari root project. Ini perbaikan resmi untuk package yang dicapai lewat path yang dirangkai saat runtime atau binary native siap pakai seperti sharp, yang tidak terlihat oleh static analysis.

Foto oleh Daniel Linsbauer via Wikimedia Commons (Public domain)
Ringkasan Utama
Mengatur output ke standalone membuat Next.js melakukan tracing terhadap dependency nyata setiap route dan menyalin hanya file itu, bersama server.js minimal, ke .next/standalone. Tracing sengaja melewatkan public dan .next/static, sehingga Dockerfile multi-stage harus menyalin keduanya secara manual sebelum stage runner menjadi image yang dipublikasikan.
Standalone image pertama yang saya build jalan dengan bersih, menjawab 200 di semua route, dan menampilkan situs sebagai teks hitam tanpa styling di atas halaman putih. Tidak ada yang gagal. Health check hijau, log kosong, dan setiap permintaan stylesheet berakhir 404. Saya menyalin .next/standalone ke stage runner lalu berhenti di situ, persis kesalahan yang diperingatkan dokumentasi Next.js dalam satu kalimat yang sangat mudah terlewat.
Tulisan ini membahas apa yang sebenarnya dihasilkan output standalone, kenapa hasilnya kecil, dan di mana saja hasil itu diam-diam belum cukup. Dockerfile di bagian akhir adalah Dockerfile yang dipakai situs ini di VPS self-hosted di belakang nginx, pada Next.js 15.5. Semua klaim tentang perilaku tracing diperiksa ke dokumentasi output Next.js, bukan diingat-ingat.
Standalone adalah mode build output, bukan flag runtime. Saat next build berjalan, Next.js menulis folder .next/standalone yang berisi server.js minimal dan bagian dari node_modules yang dibutuhkan halaman hasil tracing. Dokumentasinya tegas soal konsekuensinya: folder itu bisa di-deploy sendiri tanpa menginstal node_modules, dan server.js bisa dipakai menggantikan next start.
// next.config.ts
const nextConfig = {
output: "standalone" as const,
};
export default nextConfig;
// Terminal, after the build. No install happens inside .next/standalone —
// the traced node_modules subset is already sitting there.
// next build
// node .next/standalone/server.jsPenghematannya bukan kompresi dan bukan runtime yang lebih kecil. Intinya, proses build sudah tahu file mana saja yang disentuh route Anda, jadi sisanya bisa ditinggal. Di repositori ini node_modules berukuran 821 MB dengan 38.161 file, diukur memakai du dan find pada 4 September 2026. Itu hasil install development, bukan production, tetapi persis pohon file itulah yang dibawa Dockerfile tanpa tracing ke dalam image yang dipublikasikan.
Folder standalone berdiri sendiri, dan itu memberi Anda tes satu detik. Setelah build, jalankan node .next/standalone/server.js dari root repositori tanpa langkah install apa pun di folder tersebut. Kalau ada module yang terlewat dari trace, kegagalannya muncul di sini persis seperti nanti di dalam container, sebelum Anda menghabiskan satu Docker build untuk mengetahuinya.
Tracing adalah static analysis, dan memahami hal itu langsung menjelaskan apa yang benar dan bagaimana ia gagal. Dokumentasi menyebutkan bahwa saat next build, Next.js memakai package vercel nft untuk menganalisis penggunaan import, require dan fs secara statis guna menentukan semua file yang mungkin dimuat sebuah halaman. Server production juga ikut di-trace, ke file bernama next-server.js.nft.json di dalam direktori .next.
Semua hal di daftar itu bisa dilihat sebuah analyser hanya dengan membaca kode tanpa menjalankannya. Path yang dirangkai saat runtime tidak bisa, dan itulah kelas kegagalan yang dibahas di bagian bawah. Tracing juga membuat monorepo perlu perhatian khusus: dokumentasi mencatat bahwa direktori project menjadi tracing root secara default, sehingga file di luarnya tidak ikut kecuali outputFileTracingRoot mengatur lain.
Ini kegagalan deployment standalone yang paling sering terjadi dan penyebabnya cuma satu baris. Dokumentasi menyatakan bahwa server minimal tidak menyalin folder public maupun .next/static secara default, karena idealnya keduanya ditangani CDN, meskipun keduanya bisa disalin manual ke standalone/public dan standalone/.next/static, dan setelah itu server.js melayaninya secara otomatis.
Gejalanya bukan pesan error. Container jalan, health check lolos, HTML dikirim, dan setiap asset ber-hash yang diminta halaman hilang. Kalau ada CDN di depan aplikasi dan assetPrefix mengarah ke sana, perilaku default itu sudah benar dan sebaiknya dibiarkan. Kalau Anda self-hosting di belakang nginx dan melayani asset sendiri, yang merupakan kasus paling umum, dua baris COPY menyelesaikannya secara permanen.
# Wrong: the container starts, the health check passes, and every request
# for /_next/static/... is a 404. No error is printed anywhere.
COPY --from=builder /app/.next/standalone ./
CMD ["node", "server.js"]
# Right: tracing copies neither of these, so you copy them yourself.
# .next/static goes INSIDE the unpacked .next, not next to it.
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
# The same two copies locally, straight from the Next.js docs, for reproducing
# the fixed behaviour before you spend a Docker build on it:
# cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/Salin .next/static ke dalam .next yang sudah terbuka, bukan di sebelahnya. Folder standalone terbuka sedemikian rupa sehingga server.js berada di root working directory, jadi tujuannya adalah ./.next/static. Salinan ke ./static tetap bisa di-build, tetap jalan, dan menghasilkan halaman tanpa styling yang persis sama seperti kalau barisnya dilupakan sama sekali — itulah alasan kesalahan ini memakan waktu satu sore, bukan satu menit.
Dokumentasi Docker menjelaskan multi-stage build sebagai beberapa perintah FROM yang masing-masing memulai stage baru, sehingga Anda bisa menyalin artefak tertentu dari satu stage ke stage lain dan meninggalkan semua yang tidak diinginkan di image akhir. Untuk build Next.js pembagiannya jatuh secara alami menjadi deps, builder dan runner, dan hanya runner yang menjadi image yang dipublikasikan.
deps ada semata-mata supaya perubahan source tidak membatalkan layer npm ci, dan itu penghematan waktu build yang tidak mengubah satu byte pun di image. builder memegang seluruh dependency tree dan seluruh source tree, dan tidak satu pun ikut dikirim. runner mulai dari base yang bersih dan menerima tepat tiga salinan. Berikut file lengkapnya, yang memang dipakai situs ini, dengan satu blok tentang V8 heap sizing yang tidak relevan dihapus.
# ---- deps: the only stage that ever runs npm ci ------------------------
FROM node:20-alpine AS deps
WORKDIR /app
# Lock file first, source later, so editing a component reuses the cached
# install layer. This is a build-TIME saving; it changes no published byte.
COPY package.json package-lock.json ./
RUN npm ci
# ---- builder: full dependency tree, full source, nothing published -----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Needs output: standalone in next.config.ts. Without it .next/standalone is
# simply not written and the runner's COPY fails with "not found".
RUN npm run build
# ---- runner: the only stage that becomes the published image -----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# server.js reads both. The official Next.js Docker example sets HOSTNAME
# explicitly rather than relying on the default, and so does this.
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# public/ is only ever read at runtime, so root ownership is harmless here.
COPY --from=builder /app/public ./public
# standalone unpacks to the working directory: server.js lands at /app/server.js
# with its traced node_modules and .next/server beside it.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Not traced either. Owned by the runtime user because .next is written to.
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Semua hal di atas bekerja pada kode Anda. Base image adalah keputusan tentang kode orang lain, dan pada aplikasi kecil biasanya ia menggeser angka akhir lebih jauh daripada seluruh pekerjaan lain digabung. Image node resmi didokumentasikan per varian, dan pertukarannya bukan hanya soal ukuran.
| Tag | Isinya | Konsekuensinya |
|---|---|---|
| node:20 | Berbasis buildpack-deps, dengan banyak sekali package Debian yang umum | Paling besar, dan jawaban resmi kalau Anda belum tahu kebutuhannya |
| node:20-slim | Debian tanpa kumpulan package buildpack-deps | Tetap memakai glibc, jadi native module berperilaku seperti di laptop Debian |
| node:20-alpine | Alpine Linux dengan musl libc, dan tanpa git atau bash di dalam image | Sekitar 25% lebih kecil dari slim, dibayar dengan kompatibilitas glibc |
README nodejs docker-node berterus terang soal harganya. Image Alpine sekitar 25% lebih kecil dibanding image slim berbasis Debian, tetapi Alpine memakai musl libc dan bukan GNU C library, dan umumnya aplikasi yang ditulis untuk Debian tidak akan jalan di Alpine. README yang sama juga mencatat bahwa build musl untuk amd64 berada di tier dukungan Experimental milik Node, dan build musl untuk arsitektur lain, termasuk arm64, tidak diuji sebelum rilis.
Itu peringatan jujur untuk apa pun yang punya native module. Situs ini berjalan di node:20-alpine dan belum pernah bermasalah, tetapi satu-satunya kode native di jalur runtime-nya adalah sharp, yang datang bersama Next.js dan menerbitkan build musl sendiri — package linuxmusl terdaftar di optional dependencies milik sharp pada lock file ini. Contoh Docker resmi Next.js justru mengambil sisi lain dari pertukaran ini dan memakai tag Debian slim. Tidak ada yang salah di antara keduanya; pilihlah dengan sadar, bukan dengan menyalin tag dari project sebelumnya.
Stage runner membuat system group dan user lalu berpindah ke sana sebelum CMD. Itu tidak memakan biaya apa pun, dan menghilangkan kelas masalah container yang paling membosankan. Detail yang sering menjegal adalah kepemilikan file: file yang disalin oleh root dimiliki root, dan runtime user kemudian tidak bisa menulis di tempat yang diharapkan Next.js.
public hanya dibaca saat runtime, jadi kepemilikan root di sana tidak berbahaya. .next berbeda — prerender cache dan image cache di disk berada di bawahnya, dan setiap route yang melakukan revalidate perlu menulis ke sana. Contoh Docker resmi Next.js menanganinya dengan membuat .next dan menyerahkannya ke runtime user sebelum salinan standalone masuk, lengkap dengan komentar yang menyebut prerender cache sebagai alasannya. Contoh itu juga memakai user node yang sudah ada di base image, bukan membuat user baru.
# Create .next and hand it to the runtime user BEFORE the standalone copy
# lands, so the on-disk prerender and image caches are writable at runtime.
RUN mkdir .next
RUN chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER nodeKarena analyser membaca kode dan tidak menjalankannya, dependency yang dicapai lewat path yang dirangkai saat runtime tidak terlihat olehnya. File itu tidak disalin, image tetap ter-build dan jalan dengan bersih, lalu permintaan pertama yang membutuhkan module tersebut gagal. Dokumentasi mengakuinya terang-terangan: ada kasus di mana Next.js bisa gagal menyertakan file yang dibutuhkan, atau justru menyertakan file yang tidak terpakai.
Jalan keluarnya adalah outputFileTracingIncludes, dengan outputFileTracingExcludes untuk masalah kebalikannya. Keduanya menerima object yang key-nya berupa glob route yang dicocokkan dengan picomatch terhadap path route, dan value-nya berupa pola glob yang diselesaikan dari root project. Contoh include umum di dokumentasinya adalah package sharp, yang memang persis bentuk hal yang tidak bisa ditemukan static analysis: binary native siap pakai yang tidak pernah disebut satu pun perintah import.
// next.config.ts
const nextConfig = {
output: "standalone" as const,
outputFileTracingIncludes: {
// Every server-traced route gets the native binary.
"/*": ["node_modules/sharp/**/*"],
// One route that reads a data file through a path built at runtime.
"/api/invoice": ["./templates/**/*.hbs"],
},
};
export default nextConfig;Ada dua batasan yang perlu diketahui sebelum memakainya. Opsi ini berlaku untuk server trace, jadi route Edge Runtime dan halaman yang sepenuhnya statis tidak terpengaruh. Dokumentasinya juga menyarankan agar pola dibuat sesempit mungkin supaya trace tidak membengkak, yang merupakan cara sopan untuk mengatakan bahwa wildcard di root repositori membatalkan seluruh fitur yang baru saja Anda nyalakan.

Setiap angka megabyte dalam tulisan tentang ukuran image, termasuk dua angka di tulisan ini, adalah fakta tentang dependency tree orang lain. Angka yang layak ditindaklanjuti hanyalah angka yang dicetak build Anda sendiri, dan angka itu murah untuk didapat.
# The number, before and after each single change.
docker image ls matthewswong-portfolio:latest
# Which instruction is carrying the weight. --no-trunc prints the whole
# command, so you can tell WHICH copy is large instead of guessing.
docker history --no-trunc --format "{{.Size}} {{.CreatedBy}}" matthewswong-portfolio:latest
# Open the builder stage — it is never published, so this is the only way
# to see the traced output next to the tree it was traced from.
docker build --target builder -t portfolio-builder .
docker run --rm portfolio-builder du -sh /app/node_modules /app/.next/standalone /app/.next/static
# What the same measurement says on this repository, run 2026-09-04:
# du -sh node_modules -> 821M
# find node_modules -type f | wc -> 38161 files
# du -sh public -> 78MMenggabungkan dua perubahan sekaligus adalah cara orang akhirnya memuji standalone atas penghematan yang sebenarnya datang dari mengganti Debian dengan Alpine, lalu menanggung peringatan musl yang sebetulnya tidak perlu mereka terima. Urutannya juga penting karena alasan kedua: kalau pergantian base image saja sudah cukup, pekerjaan tracing tetap layak dilakukan, tetapi Anda jadi tahu apa yang dibeli masing-masing.
docker history membebankan ukuran pada instruksi, jadi ia memberi tahu COPY mana yang mahal, bukan package mana. Ketika satu salinan mendominasi dan itu bukan node_modules, jawabannya biasanya public — asset statis tidak di-trace, tidak disentuh proses build, dan bertambah besar diam-diam. Folder public di repositori ini berukuran 78 MB, diukur pada 4 September 2026, sebagian besar berupa cover image hasil generate.
Standalone sepadan dengan dua baris COPY tambahan, tetapi perlakukan ia sebagaimana adanya: sebuah build output yang mengenal dependency graph Anda, bukan sebuah pengaturan ukuran. Tulis tiga stage-nya, salin sendiri public dan .next/static, pilih base image dengan sadar dan bukan karena kebiasaan, lalu percayai angka yang dicetak build Anda sendiri di atas angka yang Anda baca di artikel mana pun, termasuk yang ini.