Belajar Docker - Membuat Custom Image Menggunakan Dockerfile (Dasar)
Episode 5 of 28

Belajar Docker - Membuat Custom Image Menggunakan Dockerfile (Dasar)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
10 min read

Pendahuluan

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.

Pembahasan Utama

Apa itu Dockerfile?

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.

Layered Architecture: Image Itu Tumpukan Layer

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:

Melihat tumpukan layer sebuah image
docker image history my-app:1.0
Contoh output docker image history
IMAGE          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.

Build Context: Apa yang Dikirim ke Daemon?

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: Memilih Fondasi Image

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 ImageUkuran (kira-kira)Package ManagerCiri Khas
alpine:3.20±8 MBapkPaling kecil, memakai musl libc + BusyBox
debian:bookworm-slim±50 MBaptDebian ramping, glibc, ekosistem paket lengkap
node:20-alpine / node:20-slim±50-180 MBapk / aptVaritan resmi per-runtime
  • Alpine adalah juara ukuran — image 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.
  • Debian Slim adalah versi ramping dari Debian yang tetap memakai 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.
  • Debian/Ubuntu penuh mempertahankan semua tooling bawaan (compiler, 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: Menentukan Direktori Kerja

WORKDIR menetapkan direktori kerja untuk semua instruksi setelahnya (RUN, COPY, CMD, ENTRYPOINT). Jika direktori belum ada, Docker akan membuatnya secara otomatis.

WORKDIR — rumah bagi aplikasi di dalam image
WORKDIR /app
RUN pwd   # output: /app

Tanpa 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 vs ADD: Kenapa COPY Selalu Menang

COPY dan ADD sama-sama berfungsi menyalin file dari build context ke dalam image. Perbedaannya: ADD memiliki dua perilaku magis yang tidak dimiliki COPY:

  1. Jika sumbernya adalah file tar lokal (.tar, .tar.gz, dll.), ADD akan mengekstraknya otomatis ke direktori tujuan.
  2. ADD bisa menerima URL remote dan mengunduh file tersebut saat build.
ADD vs COPY — dua perilaku ajaib
ADD app.tar.gz /app/        # tar diekstrak otomatis → isi app/ ter-ekstrak
ADD https://example.com/x /  # file diunduh dari internet saat build

Mengapa 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 Saat Build

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 yang bersih untuk apt
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: Hanya Dokumentasi

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 sebagai dokumentasi
EXPOSE 3000
# docker run -p 3000:3000 my-app:1.0  → baru inilah port benar-benar terbuka

Anggap 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 vs ENTRYPOINT: Dua Cara Menentukan Proses Utama

CMD dan ENTRYPOINT sama-sama menentukan proses yang dijalankan ketika kontainer start. Keduanya punya dua bentuk penulisan:

  • Exec form — array JSON: ["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).
  • Shell form — string biasa: 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 -v

Di 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.

Contoh Dockerfile Lengkap

Mari kita rakit semua yang sudah dipelajari menjadi sebuah proyek nyata: aplikasi Node.js sederhana yang menampilkan teks. Struktur direktori proyek:

Struktur proyek my-app
my-app/
├── .dockerignore
├── Dockerfile
├── index.js
└── package.json

Isi file index.js:

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):

package.json
{
  "name": "my-app",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": { "start": "node index.js" },
  "dependencies": {}
}

Dan inilah Dockerfile-nya:

Dockerfile
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.

Build & Tag Image

Sekarang saatnya membangun image:

Membangun image dengan tag
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 — alias untuk image yang sama
docker tag my-app:1.0 my-app:latest
docker tag my-app:1.0 registry.example.com/team/my-app:1.0

docker 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:

Menjalankan image hasil build
docker run -d --name my-web -p 3000:3000 my-app:1.0
curl http://localhost:3000
Output curl
Halo dari kontainer Docker!

Pitfalls yang Sering Menghantui Pemula

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Penutup

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!

Belajar Docker - Membuat Custom Image Menggunakan Dockerfile (Dasar) | Belajar Docker