Skip to main content
BlogDevOps

Cara Mengamankan Container Docker: 7 Langkah untuk Pemula

Cara mengamankan container Docker untuk pemula: user non-root, filesystem read-only, cap-drop, secret di luar image, pin versi, scan, plus tiap cek.

Matthews Wong

Software Engineer

Oktober 202610 menit baca
Cara Mengamankan Container Docker: 7 Langkah untuk Pemula

Ringkasan Utama

Cara mengamankan container Docker untuk pemula: jalankan sebagai user non-root lewat USER, pasang --read-only dengan --tmpfs untuk folder yang harus bisa ditulis, buang semua capability dengan --cap-drop ALL, jauhkan secret dari image, kunci versi base image, lalu scan dengan Docker Scout atau Trivy. Tiap kontrol punya satu perintah cek.

Container kamu jalan, aplikasi menjawab di port-nya, lalu kamu anggap selesai. Kemudian ada yang bertanya apakah container itu aman, dan kamu sadar belum pernah melihat proses itu berjalan sebagai siapa, apa yang boleh ditulisnya, atau apa saja yang tertanam di image. Pemula biasanya tersangkut di tiga titik: tidak tahu bahwa defaultnya adalah root, tidak tahu cara membuktikan sebuah pengaturan benar-benar aktif, dan menyalakan kontrol yang lalu membuat aplikasi error tanpa penjelasan.

Panduan ini mengeraskan satu container Node.js kecil dalam tujuh langkah, dan tiap langkah ditutup dengan perintah yang menunjukkan hasilnya. Setiap flag saya cocokkan dengan dokumentasi resmi Docker dan Trivy. Tujuannya bukan membuat container tak bisa ditembus, karena tidak ada satu kontrol pun yang bisa itu. Tujuannya memperkecil apa yang bisa dipakai penyerang kalau aplikasimu suatu hari diretas, sambil jujur soal apa yang masih belum ada. Langkah terakhir membandingkan hasilnya dengan file Compose situs ini.

Mau lihat gambaran besarnya dulu, lengkap dengan diagram interaktif tentang lapisan di sekeliling satu container? Ikuti pelajaran gratis Hardening Container Docker: Non-Root, Image Kecil, Scanning di jalur DevOps from zero. Post ini adalah praktik langsung untuk tugasnya.

Yang kamu butuhkan

Kamu butuh Docker yang sudah berjalan di komputermu dan sebuah terminal. Kalau belum pernah membangun image, mulai dari panduan Docker dari nol dulu. Memisahkan build dan runtime ke beberapa stage adalah topik tersendiri yang dibahas di panduan multi-stage build di bagian akhir, jadi Dockerfile di sini sengaja satu stage.

  • Docker Engine atau Docker Desktop, dan sebuah terminal
  • Satu folder berisi satu file, server.js, seperti di langkah 1
  • Opsional untuk langkah 6: CLI Trivy. Docker Scout tidak butuh apa-apa lagi kalau Docker kamu sudah punya perintah scout

Langkah 1: Buat container awal dan buktikan ia berjalan sebagai root

Mulai dari versi yang belum diamankan supaya kamu bisa melihat apa yang berubah. Aplikasinya satu file yang menjawab hello. Perhatikan port-nya: dipublikasikan sebagai 127.0.0.1:3000:3000, artinya hanya komputermu sendiri yang bisa menjangkaunya. Kalau hanya menulis 3000:3000, port itu mendengarkan di semua interface, kesalahan yang muncul lagi di tabel error.

// server.js
const http = require("node:http");
http.createServer((req, res) => res.end("hello\n")).listen(3000, "0.0.0.0");

# Dockerfile.root  (no USER line, so the process runs as root)
FROM node:22-alpine
WORKDIR /app
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
docker build -f Dockerfile.root -t hardening-lab:root .

# Wrong: 3000:3000 listens on EVERY interface of the machine
# docker run -d --name hardening-lab -p 3000:3000 hardening-lab:root

# Right: 127.0.0.1 in front means only this machine can connect
docker run -d --name hardening-lab -p 127.0.0.1:3000:3000 hardening-lab:root

curl http://127.0.0.1:3000
# hello

# Which user does the process run as?
docker exec hardening-lab id
# uid=0(root) ...   <- root, the default when no USER is set

docker rm -f hardening-lab

Perintah id mencetak user yang menjalankan proses. Tanpa instruksi USER, image berjalan sebagai root, dan referensi Compose menyatakan hal yang sama: kalau image tidak mengatur apa pun, user-nya root. Root di dalam container tidak sama dengan root di host, tetapi kuasanya jauh lebih besar daripada yang dibutuhkan aplikasimu.

Langkah 2: Jalankan sebagai user non-root

Buat user dan group di Dockerfile, beri ID eksplisit, lalu pindah dengan USER. Halaman best practice Docker menyarankan UID dan GID eksplisit karena ID yang dibagikan otomatis bisa berubah antar rebuild. Dua baris addgroup dan adduser di bawah sama dengan yang dipakai Dockerfile situs ini, dengan group nodejs dan user nextjs di ID 1001.

# Dockerfile.hardened
FROM node:22-alpine
WORKDIR /app

# Same two lines this site's Dockerfile uses: explicit ID 1001
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs

# Wrong: COPY server.js ./   (the file would belong to root)
# Right: hand ownership to the new user while copying
COPY --chown=nextjs:nodejs server.js ./

# Keep USER late: everything above still runs as root
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
docker build -f Dockerfile.hardened -t hardening-lab:hardened .
docker run -d --name hardening-lab -p 127.0.0.1:3000:3000 hardening-lab:hardened

docker exec hardening-lab id
# uid=1001(nextjs) ...   <- no longer 0

docker rm -f hardening-lab

Urutan penting. File yang disalin sebelum USER dimiliki root, jadi user baru mungkin tidak bisa menulis ke sana. Pakai COPY --chown untuk apa pun yang harus diubah aplikasi, dan taruh USER sedekat mungkin ke akhir supaya langkah build sebelumnya tetap punya hak yang dibutuhkan.

Cek setiap kontrol dengan cara yang sama: ubah satu hal, build ulang, lalu jalankan perintah yang membuktikannya. Pengaturan yang tidak bisa kamu verifikasi hanyalah pengaturan yang kamu harap aktif.

Langkah 3: Buat filesystem menjadi read-only

Flag --read-only memasang root filesystem container sebagai read-only, jadi penyerang tidak bisa menaruh skrip atau mengubah kodemu di tempat. Apa pun yang memang perlu menulis diberi tempat tulis sendiri: --tmpfs untuk file sementara, atau volume untuk data yang dipertahankan. Referensi Docker menunjukkan --tmpfs menerima opsi mount seperti rw,noexec,nosuid, yang juga melarang menjalankan program dari folder itu.

docker run -d --name hardening-lab \
  -p 127.0.0.1:3000:3000 \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=65536k \
  hardening-lab:hardened

# The root filesystem refuses writes...
docker exec hardening-lab touch /var/tmp/x
# touch: /var/tmp/x: Read-only file system

# ...but the folder you opened on purpose still works (no output = success)
docker exec hardening-lab touch /tmp/x

curl http://127.0.0.1:3000
# hello

docker rm -f hardening-lab

Ujinya dengan mencoba menulis. Filesystem read-only menolak di mana-mana kecuali folder yang kamu buka, dan aplikasimu tetap jalan karena ia hanya membaca kodenya sendiri.

Langkah 4: Buang capability dan blokir privilege baru

Linux memecah kuasa root menjadi capability, dan Docker menjalankan container dengan set default yang sudah dibatasi. Halaman keamanan Docker menyebut praktik terbaiknya adalah membuang semua capability kecuali yang benar-benar dibutuhkan proses. Flag ini menerima nilai ALL, jadi kamu buang semuanya dan menambah kembali hanya yang diminta oleh error nyata. Server Node di port 3000 tidak butuh satu pun.

docker run -d --name hardening-lab \
  -p 127.0.0.1:3000:3000 \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=65536k \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  hardening-lab:hardened

# Read the settings back instead of trusting them
docker inspect -f '{{.HostConfig.CapDrop}}' hardening-lab
# [ALL]
docker inspect -f '{{.HostConfig.SecurityOpt}}' hardening-lab
# [no-new-privileges]

curl http://127.0.0.1:3000
# hello   <- the app did not need a single capability

Flag kedua, no-new-privileges, menahan proses dari mendapatkan privilege lebih besar dari saat ia mulai, sehingga alat seperti su dan sudo tidak lagi berfungsi di dalam container. Pasangan ini tidak merugikan aplikasi web biasa, dan docker inspect membacakan pengaturannya kembali kepadamu.

Jangan pernah memakai --privileged hanya untuk membungkam error. Dokumentasi Docker menyebut flag itu memberi semua capability ke container, yang membatalkan seluruh langkah ini. Cari satu capability yang disebut error dan tambahkan hanya itu dengan --cap-add.

Langkah 5: Jauhkan secret dari image dan kunci versi base image

Image adalah tumpukan layer yang bisa dibaca siapa pun yang boleh menariknya. Secret yang ditulis dengan ENV di Dockerfile tetap ada di history image selamanya, walau baris berikutnya mencoba menghapusnya. Berikan secret saat container dimulai. Panduan lengkapnya ada di post tentang menyimpan secret Docker dengan aman, ditautkan di bagian akhir.

# Wrong: the value is stored in an image layer and shows in history
ENV API_KEY=example-not-a-real-key

# Right: nothing in the Dockerfile, the value arrives when the container starts
docker run -d --env-file .env hardening-lab:hardened

# Prove a built image is clean: --no-trunc stops long lines being cut short
docker history --no-trunc hardening-lab:hardened | grep API_KEY
# (no output = nothing found)

Bagian kedua adalah pinning. Halaman best practice Docker mencatat bahwa tag image bisa berubah, jadi node:22-alpine bisa menunjuk image lain bulan depan. Pin ke digest menjaga build tetap di image yang persis sama. Konsekuensi yang disebut halaman itu: kamu harus memperbarui digest sendiri dan melepas perbaikan keamanan otomatis, dan ia menyarankan Dependabot untuk menjaganya tetap baru.

# Show the digest of the base image you already pulled
docker image ls --digests node
# REPOSITORY  TAG        DIGEST         IMAGE ID  ...
# node        22-alpine  sha256:(...)   (...)

# Dockerfile: tag for humans, digest for exactness
FROM node:22-alpine@sha256:PASTE-THE-DIGEST-FROM-THE-OUTPUT-ABOVE

Langkah 6: Scan image untuk kerentanan yang sudah dikenal

Hardening container tidak memperbaiki paket rentan di dalam image. Scanner membandingkan isi image dengan database CVE yang sudah dikenal. Docker Scout dan Trivy sama-sama membaca image langsung dari Docker lokalmu, dan keduanya bisa menyaring berdasarkan severity supaya scan pertama tidak menenggelamkanmu dengan temuan berprioritas rendah.

# Docker Scout: only the serious findings
docker scout cves --only-severity critical,high hardening-lab:hardened

# Trivy: the same idea
trivy image --severity HIGH,CRITICAL hardening-lab:hardened

Kedua alat juga bisa mengubah temuan menjadi exit code gagal, cara membuat pipeline CI menolak image yang buruk. Scout mengembalikan exit code 2 saat menemukan kerentanan. Untuk Trivy kamu menentukan sendiri exit code-nya dengan --exit-code. Perbaikan yang lazim untuk sebuah temuan adalah build ulang di atas base image yang lebih baru, lalu scan lagi.

# Scout: exit code 2 when vulnerabilities are detected
docker scout cves --exit-code --only-severity critical,high hardening-lab:hardened
echo $?

# Trivy: you choose the exit code to return on a finding
trivy image --severity HIGH,CRITICAL --exit-code 1 hardening-lab:hardened
echo $?

Langkah 7: Pindahkan pengaturan ke Compose

Baris docker run yang panjang tidak awet, jadi tulis kontrol yang sama ke file Compose. Setiap key di bawah ada di referensi services Compose: user, read_only, tmpfs, cap_drop, security_opt, mem_limit, dan pids_limit.

# docker-compose.yml
services:
  web:
    image: hardening-lab:hardened
    container_name: hardening-lab
    user: "1001:1001"            # same ID as the Dockerfile
    read_only: true
    tmpfs:
      - /tmp                     # the one writable scratch folder
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    mem_limit: 512m              # same cap this site uses on its VPS
    pids_limit: 100              # an example value, tune it for your app
    ports:
      - "127.0.0.1:3000:3000"    # loopback only, as in step 1

# Start it, then run the same proof as before
docker compose up -d
docker exec hardening-lab id

Bandingkan dengan file asli di balik situs ini. File Compose-nya sudah mengikat port ke loopback, membatasi memori di 512m dan satu CPU, mendefinisikan health check, dan Dockerfile-nya berjalan sebagai uid 1001. Ia belum mengatur read_only maupun cap_drop, dan tidak ada image scanner yang berjalan di repositori. Celah itu sengaja saya sebut: daftar hardening baru berguna kalau jujur soal apa yang belum dikerjakan.

Error umum dan cara memperbaikinya

Kebanyakan masalah setelah hardening muncul saat satu kontrol bertemu satu asumsi. Cocokkan gejalamu dengan salah satu baris.

GejalaPenyebabPerbaikan
Aplikasi crash saat start dengan Read-only file system setelah menambah --read-onlyAplikasi menulis file sementara, cache, atau log ke folder yang sekarang terkunciCari path di pesan error, lalu buka hanya path itu dengan --tmpfs, atau volume kalau datanya harus bertahan setelah restart
permission denied saat user non-root menulis ke folder aplikasiFile disalin sebelum USER, sehingga dimiliki rootPakai COPY --chown=nextjs:nodejs untuk file yang harus diubah aplikasi, atau tulis ke tmpfs atau volume
Volume yang di-mount tidak bisa ditulis setelah pindah ke user non-rootFolder host yang di-bind-mount ke container dimiliki rootPakai named volume, yang kepemilikannya diisi Docker dari image, atau chown folder host ke UID container
operation not permitted setelah --cap-drop ALLAplikasi, atau skrip startup, butuh satu capability tertentuTambahkan kembali hanya capability itu dengan --cap-add. Jangan pakai --privileged
Scan menampilkan ratusan temuan dan kamu bingung mulai dari manaTidak ada filter severity, jadi semua CVE berprioritas rendah ikut tampilSaring dengan --only-severity critical,high untuk Scout atau --severity HIGH,CRITICAL untuk Trivy, lalu build ulang di atas base image yang lebih baru
Container bisa diakses dari mesin lain padahal aplikasinya sudah di-hardenPort dipublikasikan sebagai 3000:3000, yang mendengarkan di semua interface. EXPOSE di Dockerfile hanya mendokumentasikan port dan tidak mempublikasikan apa punPublikasikan sebagai 127.0.0.1:3000:3000 dan biarkan reverse proxy di mesin yang sama yang menghadap internet

Checklist sebelum kamu mengirim container

  • docker exec menunjukkan uid non-root, bukan 0
  • Menulis ke folder aplikasi gagal dengan Read-only file system, sementara folder tmpfs tetap bisa
  • docker inspect menampilkan ALL di CapDrop dan no-new-privileges di SecurityOpt
  • docker history tidak menampilkan secret, dan baris FROM sudah dipin ke digest
  • Scan untuk temuan critical dan high sudah dijalankan, dan port terikat ke 127.0.0.1

Belajar lebih dalam

Pelajaran gratis Hardening Container Docker: Non-Root, Image Kecil, Scanning menggambar lapisan-lapisannya dan menunjukkan mana yang diatur situs ini. Untuk image yang lebih kecil untuk di-scan, baca praktik terbaik containerization Docker dan optimasi image distroless. Berikutnya di seri ini: pisahkan build dari runtime di panduan multi-stage build, dan jauhkan secret dari image di panduan secret Docker.

Aturan yang dibawa pulang: keraskan container satu kontrol demi satu kontrol, dan jangan simpan kontrol yang tidak bisa kamu buktikan dengan perintah. Non-root, read-only, tanpa capability, tanpa secret, dipin, dan di-scan tidak membuatmu kebal, tetapi masing-masing menghilangkan sesuatu yang kalau tidak akan didapat penyerang dengan gratis.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Kenapa container Docker saya berjalan sebagai root secara default?

Docker tidak mengganti user untukmu. Kalau image tidak punya instruksi USER, proses berjalan sebagai root di dalam container. Buat user dengan ID eksplisit, tambahkan baris USER, lalu cek dengan docker exec NAMA_CONTAINER id.

Apakah menjalankan container sebagai root benar-benar berbahaya?

Root di dalam container tidak sama dengan root di host, dan Docker membuang sebagian capability secara default. Tetap saja, kalau aplikasi diretas, root memberi penyerang jauh lebih banyak bahan daripada user biasa. Halaman keamanan Docker sendiri menyebut container lebih aman bila proses berjalan sebagai user tanpa hak istimewa.

Apa fungsi docker run --read-only?

Flag ini memasang root filesystem container sebagai read-only, sehingga tidak ada yang bisa ditulis kecuali ke volume atau tmpfs yang sengaja kamu buka. Penyerang tidak bisa mengubah kodemu atau menaruh file di folder aplikasi. Kalau aplikasimu butuh folder sementara, tambahkan --tmpfs untuk path itu.

Kenapa aplikasi saya error operation not permitted setelah cap-drop ALL?

Prosesnya butuh satu capability Linux yang sekarang sudah dibuang. Baca pesan error untuk tahu yang mana, lalu tambahkan kembali hanya itu dengan --cap-add, bukan mencabut pembatasan atau memakai --privileged. Kebanyakan aplikasi web di port di atas 1024 tidak butuh capability apa pun.

Bagaimana cara scan image Docker untuk mencari kerentanan secara gratis?

Jalankan docker scout cves IMAGE dengan --only-severity critical,high, atau trivy image --severity HIGH,CRITICAL IMAGE. Keduanya bisa membaca image dari Docker lokalmu. Build ulang di atas base image yang lebih baru kalau ada temuan serius, lalu scan lagi.