Best Practice Dockerfile #
Docker image adalah fondasi dari seluruh ekosistem container. Image yang buruk akan berdampak langsung pada keamanan, ukuran, kecepatan build, waktu startup, dan stabilitas di production. Sebaliknya, Docker image yang dirancang dengan baik akan membuat deployment lebih cepat, scaling lebih efisien, dan operasional lebih tenang.
Artikel ini membahas best practice Dockerfile secara universal, tidak terikat pada bahasa pemrograman tertentu. Setiap bahasa (Go, Java, Python, Rust, Node.js, Ruby, PHP) sudah punya artikel terpisah di section ini — di sini kita fokus pada prinsip lintas bahasa yang menjadi fondasi semua Dockerfile yang baik.
1. Selalu Pakai Multi-Stage Build #
Multi-stage build adalah fondasi wajib untuk Dockerfile modern. Ia memisahkan build environment (dengan compiler, package manager, dan tool) dari runtime environment (hanya artefak akhir dan dependency minimum).
Mengapa wajib:
- Image runtime lebih kecil — compiler dan tool build tidak ikut.
- Attack surface lebih sempit — tidak ada compiler yang bisa dieksploitasi.
- Build boundary jelas — perbedaan antara “dibutuhkan untuk build” dan “dibutuhkan untuk runtime” eksplisit di Dockerfile.
- Supply chain lebih aman — lebih sedikit dependency yang harus di-patch.
// ANTI-PATTERN: semua di satu stage, image gemuk
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o app
CMD ["./app"]
// Image ~300 MB karena bawa compiler dan tool
// BENAR: multi-stage, image ramping
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/app /app
CMD ["/app"]
// Image ~15 MB, hanya binary
Aturan praktis: Jika Dockerfile kamu hanya punya satu FROM, itu hampir pasti bisa di-improve. Multi-stage bukan optimasi — ia adalah standar.
2. Pilih Base Image yang Sesuai Kebutuhan #
Base image menentukan fondasi image. Pilih yang paling sesuai dengan kebutuhan runtime, bukan yang paling familiar.
| Base Image | Use Case | Ukuran |
|---|---|---|
scratch |
Static binary Go/Rust tanpa HTTPS | 0 MB |
gcr.io/distroless/* |
Production Java, Node.js, Python, Go | 10-50 MB |
alpine |
Bahasa yang perlu tool di runtime, glibc/musl compatible | 5-10 MB |
-slim variant |
Default aman, ada shell untuk debugging | 50-150 MB |
| default tag | Development, prototyping | 200-900 MB |
ubuntu / debian |
Butuh tool OS lengkap, debugging interaktif | 70-100 MB |
Prinsip pemilihan:
- Production high-maturity → distroless (observability harus solid).
- Default production → alpine atau slim (debugging能力).
- Development → default tag (banyak tool, mudah debug).
- Static binary → scratch.
// ✗ ANTI-PATTERN: base image default untuk production
FROM node:20
// Image 900+ MB
// ✓ BENAR: base image ramping untuk production
FROM node:20-alpine
// Image 50-100 MB setelah optimasi
Tag eksplisit, bukan latest:
// ✗ Tidak deterministik
FROM node:latest
// ✓ Reproducible
FROM node:20.11.1-alpine3.20
3. Jangan Jalankan Container sebagai Root #
Container default jalan sebagai root (UID 0). Ini berbahaya. Selalu buat user non-root dengan UID eksplisit.
Mengapa penting:
- Mengurangi dampak container escape — attacker tidak langsung mendapat root host.
- Compliance — banyak standar keamanan (PCI-DSS, SOC2) mengharuskan non-root.
- Least privilege — container hanya punya permission yang dibutuhkan.
- Volume mount lebih aman — file di-mount dengan permission user, bukan root.
// ✗ Container jalan sebagai root
FROM alpine
COPY app /app
CMD ["/app"]
// ✓ Container jalan sebagai non-root
FROM alpine
RUN addgroup -g 1001 -S appgroup \
&& adduser -u 1001 -S appuser -G appgroup
COPY --chown=appuser:appgroup app /app
USER appuser
CMD ["/app"]
Untuk distroless — user nonroot sudah built-in:
FROM gcr.io/distroless/base-debian12:nonroot
USER nonroot:nonroot
Untuk scratch — harus setup /etc/passwd manual atau terima default.
4. Pisahkan Production dan Development Dependency #
Hampir semua bahasa punya cara memisahkan dependency:
- Node.js:
npm ci --omit=devatau pisahkandependenciesvsdevDependenciesdipackage.json. - Python: pisahkan
requirements.txt(prod) danrequirements-dev.txt. - Ruby:
bundle config set without 'development test'. - PHP:
composer install --no-dev. - Go:
go mod tidyhanya menarik dependency yang di-import. - Rust: pisahkan
[dependencies]vs[dev-dependencies]diCargo.toml. - Java/Maven:
<scope>provided</scope>atau<scope>test</scope>dipom.xml.
// ✗ Semua dependency termasuk dev
RUN npm install
// node_modules sekarang punya typescript, jest, eslint, dll
// ✓ Hanya production dependency
RUN npm ci --omit=dev
// atau pisahkan
COPY package.json ./
RUN npm ci --omit=dev && npm cache clean --force
Prinsip: Production image tidak boleh membawa test framework, debugger, formatter, atau linter. Semua itu tidak dibutuhkan runtime.
5. Manfaatkan .dockerignore dengan Tegas
#
.dockerignore adalah senjata yang sering diremehkan. Tanpa file ini, build context bisa ratusan MB (termasuk node_modules, .git, .env, dll) dan proses build akan lambat.
Wajib dikecualikan:
# .dockerignore
.git
.gitignore
.env
.env.*
node_modules
vendor
target
dist
build
__pycache__
*.pyc
*.log
coverage
.DS_Store
.vscode
.idea
*.md
docs/
tests/
Dampak .dockerignore yang baik:
- Build context lebih kecil →
docker buildlebih cepat. - Tidak ada file sensitif (
.env, credentials) yang ikut ke image. - Tidak ada dependency lokal yang bentrok dengan dependency Docker.
flowchart LR
A[Build Context 500 MB] -->|Tanpa .dockerignore| B[Build Lambat + Risiko]
C[Build Context 10 MB] -->|Dengan .dockerignore| D[Build Cepat + Aman]
6. Jangan Simpan Secret di Docker Image #
Secret yang ada di image akan terekpos selamanya. Siapa pun yang punya akses ke image (atau registry) bisa mengekstraknya. Ini adalah kesalahan keamanan paling umum.
// ✗ FATAL: secret di-bake ke image
FROM node:20
ENV DB_PASSWORD=supersecret
COPY credentials.json /app/
// ✓ Secret di-inject saat runtime
FROM node:20
# Tidak ada secret di Dockerfile
Cara inject secret saat runtime:
- Environment variable:
docker run -e DB_PASSWORD=xxx - File mount:
docker run -v /host/secret:/app/secret:ro - Docker secrets (Swarm mode)
- Kubernetes secrets
- External secret manager: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager
Build-time secret (untuk build saja, tidak tersimpan di image):
# syntax=docker/dockerfile:1.7
FROM node:20
# Pakai BuildKit secret mount
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
Prinsip: Docker image adalah artefak publik — bisa di-push ke registry, di-share, di-cache. Treat image seperti kode yang di-publish ke npm. Tidak boleh ada credential di dalamnya.
7. Gunakan Tag Image dengan Strategi yang Benar #
latest adalah anti-pattern untuk production. Tag harus deterministik dan bisa di-rollback.
Hindari latest:
# ✗ Tidak deterministic
docker build -t myapp:latest .
# ✗ Tidak jelas versinya
docker build -t myapp:1 .
Strategi tag yang baik:
# Semantic version
docker build -t myapp:1.4.0 .
# Git commit hash (immutable)
docker build -t myapp:git-a1b2c3d .
# Build number (CI)
docker build -t myapp:1.4.0-$BUILD_NUMBER .
# Kombinasi
docker build -t myapp:1.4.0-a1b2c3d .
Prinsip:
- Tag eksplisit lebih baik dari
latest. - Git hash untuk full reproducibility.
- Semantic version untuk release yang diketahui stabil.
- Build number untuk tracking deployment.
8. Optimasi Layer untuk Cache Build #
Urutan instruksi Dockerfile menentukan performa build. Yang jarang berubah di atas, yang sering berubah di bawah.
// ✗ Urutan yang buruk: cache sering invalid
FROM node:20-alpine
WORKDIR /app
COPY . . # source code di atas
RUN npm install # dependency install di bawah
CMD ["npm", "start"]
// Setiap perubahan kode → npm install ulang
// ✓ Urutan yang benar: cache optimal
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # dependency definition dulu
RUN npm install # install sekali, di-cache
COPY . . # source code terakhir
CMD ["npm", "start"]
// Perubahan kode → npm install tidak diulang
Prinsip layering:
- Base image di paling atas (jarang berubah).
- OS dependency setelah base image (jarang berubah).
- App dependency definition setelah OS dep (jarang berubah).
- App dependency install setelah definisi (jarang berubah).
- Source code di bawah (sering berubah).
- ENTRYPOINT/CMD di paling bawah (jarang berubah).
Tambahan: Gabungkan perintah yang berkaitan dalam satu RUN untuk mengurangi jumlah layer:
// ✗ Tiga layer, cache tidak dibersihkan
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
// ✓ Satu layer, semua bersih
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
9. Log ke STDOUT, Bukan File #
Container production tidak boleh menulis log ke file. Orchestrator (Kubernetes, ECS) sudah punya mekanisme untuk mengumpulkan log dari STDOUT/STDERR.
Prinsip 12-factor apps:
- Log adalah event stream, bukan file.
- Aplikasi menulis ke STDOUT/STDERR.
- Orchestrator/container runtime yang mengumpulkan log.
- Format JSON structured untuk production (mudah di-parse aggregator).
# ✗ Log ke file
import logging
logging.basicConfig(filename='/var/log/app.log')
# ✓ Log ke STDOUT
import logging
import sys
logging.basicConfig(stream=sys.stdout, format='%(asctime)s %(levelname)s %(message)s')
// ✓ Go: log ke os.Stdout
log.SetOutput(os.Stdout)
// ✓ Node.js: console.log otomatis ke STDOUT
console.log({ event: 'request', path: '/api/users', status: 200 });
Benefit:
- Log tidak hilang saat container restart.
- Format konsisten yang bisa di-parse aggregator.
- Tidak ada masalah log rotation.
- Bisa di-stream ke Elasticsearch, Loki, CloudWatch, dll.
10. Scan Vulnerability Image secara Rutin #
Image kecil ≠ image aman. Image yang dibuild dari base image outdated pasti punya CVE. Scanning harus jadi bagian dari CI/CD.
Tool populer:
- Trivy — open source, mudah dipakai, support banyak format.
- Snyk — komersial, database CVE luas.
- Docker Scout — built-in di Docker Hub dan Docker Desktop.
- Grype — open source dari Anchore.
- Clair — open source dari Red Hat.
Integrasi CI/CD:
# GitHub Actions
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
run: trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:${{ github.sha }}
Yang di-scan:
- OS package — CVE di
apt,apk,yumpackages. - Application dependency — CVE di
pip,npm,gem,composerpackages. - Base image — pastikan base image yang kamu pilih up-to-date.
Prinsip: Image bukan hanya hasil build — ia adalah artefak deployable yang punya security posture sendiri. Ia harus di-track, di-scan, dan di-patch seperti software lainnya.
Anti-Pattern yang Harus Dihindari #
// ✗ Anti-pattern 1: base image default
FROM ubuntu:latest
// Image besar, tidak perlu untuk kebanyakan kasus
// ✓ Solusi: base image ramping dan eksplisit
FROM ubuntu:24.04
// Atau lebih baik: alpine, distroless, slim
// ✗ Anti-pattern 2: layer tidak efisien
RUN apt-get update
RUN apt-get install -y package
// Cache menumpuk, layer tidak optimal
// ✓ Solusi: gabungkan dan hapus cache
RUN apt-get update && apt-get install -y --no-install-recommends \
package \
&& rm -rf /var/lib/apt/lists/*
// ✗ Anti-pattern 3: secret di image
ENV API_KEY=abc123
COPY credentials.json /app/
// Credential bocor ke image
// ✓ Solusi: inject saat runtime
# Dockerfile tidak punya secret
# docker run -e API_KEY=xxx myapp
# atau pakai secret manager
// ✗ Anti-pattern 4: container sebagai root
FROM alpine
COPY app /app
CMD ["/app"]
// Default root, privilege escalation risk
// ✓ Solusi: user non-root
FROM alpine
RUN adduser -D -u 1001 appuser
COPY --chown=appuser app /app
USER appuser
CMD ["/app"]
// ✗ Anti-pattern 5: copy seluruh context
COPY . /app
// node_modules, .git, .env ikut masuk
// ✓ Solusi: .dockerignore tegas + copy eksplisit
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
// ✗ Anti-pattern 6: shell form di CMD/ENTRYPOINT
CMD npm start
// sh jadi PID 1, signal tidak sampai ke npm
// ✓ Solusi: exec form
CMD ["npm", "start"]
// ✗ Anti-pattern 7: log ke file
CMD ["./app", "--log-file=/var/log/app.log"]
// Log hilang saat container restart
// ✓ Solusi: log ke STDOUT
CMD ["./app"]
// Aplikasi harus log ke STDOUT
// ✗ Anti-pattern 8: tag tanpa strategi
docker build -t myapp .
// Image jadi "myapp:latest", tidak bisa rollback
// ✓ Solusi: tag eksplisit
docker build -t myapp:1.4.0-a1b2c3d .
Checklist Review Dockerfile Universal #
FRONT MATTER & TAG:
□ Title singkat dan deskriptif
□ Weight kelipatan 10
□ Description 150-250 karakter
□ BookToC: true
STRUKTUR:
□ H1 dengan format # **Judul** (bold)
□ Tidak ada separator (---) sebelum H2 pertama
□ Setiap H2 punya kalimat pengantar
□ Ringkasan ada di akhir dengan hint box tip (Ringkasan wajib)
KONTEN:
□ Paragraf pembuka tidak dimulai dengan "Dalam artikel ini"
□ Anti-pattern disandingkan dengan solusi BENAR
□ Komentar kode menggunakan konvensi BENAR / ANTI-PATTERN / ✓ / ✗
□ Bahasa "kamu" (bukan "Anda")
KODE & DIAGRAM:
□ Semua kode block punya label bahasa
□ Diagram menggunakan Mermaid (bukan ASCII)
□ Tabel data menggunakan markdown table
BASE IMAGE:
□ Tag eksplisit (image:version-variant, bukan latest)
□ Base image ramping (alpine/slim/distroless, bukan default)
□ Runtime stage berbeda dari build stage (multi-stage)
BUILD:
□ Multi-stage build
□ COPY file dependency duluan, source code belakangan
□ Build tool (compiler, *-dev) hanya di build stage
□ Cache package manager dihapus (rm -rf /var/lib/apt/lists/* dll)
□ pip install --no-cache-dir
□ npm ci (bukan npm install)
□ bundle install --without development test
□ composer install --no-dev
RUNTIME:
□ USER nonroot (UID >= 1000)
□ EXPOSE port
□ ENTRYPOINT/CMD dalam exec form
□ Healthcheck (HEALTHCHECK atau orchestrator probe)
□ Log ke STDOUT (bukan file)
□ Signal handling (SIGTERM handler di aplikasi)
UKURAN:
□ < 100 MB untuk distroless runtime
□ < 200 MB untuk alpine/slim runtime
□ docker history tidak ada layer aneh yang besar
KEAMANAN:
□ Tidak ada secret di image
□ .dockerignore tegas (.git, .env, node_modules, dll)
□ Image di-scan trivy/grype di CI
□ Base image up-to-date (CVE patched)
□ Container bukan root
OPERASIONAL:
□ Tag image eksplisit (semantic version atau git hash)
□ Image immutable (tidak diubah setelah build)
□ Observability: log + metrics + healthcheck
□ Build reproducible (lock file di-commit)
Ringkasan #
- Multi-stage build adalah fondasi — pisahkan build environment dari runtime environment. Satu stage = sinyal merah.
- Base image yang sesuai: distroless untuk production mature, alpine/slim untuk default, scratch untuk static binary. Hindari default tag untuk production.
- Non-root user wajib — UID eksplisit, distroless sudah ada
nonroot:nonroot. Container root = privilege escalation risk.- Pisahkan dev vs prod dependency —
npm ci --omit=dev,composer install --no-dev,bundle config set without 'development test'. Production image tidak boleh bawa test tool..dockerignoretegas — exclude.git,.env,node_modules,vendor,target,dist, test files. Build context kecil = build cepat + aman.- Tidak ada secret di image — inject saat runtime lewat env var, file mount, atau secret manager. Docker image adalah artefak publik.
- Tag eksplisit, bukan
latest— semantic version + git hash. Reproducibility penting untuk audit dan rollback.- Layer ordering — yang jarang berubah di atas (base, OS dep, app dep def), yang sering berubah di bawah (source code). Maksimalkan cache hit.
- Log ke STDOUT — bukan file. Container production tidak boleh menulis log ke file lokal. Format JSON structured.
- Scan vulnerability di CI/CD — Trivy, Grype, Snyk, Docker Scout. Image kecil ≠ image aman.
- Exec form di CMD/ENTRYPOINT —
CMD ["app"], bukanCMD app. Supaya signal handling benar.- Image ramping butuh observability solid — log JSON, metrics, healthcheck, graceful shutdown. Image kecil + observability solid > image besar + debugging manual.
- Tag image dan lock file di-commit — untuk build reproducibility. Audit dan rollback hanya mungkin dengan build yang reproducible.
- Best practice bukan checklist — ia adalah pola pikir. Setiap Dockerfile adalah keputusan tentang keamanan, ukuran, dan operasional. Pilih dengan sadar.