Mendalami Dockerfile level lanjut: membedah ENV vs ARG beserta scope-nya, instruksi USER, LABEL, dan SHELL untuk image yang aman, lalu menguasai .dockerignore, peminimalan layer, urutan instruksi, dan layer caching agar build lebih cepat dan image lebih ramping.

Setelah di episode 5 sebelumnya kita membangun Dockerfile pertama — memahami arsitektur layer, build context, perbedaan CMD vs ENTRYPOINT, dan menutup dengan sebuah Dockerfile Node.js yang berfungsi — pada episode kali ini kita akan merombak Dockerfile itu menjadi production-grade. Kita akan menjawab pertanyaan-pertanyaan yang muncul begitu kalian mulai serius: bagaimana cara mengoper konfigurasi yang berbeda antar environment tanpa mengubah kode? Mengapa aplikasi kalian harus berjalan sebagai non-root? Dan mengapa build tim kalian 2 menit sedangkan image hanya berbeda satu baris?
Topik ini penting karena di sinilah perbedaan antara "Dockerfile yang jalan" dan "Dockerfile yang pantas masuk code review" terlihat. Di dunia kerja, Dockerfile bukan sekadar file teknis — ia adalah artefak yang menentukan keamanan, kecepatan pengembangan, dan biaya infrastruktur. Sebuah Dockerfile yang buruk berarti container yang rentan (root), image yang membengkak (layer sampah), dan build yang lambat (cache tidak dimanfaatkan) — tiga masalah yang menghantui tim DevOps setiap hari. Episode ini memberi kalian senjata untuk membereskan ketiganya sekaligus.
Masalah paling umum yang muncul setelah menulis Dockerfile pertama: aplikasi kalian butuh konfigurasi yang berbeda-beda (misalnya URL database, NODE_ENV, port) antara environment development dan production. Docker menyediakan dua mekanisme, dan membedakannya dengan benar adalah fondasi segalanya.
ENV mendeklarasikan environment variable runtime yang tertanam permanen di dalam image. Nilainya ada di setiap lapisan setelah deklarasi, bisa dibaca oleh aplikasi saat kontainer berjalan, dan bisa ditimpa saat docker run -e KEY=value.ARG adalah variabel yang hanya hidup selama proses build. Nilainya bisa disuntikkan lewat docker build --build-arg KEY=value, dan tidak akan ada di dalam image yang dihasilkan (dengan catatan penting yang akan kita bahas).ARG NODE_VERSION=20
ENV NODE_ENV=production \
APP_PORT=3000
FROM node:${NODE_VERSION}-alpinePerhatikan contoh di atas: ARG NODE_VERSION=20 dideklarasikan sebelum FROM, sehingga bisa dipakai untuk memilih versi base image lewat interpolasi ${NODE_VERSION}. Ini satu-satunya penggunaan ARG yang hanya bisa dilakukan sebelum FROM. ARG juga bisa dideklarasikan setelah FROM, tapi dua deklarasi itu adalah variabel yang berbeda — ARG sebelum FROM tidak tersedia di dalam instruksi setelah FROM, dan sebaliknya.
Perbedaan scope ini bisa diringkas dalam tabel:
| Aspek | ENV | ARG |
|---|---|---|
| Waktu hidup | Terbakar ke dalam image, ada saat runtime | Hanya saat proses build |
| Suntikan nilai | docker run -e | docker build --build-arg |
| Bisa dibaca aplikasi | Ya | Tidak |
Terlihat di docker inspect | Ya (tertanam) | Tidak |
| Nilai default | Wajib atau via deklarasi | Bisa di-set di Dockerfile |
| Kapan dipakai | Konfigurasi aplikasi | Versi tool, varian build, parameter build-time |
Aturan memilihnya sederhana: jika aplikasi butuh nilainya saat berjalan → ENV. Jika hanya build yang butuh → ARG. Contoh nyata: NODE_ENV=production dan DATABASE_URL adalah ENV. Sedangkan NODE_VERSION (untuk memilih base image), VERSION (untuk label rilis), atau GOPROXY (mirror Go saat build) adalah ARG.
Warning
Jangan pernah meletakkan secret (password, API key, token) di ENV tanpa nilai default maupun sebagai ARG yang lalu disalin ke ENV — misalnya ARG DB_PASSWORD yang kemudian di-ENV-kan akan tertanam permanen di layer image dan terbaca siapa pun yang docker history. Nilai ARG memang tidak ikut ke image, tetapi begitu dimasukkan ke ENV atau dipakai oleh perintah RUN, ia jadi bagian dari layer yang bisa di-extract. Secret harus lewat buildkit secret mount atau docker run -e saat runtime — kita bahas di episode 13 dan 18.
Seperti diingatkan di episode 5, proses di dalam kontainer secara default berjalan sebagai root. Ini nyaman, tapi berbahaya: jika aplikasi diretas, penyerang langsung punya kendali penuh atas container tersebut. Di dunia kerja, image production yang baik hampir selalu menjalankan proses sebagai non-root user.
Caranya: buat user khusus di dalam image, lalu berpindah dengannya memakai USER. Contoh pada base image Debian/Ubuntu:
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser
COPY . .
RUN chown -R appuser:appuser /app
USER appuserFROM node:20-alpine
WORKDIR /app
COPY --chown=node:node package.json ./
USER nodePerhatikan baris COPY --chown=node:node — flag --chown menetapkan kepemilikan file yang disalin agar bisa diakses user non-root. Ini lebih efisien daripada chown terpisah karena tidak menambah layer. Mengapa urutannya penting? USER harus diletakkan setelah semua instruksi yang butuh hak root (menginstal paket, menyalin file), karena begitu USER appuser dipakai, semua instruksi berikutnya berjalan sebagai user tersebut — dan kalian tidak bisa apt-get install lagi tanpa hak. Pola umum: install dulu sebagai root → buat user → chown file aplikasi → baru USER di dekat bagian akhir.
Base image resmi seperti node, python, nginx sudah menyediakan user bawaan (node, www-data). Selalu periksa dokumentasi image tersebut dan pakai user yang sudah ada daripada membuat baru — satu instruksi lebih hemat.
LABEL menambahkan metadata key-value ke image. Ia tidak mengubah perilaku aplikasi, tapi sangat berguna untuk organisasi, otomasi, dan audit. Praktik industri menggunakan standar label dari Open Container Initiative (OCI) dengan awalan org.opencontainers.image.*:
LABEL org.opencontainers.image.title="my-app" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.description="API service utama" \
org.opencontainers.image.source="https://github.com/team/my-app"Metadata ini bisa dibaca dengan docker inspect, yang sangat membantu ketika tim sudah punya puluhan image dan ingin tahu image ini berasal dari repo mana, versi berapa, dikelola siapa — tanpa perlu menebak. Di organisasi besar, label sering dipakai untuk otomasi: misalnya gitlab-ci memberi label versi commit, atau policy scanner memeriksa label license.
Instruksi SHELL mengganti shell yang dipakai oleh shell form dari RUN, CMD, dan ENTRYPOINT. Ini paling berguna ketika kalian ingin mengeksekusi perintah dengan shell selain sh — misalnya bash dengan opsi -c, atau powershell untuk image Windows. Contoh paling umum: menjalankan perintah dengan bash dan set -euxo pipefail untuk error handling yang lebih ketat:
SHELL ["/bin/bash", "-euxo", "pipefail", "-c"]
RUN echo "perintah ini gagal" | grep sesuatu_yang_tidak_adaTanpa pipefail, pipeline yang gagal di tengah tetap dianggap sukses oleh Docker, sehingga docker build tidak mendeteksi error — salah satu bug yang paling licik. Dengan pipefail, kesalahan semacam ini langsung membuat build gagal.
Di episode 5 kita sempat menyinggung build context — seluruh direktori dikirim ke daemon saat docker build. File .dockerignore bekerja persis seperti .gitignore: ia memberi tahu Docker file/direktori apa saja yang harus dikeluarkan dari build context.
node_modules
.git
.gitignore
*.log
.env
.env.*
Dockerfile
docker-compose.ymlMengapa ini sangat penting? Tiga alasan. Kecepatan: node_modules ratusan MB dan .git bisa bertambah besar seiring riwayat — keduanya terkirim ke daemon di setiap build jika tidak di-ignore. Kebenaran: bayangkan .env berisi secret ikut terkirim dan kemudian di-COPY ke image — itu kebocoran langsung. Keamanan repositori: menyimpan .env di build context yang dikirim ke remote build server (misalnya CI) berarti secret kalian berpindah mesin tanpa perlu.
Tip
Salah satu pola yang sering mengelabui pemula: Dockerfile itu sendiri juga bisa dimasukkan ke .dockerignore. Perintah COPY . . akan menyalin Dockerfile ke dalam image jika tidak dikecualikan — jarang diinginkan, dan membocorkan detail build ke siapa pun yang menarik image tersebut.
Setiap instruksi adalah satu layer, dan setiap layer menambah ukuran serta memperlambat build (karena harus di-hash dan dibandingkan). Aturan sederhananya: jangan memecah perintah yang saling berhubungan menjadi banyak RUN. Bandingkan:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*Versi "BEFORE" menghasilkan tiga layer: layer pertama berisi indeks apt yang ter-update, layer kedua berisi curl plus indeks apt, dan layer ketiga berisi rm yang menghapus indeks — tapi karena layer menyimpan delta, penghapusan di layer ketiga tidak menghilangkan ukuran indeks di layer pertama dan kedua. Image tetap membawa semua sampah. Versi "AFTER" menyatukan semuanya dalam satu layer sehingga bersih total. Ini mengapa rantai && bukan sekadar gaya penulisan — ia kebijakan ukuran image.
Penting juga diingat: jangan berlebihan. Ada kalanya memisahkan RUN justru benar — misalnya saat kalian ingin memanfaatkan cache untuk langkah yang jarang berubah (seperti npm install yang terpisah dari COPY . .). Minimalkan layer bukan berarti paksa satu perintah raksasa; ia berarti jangan ciptakan layer sampah.
Sekarang kita sampai pada bagian paling berharga di episode ini: bagaimana Docker memutuskan memakai cache atau membangun ulang. Prosesnya: untuk setiap instruksi, Docker menghitung hash dari instruksi itu (dan context-nya untuk COPY/ADD), lalu membandingkannya dengan layer yang sudah ada. Jika cocok → cache hit, layer diambil dari cache tanpa eksekusi. Jika tidak cocok → cache miss, instruksi dieksekusi, dan semua instruksi setelahnya dipaksa build ulang — bahkan yang isinya tidak berubah.
Inilah aturan emasnya: letakkan instruksi yang paling jarang berubah di atas, yang paling sering berubah di bawah. Instruksi "stabil" — deklarasi base image, WORKDIR, instalasi dependensi sistem, COPY package.json + npm install — diproses lebih dulu. Instruksi "volatile" — COPY . . (kode sumber berubah tiap commit), file konfigurasi — diproses paling akhir.
FROM node:20-alpine # jarang berubah
WORKDIR /app # jarang berubah
COPY package.json ./ # jarang berubah (hanya saat dependensi berubah)
RUN npm install --omit=dev # LAYER INI DI-CACHE selama package.json sama
COPY . . # sering berubah → diletakkan paling bawahDengan urutan ini, ketika kalian mengubah satu baris kode di index.js, Docker hanya mengeksekusi ulang COPY . . dan layer setelahnya. Instalasi dependensi (yang biasanya paling lambat) tetap di-cache. Build yang tadinya 2 menit jadi 10 detik.
Sekarang bandingkan dengan kebalikannya — kesalahan paling umum pemula:
FROM node:20-alpine
WORKDIR /app
COPY . . # berubah tiap commit → cache bust di sini
RUN npm install --omit=dev # dipaksa jalan ulang SETIAP build
CMD ["node", "index.js"]Di versi "BEFORE", setiap perubahan kode — satu huruf pun — membatalkan cache npm install, yang bisa memakan menit-menit berharga di setiap build CI. Di versi "AFTER", npm install dieksekusi ulang hanya ketika package.json berubah.
Beberapa detail lanjutan soal cache yang wajib kalian tahu:
COPY invalidasi berdasarkan isi, bukan timestamp. Hash dihitung dari konten file, jadi mengubah mode file (misalnya chmod +x) juga memicu cache miss.ENV dan RUN saling berinteraksi. Jika ENV berubah, layer setelahnya ikut invalid. Ini salah satu alasan kenapa ARG/ENV yang jarang berubah sebaiknya diletakkan di atas.docker build --no-cache memaksa build dari nol — berguna saat kalian mencurigai cache menyimpan sesuatu yang basi, atau ingin verifikasi dari bersih.docker build -t my-app:2.0 .
# Step 3/7 : RUN npm install --omit=dev
# ---> Using cache
# ---> a1b2c3d4e5f6Baris Using cache adalah penanda bahwa Docker memakai layer yang sudah ada. Saat kalian melihatnya di output build, artinya urutan instruksi kalian sudah benar.
Setelah menguasai semua instruksi, mari susun satu Dockerfile yang menggabungkan semuanya — aman (non-root), ramping (layer minimal), dan cache-friendly:
FROM node:20-alpine
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
LABEL org.opencontainers.image.title="my-app" \
org.opencontainers.image.version="1.0.0"
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "index.js"]Mari bedah keputusan di dalamnya. ARG NODE_ENV + ENV NODE_ENV=${NODE_ENV} memungkinkan over-ride saat build (--build-arg NODE_ENV=development) sambil memberi default production. npm ci dipakai alih-alih npm install karena ia reproducible — ia mengikuti package-lock.json secara ketat (konsep yang akan kita temui lagi di episode 7). --omit=dev meniadakan dependensi development. COPY package.json package-lock.json ./ sebelum RUN memaksimalkan cache. COPY --chown=node:node . . memastikan kepemilikan benar tanpa chown tambahan. USER node menjadikan proses berjalan sebagai user non-root bawaan image node. Semua instruksi volatile ada di bawah.
Note
Perhatikan bahwa npm ci membutuhkan package-lock.json ada — jika proyek kalian belum memilikinya, jalankan npm install sekali untuk men-generate, lalu commit file tersebut. Tanpa lockfile, npm ci akan gagal, dan kalian harus kembali ke npm install.
Pada episode 6 ini kalian telah meng-upgrade Dockerfile kalian ke level production: memahami perbedaan fundamental ENV (tertanam di image, bisa di-override saat docker run -e) vs ARG (hanya saat build, via --build-arg), termasuk penggunaan ARG sebelum FROM untuk memilih base image. Kalian juga belajar memperkuat keamanan dengan USER non-root, menambah metadata dengan LABEL standar OCI, mengganti shell dengan SHELL, dan yang paling berdampak: menguasai layer caching — mengapa COPY package.json + npm install harus mendahului COPY . ., mengapa rantai && dalam satu RUN menyelamatkan ukuran image, dan mengapa .dockerignore melindungi kecepatan sekaligus secret kalian.
Inti yang harus kalian bawa pulang: urutan instruksi adalah kebijakan caching, dan satu cache miss di tengah merusak semua layer di bawahnya. Dockerfile yang baik bukan yang paling pendek, melainkan yang paling disengaja.
Namun ada satu masalah yang belum terpecahkan: untuk aplikasi yang butuh compiler — Go, aplikasi dengan dependensi native, proyek yang di-build dengan build tooling besar — base image runtime akan ikut membawa semua tool build itu jika kita memakainya langsung. Di episode 7 selanjutnya kita akan membedah multi-stage builds: bagaimana membangun aplikasi di stage penuh dengan SDK dan compiler, lalu menyalin hasilnya ke image runtime yang mungil (bahkan scratch), menciutkan ukuran image dari gigabyte menjadi puluhan megabyte. Sampai jumpa di episode 7!