Setelah memahami bagaimana image tersusun dari layer dan bagaimana sistem tag bekerja, saatnya kita mempraktikkan cara membuat image sendiri. Bab ini membahas Dockerfile dari nol: cara menulisnya, instruksi-instruksi utama yang paling sering dipakai, teknik multi-stage build untuk memangkas ukuran image final, sampai kebiasaan yang perlu dijaga supaya Dockerfile tetap efisien dan aman dipakai jangka panjang. Baik Developer yang ingin men-containerize aplikasi pertamanya maupun Sysadmin/DevOps Engineer yang bertanggung jawab menjaga image tetap ramping di production, keduanya butuh fondasi yang sama: memahami betul apa yang terjadi di setiap baris Dockerfile yang mereka tulis.
5.1 Penulisan Dockerfile
Dockerfile adalah file teks biasa berisi serangkaian instruksi yang dibaca Docker secara berurutan dari atas ke bawah untuk membangun sebuah image. Setiap instruksi diawali kata kunci huruf besar (menurut konvensi, meski tidak wajib secara teknis) diikuti argumennya, dan Docker mengeksekusinya satu per satu, menghasilkan satu layer baru untuk setiap instruksi yang mengubah filesystem.
Secara default, Docker mencari file bernama persis Dockerfile (tanpa ekstensi) di root build context, yaitu direktori yang kita berikan sebagai argumen ke docker build. Build context inilah yang dikirim ke Docker daemon dan menjadi sumber file untuk instruksi seperti COPY. Mari mulai dari yang paling sederhana: buat direktori kerja baru berisi aplikasi statis kecil, lalu tulis Dockerfile pertama kita.
mkdir dockerfile-demo && cd dockerfile-demo
echo "<h1>Halo dari container</h1>" > index.htmlBuat file baru bernama Dockerfile di direktori yang sama, lalu isi dengan instruksi berikut.
FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.htmlDua baris ini sudah cukup untuk menghasilkan image yang bisa dijalankan. Baris pertama menentukan base image, baris kedua menyalin file index.html dari build context ke lokasi yang dibaca web server nginx bawaan image tersebut. Build image ini lewat docker build, dengan opsi -t untuk memberi nama dan tag, serta titik di akhir perintah yang menunjuk build context ke direktori saat ini.
docker build -t dockerfile-demo:1.0 .Perhatikan output build: setiap instruksi Dockerfile muncul sebagai satu step terpisah, lengkap dengan waktu eksekusinya. Sejak Docker Engine 23.0, BuildKit menjadi builder default untuk perintah docker build di semua platform, menggantikan builder klasik yang dipakai versi-versi sebelumnya. BuildKit membawa peningkatan performa lewat eksekusi paralel antar-stage yang independen dan cache yang lebih efisien, manfaat yang makin terasa begitu Dockerfile punya banyak stage terpisah seperti pada pola multi-stage build.
Jalankan container dari image yang baru dibangun untuk memastikan hasilnya benar.
docker run -d -p 8080:80 --name demo-nginx dockerfile-demo:1.0Akses http://localhost:8080 lewat browser atau curl, pastikan isi halaman menampilkan teks "Halo dari container" yang kita tulis di index.html.
curl http://localhost:8080Selain nama file Dockerfile standar, kita juga bisa memakai nama file lain (misalnya Dockerfile.prod) lewat opsi -f pada docker build, berguna saat satu proyek butuh beberapa varian Dockerfile untuk environment berbeda. Build context juga sebaiknya dijaga seminimal mungkin; setiap file di direktori context ikut dikirim ke Docker daemon saat build dimulai, jadi menyertakan folder besar yang tidak relevan (seperti node_modules atau .git) hanya memperlambat proses build tanpa manfaat. Buat file .dockerignore di direktori yang sama dengan Dockerfile untuk mengecualikan file-file tersebut, dengan pola penulisan yang mirip .gitignore.
node_modules
.git
*.log
Dockerfile
.dockerignoreKalau direktori build context yang ditunjuk di akhir perintah docker build salah atau tidak ada, Docker menampilkan pesan error unable to prepare context: path "..." not found; pastikan argumen path yang diberikan memang mengarah ke direktori yang benar-benar ada. Kalau file Dockerfile ada di lokasi context tapi isinya kosong, Docker menampilkan pesan the Dockerfile cannot be empty; pastikan file tersebut benar-benar berisi instruksi dan tersimpan sebagai teks biasa. Bersihkan container dan image percobaan setelah selesai.
docker rm -f demo-nginx
docker rmi dockerfile-demo:1.05.2 Dockerfile Instructions Utama
Setelah memahami alur dasar penulisan Dockerfile, kita perlu mengenal instruksi-instruksi yang paling sering dipakai satu per satu. Sub-bab ini membahas fungsi dan perilaku default masing-masing instruksi, lengkap dengan contoh praktik langsung.
5.2.1 FROM dan Base Image
FROM selalu jadi instruksi pertama yang bermakna di Dockerfile (kecuali didahului instruksi ARG yang dipakai di dalam FROM itu sendiri), menentukan base image yang jadi titik awal build. Sintaksnya sederhana: FROM <image>[:<tag>]. Kalau tag tidak disebutkan, Docker memakai tag latest secara default, meski seperti dibahas di bab tentang image, kebiasaan ini sebaiknya dihindari untuk kebutuhan production karena hasilnya sulit diprediksi.
Satu Dockerfile boleh memuat lebih dari satu instruksi FROM, masing-masing menandai awal stage baru. Setiap stage bisa memakai base image berbeda dan dirujuk lewat nama yang diberikan lewat kata kunci AS, pola yang jadi dasar teknik multi-stage build untuk memisahkan proses build dari image runtime final.
5.2.2 RUN, COPY, dan ADD
RUN mengeksekusi perintah di dalam build stage saat ini dan mencatat hasilnya sebagai layer baru, biasanya dipakai untuk instalasi package atau kompilasi kode. Instruksi ini punya dua bentuk penulisan: shell form (RUN <perintah>), yang dijalankan lewat /bin/sh -c di Linux, dan exec form (RUN ["executable", "param1", "param2"]), yang dijalankan langsung tanpa shell.
FROM debian:12-slim
RUN apt-get update && apt-get install -y curlMenggabungkan apt-get update dan apt-get install dalam satu instruksi RUN lewat operator && adalah kebiasaan penting, bukan sekadar gaya penulisan. Kalau dipisah jadi dua RUN berbeda, layer cache dari apt-get update bisa saja terpakai ulang di build berikutnya meski daftar package terbaru dari repository sudah berubah, sehingga apt-get install berpotensi memasang versi package yang sudah usang.
COPY menyalin file atau direktori dari build context ke dalam filesystem image, dengan sintaks COPY <src>... <dest>. ADD punya fungsi dasar yang mirip, tapi menambah dua kemampuan ekstra: otomatis mengekstrak file arsip terkompresi (seperti .tar.gz) ke direktori tujuan, dan bisa mengambil file langsung dari URL. Dokumentasi resmi Docker merekomendasikan memakai COPY untuk kebutuhan menyalin file biasa, dan menyimpan ADD khusus untuk dua kasus khusus tadi, karena perilaku ADD yang lebih "pintar" justru bisa membuat isi image sulit ditebak hanya dari membaca instruksinya.
FROM debian:12-slim
COPY app.tar.gz /tmp/
ADD app.tar.gz /opt/app/Pada contoh di atas, ADD otomatis mengekstrak isi app.tar.gz ke /opt/app/, sedangkan COPY hanya menyalin file arsipnya apa adanya tanpa diekstrak.
5.2.3 CMD dan ENTRYPOINT
CMD menentukan perintah default yang dijalankan saat container dari image ini di-start tanpa argumen perintah tambahan. Hanya satu instruksi CMD yang berlaku dalam satu Dockerfile; kalau ditulis lebih dari sekali, hanya instruksi CMD terakhir yang dipakai. ENTRYPOINT punya fungsi serupa, tapi lebih sulit ditimpa dibanding CMD: argumen yang diberikan lewat docker run ditambahkan setelah perintah ENTRYPOINT, bukan menggantikannya sepenuhnya seperti yang terjadi pada CMD.
Kedua instruksi ini paling sering dipakai berpasangan: ENTRYPOINT menentukan binary utama yang selalu dijalankan, dan CMD menyediakan argumen default yang masih bisa ditimpa pengguna image.
FROM alpine:3.20
ENTRYPOINT ["echo"]
CMD ["Halo dari entrypoint"]docker build -t entrypoint-demo .
docker run entrypoint-demo
docker run entrypoint-demo "Pesan custom"Perintah pertama menampilkan "Halo dari entrypoint" karena memakai CMD default, sedangkan perintah kedua menampilkan "Pesan custom" karena argumen yang diberikan menggantikan isi CMD, sementara ENTRYPOINT tetap terpakai sebagai perintah utamanya. Sama seperti RUN, kedua instruksi ini juga punya exec form (ditulis dengan tanda kurung siku seperti contoh di atas) dan shell form; exec form lebih disarankan karena tidak membungkus proses dalam shell tambahan, sehingga sinyal seperti SIGTERM dari docker stop diteruskan langsung ke proses utama, bukan tertahan di proses shell.
Satu jebakan yang sering ditemui Developer baru: exec form wajib ditulis dengan sintaks JSON array yang valid, memakai tanda kutip ganda di setiap kata, bukan tanda kutip tunggal. Menulis CMD ['node', 'server.js'] dengan kutip tunggal akan membuat parser Dockerfile gagal mengenalinya sebagai JSON array yang sah, sehingga baris tersebut malah diperlakukan sebagai shell form utuh, bukan error langsung yang mudah dikenali. Pastikan selalu memakai kutip ganda seperti CMD ["node", "server.js"] supaya perilakunya konsisten dengan yang dimaksud.
5.2.4 WORKDIR, ENV, dan ARG
WORKDIR menetapkan direktori kerja untuk instruksi RUN, CMD, ENTRYPOINT, COPY, dan ADD yang ditulis sesudahnya, dan otomatis membuat direktori tersebut kalau belum ada. Memakai WORKDIR lebih disarankan dibanding RUN cd, karena perubahan direktori lewat RUN cd tidak terbawa ke instruksi berikutnya, sedangkan WORKDIR memang dirancang untuk itu.
ENV mendefinisikan environment variable yang tersedia selama sisa proses build dan juga ikut terbawa ke dalam container saat image dijalankan. ARG mirip, tapi hanya berlaku selama proses docker build berlangsung dan tidak pernah ada di dalam container hasil akhirnya, kecuali nilainya sengaja diteruskan ke ENV.
FROM node:20-alpine
ARG APP_ENV=production
ENV NODE_ENV=$APP_ENV
WORKDIR /app
COPY . .
RUN npm install --omit=devNilai ARG bisa ditimpa saat build lewat opsi --build-arg, berguna untuk membedakan konfigurasi antar environment tanpa perlu menulis Dockerfile terpisah.
docker build --build-arg APP_ENV=staging -t node-demo:staging .Karena nilai ARG ikut tercatat di riwayat build dan bisa dilihat lewat docker image history, jangan pernah memakai ARG untuk menyimpan data sensitif seperti password atau API key. Kebutuhan seperti itu sebaiknya ditangani lewat mekanisme secrets khusus saat build, bukan lewat ARG atau ENV biasa.
5.2.5 EXPOSE, LABEL, dan USER
EXPOSE mendokumentasikan port jaringan yang dipakai aplikasi di dalam container, dengan sintaks EXPOSE <port>[/<protocol>]. Perlu ditegaskan, EXPOSE sama sekali tidak membuka port tersebut ke luar container secara otomatis; instruksi ini murni bersifat informatif dan sebagai dokumentasi bagi siapa pun yang membaca Dockerfile. Publikasi port yang sesungguhnya tetap harus lewat opsi -p saat menjalankan docker run.
LABEL menambahkan metadata berupa pasangan key-value ke image, misalnya informasi maintainer, versi, atau deskripsi singkat. Metadata ini bisa dilihat lewat docker image inspect dan berguna untuk keperluan pencarian atau automasi di kemudian hari.
FROM alpine:3.20
LABEL maintainer="[email protected]"
LABEL version="1.0" description="Contoh image demo LABEL dan EXPOSE"
EXPOSE 8080/tcpUSER menentukan user (dan opsional group) yang dipakai untuk menjalankan instruksi setelahnya, termasuk proses utama container saat dijalankan. Kalau tidak disebutkan sama sekali, container berjalan sebagai root secara default, sesuatu yang berisiko dari sisi keamanan karena proses di dalam container punya hak akses penuh terhadap filesystem container tersebut. Praktik yang disarankan adalah membuat user non-root khusus dan beralih ke user tersebut sebelum instruksi CMD atau ENTRYPOINT.
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]Di lapangan, Sysadmin/DevOps Engineer yang menjalankan container di production sering mewajibkan aturan ini lewat policy keamanan cluster, menolak menjalankan image yang masih memakai root sebagai default user, karena kalau terjadi container escape, Attacker yang berhasil kabur dari container akan langsung punya hak akses root di host, bukan sekadar hak akses user terbatas.
5.3 Multi-Stage Build
Banyak aplikasi butuh proses build sebelum bisa dijalankan, misalnya kompilasi kode Go, transpile TypeScript, atau bundling asset frontend. Kalau seluruh proses ini dilakukan dalam satu stage yang sama dengan image runtime, seluruh tools build seperti compiler, package manager, dan dependency development ikut tertinggal di image final, membuat ukurannya jauh lebih besar dari yang sebenarnya dibutuhkan saat runtime. Attacker yang menemukan celah di aplikasi juga punya lebih banyak tools siap pakai di dalam container kalau image masih membawa seluruh toolchain build tadi.
Multi-stage build menjawab masalah ini lewat lebih dari satu instruksi FROM dalam satu Dockerfile, masing-masing menandai stage terpisah dengan base image sendiri-sendiri. Setiap stage bisa dipakai untuk keperluan berbeda, misalnya satu stage khusus untuk build dan satu stage lain khusus untuk runtime, lalu hanya artifact hasil build yang disalin ke stage runtime lewat instruksi COPY --from, sementara seluruh tools build tetap tertinggal di stage sebelumnya dan tidak pernah masuk ke image final.
Mari praktikkan dengan aplikasi Go sederhana. Buat direktori kerja baru dan file main.go.
mkdir multistage-demo && cd multistage-demopackage main
import "fmt"
func main() {
fmt.Println("Halo dari binary hasil multi-stage build")
}Simpan sebagai main.go. Sekarang tulis Dockerfile dengan dua stage: stage pertama bernama build memakai image golang lengkap untuk mengompilasi binary, stage kedua memakai alpine yang jauh lebih kecil sebagai image runtime.
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY main.go .
RUN go build -o /bin/hello main.go
FROM alpine:3.20
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]Perhatikan kata kunci AS build pada baris FROM pertama, ini memberi nama pada stage tersebut supaya bisa dirujuk belakangan. Baris COPY --from=build di stage kedua mengambil file binary /bin/hello langsung dari stage build, tanpa perlu menyalin source code, compiler Go, atau dependency build lainnya. Build image ini dan bandingkan ukurannya dengan hasil single-stage.
docker build -t multistage-demo:1.0 .
docker image ls multistage-demo:1.0Ukuran image final biasanya hanya beberapa belasan megabita, jauh lebih kecil dibanding image golang:1.23-alpine yang dipakai sebagai build stage, yang bisa mencapai ratusan megabita karena membawa seluruh toolchain Go. Jalankan container untuk memastikan binary hasil build berfungsi.
docker run --rm multistage-demo:1.0Selain golang, base image runtime pada stage terakhir juga bisa diganti scratch, image kosong tanpa filesystem sama sekali, kalau binary yang dihasilkan sudah dikompilasi secara statis dan tidak butuh library sistem tambahan seperti shell atau libc di runtime.
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -o /bin/hello main.go
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]Environment variable CGO_ENABLED=0 memastikan Go compiler menghasilkan binary statis tanpa dependency ke libc sistem, syarat mutlak supaya binary tersebut bisa berjalan di atas scratch yang benar-benar kosong. Perlu dicatat, image berbasis scratch tidak punya shell sama sekali, sehingga docker exec untuk masuk ke dalam container semacam ini tidak akan berhasil, sesuatu yang perlu dipertimbangkan Sysadmin/DevOps Engineer kalau debugging langsung di dalam container masih jadi kebutuhan rutin.
Multi-stage build juga mendukung penyalinan artifact dari image eksternal, bukan cuma dari stage yang kita definisikan sendiri di Dockerfile yang sama, lewat COPY --from=<nama_image>. Kita bisa memakai stage manapun sebagai target build lewat opsi --target pada docker build, berguna misalnya untuk menghasilkan image khusus development yang berhenti di stage build tanpa lanjut ke stage runtime.
docker build --target build -t multistage-demo:build-only .Bersihkan seluruh image percobaan setelah selesai.
docker rmi multistage-demo:1.0 multistage-demo:build-only5.4 Best Practices Dockerfile
Setelah menguasai instruksi dasar dan teknik multi-stage build, beberapa kebiasaan berikut layak dijaga konsisten supaya Dockerfile yang kita tulis tetap efisien, aman, dan mudah dipelihara jangka panjang.
Pilih base image seminimal mungkin sesuai kebutuhan. Varian alpine atau slim umumnya jauh lebih kecil dibanding base image distro lengkap, mengurangi ukuran image sekaligus permukaan serangan yang harus dipantau. Tapi seperti dibahas di bab tentang image, tetap uji kompatibilitas aplikasi sebelum berpindah ke base image minimal, terutama untuk aplikasi dengan banyak dependency native yang bergantung pada glibc.
Urutkan instruksi dari yang jarang berubah ke yang sering berubah. Instalasi dependency sistem sebaiknya ditulis lebih awal, dan penyalinan source code aplikasi ditulis belakangan, supaya layer cache bisa terpakai maksimal setiap kali hanya source code yang berubah tanpa perlu instalasi ulang dependency.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]Pada contoh di atas, package.json dan package-lock.json disalin dan di-install lebih dulu, terpisah dari COPY . . yang menyalin seluruh source code. Selama file dependency tidak berubah, Docker akan memakai ulang layer hasil npm ci dari cache meski source code aplikasi berubah setiap hari.
Gabungkan instruksi RUN yang saling berkaitan. Setiap instruksi RUN menghasilkan layer baru; menggabungkan beberapa perintah terkait dalam satu RUN lewat && mengurangi jumlah layer sekaligus memungkinkan kita membersihkan file sementara (seperti cache package manager) dalam instruksi yang sama, sebelum layer tersebut "dibekukan".
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*Membersihkan cache di instruksi RUN yang terpisah dari instalasinya tidak akan mengurangi ukuran image sama sekali, karena layer sebelumnya (yang masih berisi cache tersebut) tetap tersimpan permanen di dalam image; pembersihan hanya efektif kalau dilakukan dalam instruksi RUN yang sama dengan instalasinya.
Pakai file .dockerignore secara konsisten. Selain mempercepat proses build dengan memperkecil build context, .dockerignore juga mencegah file sensitif seperti .env atau kredensial lokal ikut tersalin ke dalam image secara tidak sengaja lewat instruksi COPY . yang menyalin seluruh isi direktori.
Jangan simpan secret di dalam layer image. Password, API key, atau private key yang sempat ditulis lewat ENV, ARG, atau disalin lewat COPY tetap tersimpan permanen di layer tersebut, meski file itu dihapus lagi di instruksi RUN berikutnya; layer sebelumnya tetap menyimpan datanya dan bisa diekstrak siapa pun yang punya akses ke image. BuildKit menyediakan opsi RUN --mount=type=secret khusus untuk kebutuhan ini, yang memasukkan secret hanya selama instruksi RUN berjalan tanpa pernah tercatat sebagai bagian dari layer image final.
Pakai multi-stage build untuk memisahkan tools build dari runtime. Seperti dibahas di sub-bab sebelumnya, pola ini secara konsisten menghasilkan image final yang lebih kecil dan lebih aman dibanding menumpuk semua proses dalam satu stage.
Jalankan container sebagai user non-root. Tambahkan instruksi USER di akhir Dockerfile untuk aplikasi yang tidak benar-benar butuh hak akses root, mengurangi dampak kalau suatu saat ditemukan celah keamanan pada aplikasi tersebut.
Tentukan tag versi spesifik untuk base image, jangan andalkan latest. Base image yang berubah isinya sewaktu-waktu tanpa kita sadari membuat build hari ini bisa menghasilkan image berbeda dari build kemarin, meski Dockerfile-nya sama persis. Sysadmin/DevOps Engineer yang mengejar reproducibility tinggi di pipeline CI/CD bahkan sering mengunci base image sampai ke digest-nya, bukan cuma tag versi.
| Kebiasaan | Manfaat |
|---|---|
| Base image minimal (alpine/slim) | Ukuran lebih kecil, attack surface lebih sempit |
| Urutan instruksi dependency sebelum source code | Cache hit lebih maksimal saat build ulang |
| Gabung RUN yang berkaitan + bersihkan cache di RUN yang sama | Layer lebih sedikit, ukuran layer lebih kecil |
| .dockerignore | Build context lebih ringkas, cegah file sensitif ikut tersalin |
| RUN --mount=type=secret untuk data sensitif | Secret tidak tertinggal permanen di layer image |
| Multi-stage build | Tools build terpisah dari image runtime final |
| USER non-root | Membatasi dampak kalau terjadi container escape |
| Tag versi spesifik (bukan latest) | Build lebih dapat diprediksi dan direproduksi |

