Mengenal Dockerfile sebagai resep pembuatan image: arsitektur layer, build context, perbandingan base image (Alpine vs Debian vs Slim), instruksi inti FROM, WORKDIR, COPY, ADD, RUN, EXPOSE, hingga perbedaan CMD vs ENTRYPOINT, lengkap dengan Dockerfile Node.js yang siap dibangun.

Setelah di episode 4 sebelumnya kita membahas siklus hidup kontainer — dari docker create sampai docker kill, inspeksi dengan docker inspect, monitoring dengan docker stats, hingga pembersihan memakai docker system prune — pada episode kali ini kita beranjak dari sekadar pemakai image menjadi pencipta image. Hingga saat ini, semua kontainer yang kita jalankan (nginx, ubuntu, hello-world) dibangun oleh orang lain. Padahal pekerjaan sehari-hari seorang engineer adalah mengemas aplikasi kita sendiri menjadi image yang reproducible, portabel, dan bisa dijalankan di mesin mana pun.
Mengapa topik ini krusial? Karena inilah jembatan antara "Docker jalan" dengan "Docker menghasilkan". Tanpa kemampuan menulis Dockerfile, kalian hanya bisa menjalankan software orang lain. Dengan Dockerfile, kalian bisa membungkus aplikasi Node.js, Python, Go, atau Java milik tim sendiri menjadi artefak yang sama persis di laptop, di server staging, maupun di CI/CD — dan inilah yang membunuh penyakit klasik "it works on my machine!" yang sudah kita bahas di episode 1. Di episode ini kita akan membedah konsep arsitektur layer, build context, instruksi-instruksi inti Dockerfile, perbedaan CMD vs ENTRYPOINT, lalu menutup dengan satu Dockerfile lengkap yang benar-benar berfungsi.
Dockerfile adalah sebuah file teks berisi serangkaian instruksi yang menjelaskan langkah demi langkah bagaimana sebuah image harus dibangun. Ia adalah recipe — resep masakan — sedangkan image adalah hasil jadi dari resep tersebut, dan kontainer adalah "piring" tempat hasil masakan itu disajikan dan dinikmati.
Bayangkan dua cara menyiapkan server produksi. Cara pertama: duduk berjam-jam di depan mesin, mengetik apt install satu per satu, menyalin file dengan scp, mengubah konfigurasi secara manual. Cara kedua: menuliskan semua langkah itu ke dalam satu file yang bisa dieksekusi kapan pun dan oleh siapa pun, dengan hasil yang selalu identik. Dockerfile adalah cara kedua. Ketika kalian menjalankan docker build, daemon Docker mengeksekusi setiap instruksi secara berurutan, dari atas ke bawah, dan menghasilkan sebuah image yang siap dijalankan.
Ada cara lain untuk membuat image — misalnya docker commit yang memotret kontainer yang sedang berjalan menjadi image. Tapi cara itu sangat tidak disarankan karena hasilnya tidak reproducible: kalian tidak tahu langkah apa saja yang terjadi di dalamnya, dan image semacam itu tidak bisa direview di code review maupun diuji secara terpisah. Dockerfile adalah source of truth — ia bisa masuk ke git, bisa di-review, bisa di-versioning — sedangkan image hanyalah artefak turunannya.
Satu konsep yang wajib dicerna sebelum menulis instruksi apa pun: setiap instruksi di dalam Dockerfile menghasilkan satu layer. Image, secara fisik, adalah kumpulan layer read-only yang saling bertumpuk — seperti lasagna, atau seperti cincin pohon: setiap lapisan hanya merekam perubahan terhadap lapisan sebelumnya, bukan seluruh sistem.
Analogi yang paling mudah: bayangkan kalian memotret sebuah kue setiap kali satu bahan ditambahkan. Foto pertama adalah dasar kue (base image), foto kedua adalah kue dengan krim (hasil instruksi pertama), foto ketiga adalah kue dengan krim dan topping (hasil instruksi kedua). Setiap foto hanyalah tambahan dari foto sebelumnya. Itulah image Docker: tumpukan snapshot yang masing-masing hanya berisi delta. Untuk melihat tumpukan ini secara nyata, jalankan:
docker image history my-app:1.0IMAGE CREATED CREATED BY SIZE
abcdef123456 10 seconds ago CMD ["node" "index.js"] 0B
1234567890ab 10 seconds ago EXPOSE 3000 0B
0987654321ba 20 seconds ago COPY . . 1.2kB
...Konsekuensi praktis dari arsitektur layer ini ada dua. Pertama, layer bersifat cacheable — jika satu instruksi tidak berubah, Docker akan memakai ulang layer tersebut dari cache saat build berikutnya, sehingga build jauh lebih cepat (ini akan kita bedah dalam di episode 6). Kedua, karena setiap layer menyimpan delta, semakin banyak instruksi maka semakin besar image, dan setiap file yang pernah disalin akan "terkunci" di dalam layer-nya — itulah mengapa instruksi RUN yang meninggalkan file sampah (misalnya cache apt) akan membengkakkan image secara permanen, meskipun nanti kita menghapus file tersebut di instruksi berikutnya. Aturan emasnya: gabungkan perintah-perintah yang saling berhubungan dan bersihkan sampah di instruksi yang sama.
Ketika kalian mengetik docker build -t my-app:1.0 ., tanda titik di akhir adalah build context. Build context adalah direktori (beserta seluruh isinya) yang dikemas oleh Docker CLI menjadi tar archive dan dikirim ke Docker daemon untuk dipakai selama proses build.
Ini penting dipahami karena dua alasan. Pertama, COPY hanya bisa menyalin file yang berada di dalam build context — kalian tidak bisa menyalin file di luar direktori tersebut, misalnya COPY ../foo /bar akan error. Kedua, karena seluruh isi direktori dikirim ke daemon, semakin besar direktori semakin lambat build-nya. Jika kalian menjalankan docker build di dalam direktori proyek Node.js yang berisi node_modules (ratusan MB) dan folder .git, maka setiap build akan mengirim ratusan MB data yang sebenarnya tidak diperlukan. Inilah mengapa file .dockerignore penting — kita akan membahasnya lebih dalam di episode 6.
FROM adalah instruksi wajib pertama di setiap Dockerfile; ia menentukan base image — fondasi di atas mana image kalian dibangun. Memilih base image ibarat memilih tanah untuk membangun rumah: keputusan yang paling sulit diubah belakangan dan sangat memengaruhi hasil akhir.
Secara umum ada tiga keluarga base image yang paling sering dipakai:
| Base Image | Ukuran (kira-kira) | Package Manager | Ciri Khas |
|---|---|---|---|
alpine:3.20 | ±8 MB | apk | Paling kecil, memakai musl libc + BusyBox |
debian:bookworm-slim | ±50 MB | apt | Debian ramping, glibc, ekosistem paket lengkap |
node:20-alpine / node:20-slim | ±50-180 MB | apk / apt | Varitan resmi per-runtime |
node:20-alpine hanya sekitar 180 MB dibanding node:20 yang lebih dari 1 GB. Kelemahannya: Alpine memakai musl libc (bukan glibc), sehingga binary yang dikompilasi untuk glibc bisa gagal berjalan, dan beberapa paket native (bcrypt, sharp, dll.) butuh kompilasi ulang yang menambah waktu build.glibc dan apt. Ia menawarkan keseimbangan terbaik: kompatibilitas glibc yang luas + ukuran yang jauh lebih kecil daripada Debian penuh. Inilah pilihan default yang paling aman.curl, dll.) dan sangat berguna sebagai stage build (akan kita bahas di episode 7), tapi terlalu besar untuk image runtime.Aturan praktis: mulai dari varian -slim untuk production, pindah ke Alpine jika ukuran sangat krusial dan kalian sudah memverifikasi kompatibilitas dependensi, dan gunakan image penuh hanya untuk kebutuhan kompilasi di tahap build. Dan selalu pin versi base image (misalnya node:20-alpine, bukan node:latest) — kita akan lihat alasannya di bagian pitfalls.
WORKDIR menetapkan direktori kerja untuk semua instruksi setelahnya (RUN, COPY, CMD, ENTRYPOINT). Jika direktori belum ada, Docker akan membuatnya secara otomatis.
WORKDIR /app
RUN pwd # output: /appTanpa WORKDIR, kalian akan dipaksa menulis path absolut di setiap instruksi (COPY ./package.json /srv/app/package.json) dan berisiko menginstal sesuatu di lokasi yang tidak konsisten. Dengan WORKDIR /app, semua instruksi berikutnya berjalan seolah-olah kalian sedang berada di /app. Ini juga mereplikasi pengalaman kerja lokal — di laptop kalian, proyek berada di satu folder; di dalam image, folder itu adalah /app.
COPY dan ADD sama-sama berfungsi menyalin file dari build context ke dalam image. Perbedaannya: ADD memiliki dua perilaku magis yang tidak dimiliki COPY:
.tar, .tar.gz, dll.), ADD akan mengekstraknya otomatis ke direktori tujuan.ADD bisa menerima URL remote dan mengunduh file tersebut saat build.ADD app.tar.gz /app/ # tar diekstrak otomatis → isi app/ ter-ekstrak
ADD https://example.com/x / # file diunduh dari internet saat buildMengapa perilaku magis ini justru membuat ADD tidak disukai? Prinsip desain yang baik adalah eksplisit lebih baik daripada implisit. Ekstraksi tar otomatis membuat hasil akhir tidak terduga — kalian tidak tahu apakah file masuk sebagai arsip atau sudah ter-ekstrak. Sementara unduhan URL sangat tidak disarankan karena: tidak mendukung autentikasi, hasil unduhan bisa berubah sewaktu-waktu sehingga build menjadi non-reproducible, dan jika URL berubah isi namun kunci cache sama, kalian bisa mendapat binary yang korup tanpa disadari. Untuk unduh-dan-ekstrak seperti ini, jauh lebih baik memakai curl di dalam RUN yang jelas dan bisa dikendalikan. Kesimpulannya: gunakan COPY untuk semua kebutuhan penyalinan normal; gunakan ADD hanya jika kalian benar-benar butuh ekstraksi tar otomatis.
RUN mengeksekusi perintah shell pada saat proses build — bukan saat kontainer berjalan. Ia dipakai untuk hal-hal yang hasilnya harus "tertanam" di dalam image: menginstal paket sistem, mengunduh dependensi, mengompilasi kode.
Contoh paling khas adalah instalasi paket di Debian/Ubuntu. Perhatikan pola wajib berikut:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*Ada tiga hal penting di sini. Pertama, -y diperlukan agar apt-get install tidak bertanya konfirmasi (tidak ada keyboard saat build). Kedua, --no-install-recommends mencegah paket-paket rekomendasi yang tidak dibutuhkan ikut terinstal — hemat puluhan MB. Ketiga, dan ini yang paling sering dilupakan: rm -rf /var/lib/apt/lists/*. Perintah apt-get update menyimpan indeks paket di /var/lib/apt/lists, dan karena setiap layer menyimpan delta-nya, cache indeks ini akan terkunci permanen di dalam layer dan membengkakkan image. Pembersihan harus dilakukan di dalam instruksi RUN yang sama — kalau dipisah ke instruksi berikutnya, sampahnya sudah "terkunci" di layer sebelumnya. Inilah mengapa rantai && dipakai: memastikan semuanya satu layer.
EXPOSE 3000 terlihat seperti membuka port, padahal sebenarnya tidak melakukan apa-apa secara teknis. EXPOSE hanyalah metadata — dokumen yang memberi tahu pembaca image: "aplikasi ini mendengarkan di port 3000". Port baru benar-benar diakses dari luar saat kontainer dijalankan dengan docker run -p 3000:3000 atau docker run -P (yang memetakan semua port yang di-EXPOSE secara otomatis ke port acak).
EXPOSE 3000
# docker run -p 3000:3000 my-app:1.0 → baru inilah port benar-benar terbukaAnggap EXPOSE seperti kartu nama yang tertempel di pintu ruang server: ia memberi tahu orang apa yang ada di dalam, tapi bukan yang membuka pintunya.
CMD dan ENTRYPOINT sama-sama menentukan proses yang dijalankan ketika kontainer start. Keduanya punya dua bentuk penulisan:
["nginx", "-g", "daemon off;"]. Perintah dijalankan langsung tanpa shell, sehingga proses yang dieksekusi adalah PID 1 dan menerima sinyal SIGTERM secara langsung (krusial untuk graceful shutdown).nginx -g "daemon off;". Perintah dibungkus dalam /bin/sh -c, sehingga yang menjadi PID 1 adalah shell, bukan aplikasinya — dan sinyal SIGTERM tidak diteruskan dengan benar ke aplikasi. Ini sumber masalah "container butuh waktu lama berhenti" yang banyak dialami pemula.Perbedaan intinya ada pada perlakuan terhadap argumen saat docker run:
FROM node:20-alpine
CMD ["node", "index.js"]
# docker run my-app → node index.js
# docker run my-app node -v → CMD digantikan TOTAL → node -vDi contoh pertama, CMD berperan sebagai perintah default yang bisa diganti total oleh argumen setelah nama image di docker run. Di contoh kedua, ENTRYPOINT menjadi perintah yang selalu dijalankan, sementara CMD menjadi kumpulan argumen default yang bisa ditimpa sebagian tanpa mengubah programnya. Pola ENTRYPOINT + CMD ini adalah kombinasi paling umum dan paling elegan: ENTRYPOINT menetapkan "aplikasi apa ini", CMD menetapkan "konfigurasi default-nya". Contoh klasik dari dunia nyata: ENTRYPOINT ["nginx", "-g"] + CMD ["daemon off;"] — sehingga kalian bisa docker run nginx -t untuk memvalidasi konfigurasi, dan daemon off; hanya menjadi nilai defaultnya.
Mari kita rakit semua yang sudah dipelajari menjadi sebuah proyek nyata: aplikasi Node.js sederhana yang menampilkan teks. Struktur direktori proyek:
my-app/
├── .dockerignore
├── Dockerfile
├── index.js
└── package.jsonIsi file index.js:
const http = require("http")
const server = http.createServer((req, res) => {
res.end("Halo dari kontainer Docker!")
})
server.listen(3000, () => console.log("Server berjalan di :3000"))Isi file package.json (sangat minimal):
{
"name": "my-app",
"version": "1.0.0",
"main": "index.js",
"scripts": { "start": "node index.js" },
"dependencies": {}
}Dan inilah Dockerfile-nya:
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]Perhatikan urutannya: package.json disalin dan npm install dijalankan sebelum menyalin seluruh kode. Karena kode sumber berubah setiap commit sedangkan package.json jarang berubah, memisahkan keduanya membuat Docker memakai cache layer npm install saat hanya kode yang berubah — build berikutnya jauh lebih cepat. Inilah awal dari layer caching yang akan kita optimalkan di episode 6.
Sekarang saatnya membangun image:
docker build -t my-app:1.0 .Flag -t (atau --tag) memberi nama dan versi pada image dengan format name:tag. Tanpa :tag, Docker memberi tag latest secara implisit. Nama image juga bisa memuat registry dan username, misalnya gcr.io/project/my-app:1.0. Untuk memberi label tambahan tanpa membangun ulang, gunakan docker tag:
docker tag my-app:1.0 my-app:latest
docker tag my-app:1.0 registry.example.com/team/my-app:1.0docker tag tidak membuat salinan — ia hanya membuat alias yang menunjuk ke image yang sama, sehingga instan. Tag inilah kelak menjadi mekanisme rilis: satu image diberi nama 1.0.0 dan latest sekaligus. Setelah di-tag, jalankan:
docker run -d --name my-web -p 3000:3000 my-app:1.0
curl http://localhost:3000Halo dari kontainer Docker!Menginstal dependensi aplikasi sebagai root. Secara default, proses di dalam kontainer berjalan sebagai root. Ini berarti aplikasi, file, dan data kalian semuanya dimiliki root — dan jika kontainer dibobol, penyerang mendapat akses root penuh. Kebiasaan yang benar adalah membuat user non-root dan berpindah dengannya menggunakan instruksi USER — kita akan membahasnya tuntas di episode 6.
Tidak memakai .dockerignore. Tanpa file ini, seluruh isi build context — termasuk node_modules, .git, dan mungkin file .env berisi secret — ikut dikirim ke daemon. Selain memperlambat build, ini adalah risiko keamanan serius. Buat .dockerignore sejak proyek pertama.
Menggunakan tag latest. latest adalah label yang bisa berubah; ia tidak menunjuk ke versi tertentu. Jika image di-push ulang, latest bergeser ke versi baru dan kalian tidak bisa rollback. Selalu pin versi: my-app:1.0, bukan my-app:latest.
RUN tanpa pembersihan. Seperti pola apt di atas, setiap file sementara yang tertinggal akan "terkunci" di layer dan membengkakkan image selamanya. Biasakan menggabungkan instalasi dan pembersihan dalam satu rantai &&.
Important
Selalu tulis exec form (["node", "index.js"]) untuk CMD dan ENTRYPOINT, bukan shell form (node index.js). Exec form menjadikan aplikasi sebagai PID 1 sehingga sinyal SIGTERM diterima langsung — ini prasyarat untuk graceful shutdown dan nanti sangat terasa ketika kalian melakukan rolling update di produksi.
Pada episode 5 ini kalian telah belajar menulis Dockerfile dari nol: memahami bahwa image adalah tumpukan layer yang masing-masing menyimpan delta, bahwa docker build mengirim build context ke daemon, memilih base image yang tepat antara Alpine (kecil tapi musl), Debian Slim (glibc seimbang), dan image penuh (kaya tooling), lalu menguasai instruksi inti FROM, WORKDIR, COPY (yang selalu mengalahkan ADD), RUN dengan pola pembersihan, EXPOSE yang murni dokumentasi, serta perbedaan fundamental CMD (default yang bisa di-override) dan ENTRYPOINT (perintah tetap) dalam bentuk exec form. Kalian juga sudah membangun dan men-tag image pertama kalian sendiri dengan docker build -t dan docker tag.
Inti yang harus kalian bawa pulang: Dockerfile adalah source of truth, image adalah artefak turunannya, dan setiap instruksi adalah satu layer yang bisa di-cache — jadi urutan instruksi sangat menentukan kecepatan build dan ukuran image.
Dockerfile dasar ini baru permulaan. Di episode 6 selanjutnya kita akan membuatnya production-grade: membedah ENV vs ARG, menjalankan proses sebagai non-root dengan USER, memperkaya metadata dengan LABEL, dan yang paling penting — menguasai layer caching dengan urutan instruksi yang benar, .dockerignore, serta meminimalkan jumlah layer. Kalian akan melihat build yang tadinya 2 menit menciut menjadi beberapa detik. Sampai jumpa di episode 6!