Pembahasan EXPOSE menutup satu tikungan miskonsepsi: image myapp kini jujur mendokumentasikan port yang didengarkannya, dan kita tahu persis bahwa akses sepenuhnya diatur saat runtime. Dua instruksi berikutnya mengurus kenyamanan yang lebih mendasar: di dalam filesystem image, di direktori mana aplikasi hidup, dan sebagai user siapa aplikasi berjalan. Keduanya sering dianggap pelengkap kecil yang boleh dilewatkan — padahal tanpa WORKDIR, berkas aplikasi tercecer di direktori root bersama direktori sistem; tanpa USER, container berjalan sebagai root dengan seluruh hak akses administratif. Budi akan membuktikan kedua masalah itu lewat eksperimen kecil sebelum mematenkan pola yang benar pada image myapp.

WORKDIR Menetapkan Direktori Kerja #

Instruksi WORKDIR /app menetapkan direktori kerja untuk semua instruksi setelahnya. Path relatif pada COPY dan ADD diselesaikan relatif ke direktori itu, perintah RUN dijalankan dari dalam direktori itu, dan ketika container akhirnya berjalan, CMD atau ENTRYPOINT pun dieksekusi dengan direktori itu sebagai direktori kerja proses utama. Dua perilaku tambahan membuat WORKDIR nyaman dipakai: direktori yang ditunjuk dibuat otomatis bila belum ada — lengkap dengan seluruh folder induknya, seperti mkdir -p — dan nilai WORKDIR boleh berupa path relatif yang dirangkai ke WORKDIR sebelumnya. Efek gabungannya: satu baris instruksi memberi seluruh sisa Dockerfile sebuah “alamat rumah” yang konsisten.

Tanpa WORKDIR Berkas Mendarat di Root #

Untuk merasakan bedanya, Budi sengaja membangun image tanpa WORKDIR. Ia menyunting Dockerfile myapp menjadi versi telanjang berikut, membangunnya sebagai image eksperimen, lalu mengembalikan isinya seperti semula:

dockerfile
FROM node:22-alpine
COPY package.json server.js ./
CMD ["node", "server.js"]
bash
$ docker build -t myapp:akar .

Output yang Dihasilkan:

text
[+] Building 2.9s (8/8) FINISHED
 => [internal] load build definition from Dockerfile                    0.1s
 => => transferring dockerfile: 77B                                     0.0s
 => [internal] load metadata for docker.io/library/node:22-alpine       0.7s
 => [1/2] FROM docker.io/library/node:22-alpine                         0.0s
 => => resolve docker.io/library/node:22-alpine                         0.7s
 => [2/2] COPY package.json server.js ./                                0.1s
 => => transferring context: 734B                                       0.0s
 => exporting to image                                                  0.2s
 => => exporting layers                                                 0.1s
 => => writing image sha256:9c2f5a8b1d4e6c3a7f0b2d8e5a1c4f6b3d9e2a5c8f1b4d6a0e3c7f5b8d2a1e4c 0.1s
 => => naming to docker.io/library/myapp:akar                           0.0s

Di mana kedua berkas aplikasi mendarat? Kita mengintipnya dengan menimpa perintah bawaan container lewat argumen docker run — pola command override yang kita kenal dari Chapter Command Override:

bash
$ docker run --rm myapp:akar ls /

Output yang Dihasilkan:

text
bin
dev
docker-entrypoint.sh
etc
home
lib
media
mnt
opt
package.json
proc
root
run
sbin
server.js
srv
sys
tmp
usr
var

Mari kita telaah isi direktori root di atas:

  1. package.json dan server.js berbaris dengan direktori sistem — tanpa WORKDIR, direktori kerja bawaan adalah /, sehingga ./ pada COPY menunjuk langsung ke root filesystem image.
  2. docker-entrypoint.sh milik image node — image dasar punya isi sendiri di direktori yang sama; berkas aplikasi yang tercecer di root membuka peluang tabrakan nama dengan isi basis.
  3. Aplikasi masih bisa jalan — node server.js menemukan berkasnya karena direktori kerja / kebetulan sama dengan lokasi berkas — tetapi struktur berantakan, dan pemilik image berikutnya harus menebak-menebak di mana kode berada.

Dengan WORKDIR Semua Punya Alamat #

Bayangan sebaliknya muncul pada image myapp yang selalu punya WORKDIR /app. Direktori kerjanya tersimpan sebagai konfigurasi image dan diwarisi proses apa pun yang dijalankan container:

bash
$ docker run --rm myapp:1.1 pwd

Output yang Dihasilkan:

text
/app

Mari kita telaah satu kata /app di atas:

  1. pwd dijalankan di /app — instruksi WORKDIR pada Dockerfile tersimpan pada konfigurasi image, lalu menjadi direktori kerja bawaan untuk CMD, ENTRYPOINT, dan proses tambahan apa pun.
  2. COPY package.json server.js ./ pada image yang sama menempatkan berkas di /app/package.json dan /app/server.js — alamat rapi yang mudah ditebak siapa pun yang membuka shell di dalam container.
  3. --rm membersihkan jejak — container sekali pakai ini dibuang begitu perintah pwd selesai; pola yang pas untuk inspeksi cepat.

WORKDIR Bisa Dirangkai dan Memakai ENV #

WORKDIR boleh muncul berkali-kali dalam satu Dockerfile, dan nilai relatifnya dirangkai ke WORKDIR sebelumnya:

dockerfile
WORKDIR /app
WORKDIR src
WORKDIR tes

Mari kita telaah rangkaian di atas:

  1. WORKDIR src menghasilkan /app/src — path relatif ditempel di belakang direktori kerja saat ini, persis perilaku cd src di shell.
  2. WORKDIR tes menghasilkan /app/src/tes — tiga baris instruksi membangun struktur tiga tingkat tanpa satu pun RUN mkdir.
  3. Setiap tingkat dibuat otomatis bila belum ada — memisah pembuatan direktori ke instruksi RUN hanya menambah layer tanpa manfaat.

WORKDIR juga membaca variabel ENV — kombinasi yang membuat alamat aplikasi cukup dideklarasikan sekali:

dockerfile
ENV APP_DIR=/app
WORKDIR $APP_DIR
COPY package.json server.js ./

Mari kita telaah kombinasi di atas:

  1. $APP_DIR dievaluasi saat build — variabel ENV diganti nilainya sebelum WORKDIR diproses, sehingga instruksi menerima /app.
  2. Satu sumber kebenaran — bila banyak instruksi memakai path yang sama, mengubah satu baris ENV menggeser seluruh alamat aplikasi sekaligus; materi variabel di Dockerfile sendiri sudah kita bedah tuntas pada Chapter ENV dan ARG.

USER Menentukan Identitas Proses #

USER bekerja dengan pola yang sama persis dengan WORKDIR, tetapi mengganti identitas, bukan lokasi. Setelah baris USER app, seluruh instruksi berikutnya — RUN, CMD, dan ENTRYPOINT — dijalankan sebagai user tersebut, baik saat build maupun saat container berjalan. Tanpa USER, semuanya berjalan sebagai root: user nomor 0 dengan seluruh hak administratif. Kondisi itu lumrah di lingkungan build yang terkontrol, tetapi container produksi yang berjalan sebagai root memperlebar permukaan risiko begitu aplikasi berhasil ditembus — pembahasan mendalamnya menanti di Section Keamanan Docker, khususnya Chapter Menjalankan Container Non-Root.

Buat User Terlebih Dulu Selagi Build Masih Root #

User tidak lahir sendiri: user harus dibuat lebih dulu dengan RUN, dan RUN itu sendiri harus berjalan sebagai root — artinya diletakkan sebelum baris USER. Pola lengkapnya pada image berbasis Alpine terlihat seperti ini:

dockerfile
FROM node:22-alpine
RUN apk add --no-cache curl
RUN addgroup -g 1000 app && adduser -u 1000 -G app -H -D app
USER app

myapp sendiri tidak membutuhkan apk add — baris itu hanya memperagakan posisi pemasangan paket yang benar. Mari kita telaah urutan keempat baris di atas:

  1. RUN apk add --no-cache curl berjalan sebagai root — pemasangan paket butuh hak administratif, dan instruksi memang belum disentuh USER; seluruh pekerjaan ber-akses-root harus diletakkan sebelum baris USER, bukan sesudahnya.
  2. RUN addgroup -g 1000 app && adduser -u 1000 -G app -H -D app — pembuatan grup dan user juga menuntut hak root; keduanya digabung satu instruksi agar hanya menghasilkan satu layer.
  3. Flag adduser milik Alpine — -u 1000 menetapkan UID, -G app menjadikan grup app sebagai grup utama, -H mencegah pembuatan folder home, dan -D membuat akun tanpa password; empat flag ini membentuk user utilitas yang minim jejak.
  4. USER app memotong kewenangan mulai baris itu — instruksi sesudahnya, termasuk CMD saat container berjalan, sudah bukan root lagi.

myapp 1.4: Rumah Rapi dan Identitas Non-Root #

Budi mematenkan pola ini sebagai versi 1.4 dari image myapp — Dockerfile yang kini memuat rumah dan identitas sekaligus:

dockerfile
# syntax=docker/dockerfile:1

# Basis: Node.js 22 di atas Alpine
FROM node:22-alpine

# Metadata image
LABEL versi="1.4" deskripsi="API kecil milik Budi"

# Direktori kerja untuk semua instruksi berikutnya
WORKDIR /app

# Buat user dan grup non-root selagi build masih berjalan sebagai root
RUN addgroup -g 1000 app && adduser -u 1000 -G app -H -D app

# Berkas aplikasi mendarat di /app
COPY package.json server.js ./

# Mulai baris ini semua instruksi berjalan sebagai user app
USER app

# Port yang didengarkan aplikasi
EXPOSE 8000

# Perintah bawaan container
CMD ["node", "server.js"]
bash
$ docker build -t myapp:1.4 .

Output yang Dihasilkan:

text
[+] Building 4.3s (14/14) FINISHED
 => [internal] load build definition from Dockerfile                    0.1s
 => => transferring dockerfile: 590B                                    0.0s
 => [internal] load metadata for docker.io/library/node:22-alpine       0.8s
 => [1/7] FROM docker.io/library/node:22-alpine                         0.0s
 => => resolve docker.io/library/node:22-alpine                         0.8s
 => [2/7] LABEL versi=1.4 deskripsi=API kecil milik Budi                0.0s
 => [3/7] WORKDIR /app                                                  0.0s
 => [4/7] RUN addgroup -g 1000 app && adduser -u 1000 -G app -H -D app  0.5s
 => [5/7] COPY package.json server.js ./                                0.1s
 => => transferring context: 1247B                                      0.0s
 => [6/7] USER app                                                      0.0s
 => [7/7] EXPOSE 8000                                                   0.0s
 => exporting to image                                                  0.3s
 => => exporting layers                                                 0.2s
 => => writing image sha256:7b3e9f2c81d46a0cbe4f2d9158e7a3c6f0b4d8e2a5c7f1b9d3e6a0c4f8b2d5e7 0.1s
 => => naming to docker.io/library/myapp:1.4                            0.0s

Mari kita telaah output build di atas:

  1. [4/7] RUN addgroup ... 0.5s — satu-satunya langkah yang benar-benar bekerja: layer tipis berisi perubahan pada /etc/group, /etc/passwd, dan /etc/shadow tempat identitas baru dicatat.
  2. [6/7] USER app 0.0s — perpindahan identitas adalah metadata murni; tidak ada berkas yang ditulis, hanya konfigurasi image yang berubah.
  3. CMD tetap tidak tampil sebagai langkah bernomor — konfigurasi runtime dicatat saat ekspor image, pola yang sama dengan build pertama section ini.

Saatnya membuktikan identitasnya di lapangan. Budi menjalankan container tanpa flag port — kita tidak butuh akses host untuk memeriksa siapa yang bekerja di dalamnya:

bash
$ docker run -d --name myapp-aman myapp:1.4

Output yang Dihasilkan:

text
3f7a1c9e5b2d8f4a6c0e2b9d7f5a3c1e8b4d6f2a0c9e5b3d7f1a4c6e8b0d2f5a

docker exec membuka pintu dua arah yang sudah kita kenal dari Chapter Menjalankan Perintah dalam Container:

bash
$ docker exec myapp-aman whoami

Output yang Dihasilkan:

text
app
bash
$ docker exec myapp-aman id

Output yang Dihasilkan:

text
uid=1000(app) gid=1000(app) groups=1000(app)

Sebagai pembanding, image lama yang belum punya USER menjawab berbeda:

bash
$ docker run --rm myapp:1.1 whoami

Output yang Dihasilkan:

text
root

Mari kita telaah tiga jawaban di atas:

  1. app dari whoami — proses tambahan hasil docker exec berjalan sebagai user container, yaitu user yang ditetapkan instruksi USER; proses utama node server.js berjalan dengan identitas yang sama.
  2. uid=1000(app) gid=1000(app) — angka inilah identitas sebenarnya bagi kernel; nama hanyalah label manusiawi yang ditempel pada angka.
  3. root dari myapp:1.1 — seluruh container ber-image lama bekerja dengan wewenang penuh; membandingkan dua baris ini biasanya cukup untuk meyakinkan tim bahwa USER layak ditulis.

USER menerima nama (USER app) atau angka (USER 1000:1000), dan keduanya sah. Nama enak dibaca manusia, sedangkan angka lebih deterministik: nilai numerik tidak bergantung pada berkas /etc/passwd di dalam image sehingga tidak pernah gagal diterjemahkan, dan orchestrator yang memeriksa image pun membaca angka yang sama. Bagi kernel yang memeriksa izin berkas, yang dibandingkan selalu angka — itulah sebabnya konsistensi UID antar image menjadi urusan serius begitu data mulai berpindah. Berkas yang ditulis container ber-UID-1000 hanya bisa ditulis container lain ber-UID-1000; konflik angka antara image dan volume adalah salah satu sumber masalah penyimpanan paling klasik, dan kita bedah tuntas nanti pada Chapter Permission dan Ownership Volume.

WORKDIR versus USER #

Kedua instruksi ini kembar secara pola: sama-sama menetapkan konteks bagi instruksi setelahnya, sama-sama bisa ditimpa saat runtime. Tabel berikut merangkum keduanya berdampingan:

AspekWORKDIRUSER
MenetapkanDirektori kerja instruksi berikutnyaIdentitas user instruksi berikutnya
Berlaku untukCOPY, ADD, RUN, CMD, ENTRYPOINTRUN, CMD, ENTRYPOINT
Nilai bawaan tanpa instruksi/root
Dibuat otomatisDirektori dibuat bila belum adaUser harus dibuat lebih dulu lewat RUN
Efek pada container jalanDirektori kerja proses utamaUser yang menjalankan proses utama
Penimpa saat runtimedocker run –workdirdocker run –user

Penimpaan runtime pada baris terakhir tabel bukan teori: docker run --workdir dan docker run --user mengubah keduanya per container tanpa menyentuh image, dan mekanisme lengkapnya adalah materi Chapter Working Directory serta Chapter User dan Permissions di Section Konfigurasi Container. Image myapp:1.4 kini punya alamat dan identitas: berkas hidup di /app, proses berjalan sebagai user app nomor 1000. Dua instruksi berikutnya menambahkan dua suara lain pada image — VOLUME menandai titik data yang layak dipisahkan dari umur container, dan LABEL menempelkan metadata identitas yang dibaca tool. Keduanya tampak sama jinaknya dengan EXPOSE, tetapi VOLUME menyimpan efek runtime yang diam-diam mengejutkan; mari kita lanjutkan ke Chapter VOLUME dan LABEL.

Referensi #