Kamu jalanin container, terus langsung disambut error kayak gini:
PermissionError: [Errno 13] Permission denied: '/app/data/output.json'
atau yang lebih klasik:
mkdir: cannot create directory '/var/lib/myapp/cache': Permission denied
Padahal di local machine kamu, semua jalan mulus. Begitu masuk Docker volume, tiba-tiba file permission jadi nightmare. Ini salah satu masalah paling sering bikin developer buang waktu berjam-jam — dan solusinya sering nggak obvious kalau kamu belum pernah ketemu sebelumnya.
Artikel ini bahas cara troubleshoot Docker volume permissions issues dari akar masalahnya, bukan sekadar chmod 777 yang cuma nutupin masalah.
Kenapa Docker Volume Permissions Bisa Kacau?
Root cause-nya ada di sini: user ID (UID) di dalam container beda sama UID di host machine kamu.
Ketika Docker mount volume dari host ke container, filesystem permission yang berlaku adalah permission dari host — bukan dari dalam container. Jadi kalau file di host dimiliki oleh UID 1000, tapi proses di dalam container jalan sebagai UID 0 (root) atau UID 999 (user default dari base image), hasilnya bisa konflik.
Contoh konkret: kamu pakai image node:18-alpine. User default-nya adalah node dengan UID 1000. Tapi kalau direktori yang di-mount di host dimiliki oleh root (UID 0), container nggak bisa write ke sana.
Sebaliknya, kalau container jalan sebagai root tapi kamu mau restrict akses dari host — itu juga masalah tersendiri.
Langkah Pertama: Diagnosa Dulu
Jangan langsung ganti permission sembarangan. Diagnosa dulu siapa yang punya masalah.
Cek UID proses di dalam container:
docker exec -it nama_container id
Output-nya bakal kayak gini:
uid=1000(node) gid=1000(node) groups=1000(node)
Cek ownership file/direktori yang di-mount:
# Di host
ls -la /path/ke/volume/kamu
# Di dalam container
docker exec -it nama_container ls -la /app/data
Cek siapa yang jalanin proses di container:
docker exec -it nama_container ps aux
Dari sini kamu bisa lihat: UID proses di container berapa, dan UID owner direktori berapa. Kalau beda — itu sumber masalahnya.
Solusi 1: Match UID antara Host dan Container
Ini solusi paling clean untuk development environment. Kamu pass UID host kamu ke container supaya proses di dalam container jalan dengan UID yang sama.
Pakai --user flag saat docker run:
docker run --user $(id -u):$(id -g) \
-v $(pwd)/data:/app/data \
nama_image
$(id -u) dan $(id -g) otomatis ambil UID dan GID user kamu di host. Dengan ini, proses di container jalan dengan UID yang sama — jadi file permission dari host langsung cocok.
Versi Docker Compose:
# docker-compose.yml
version: '3.8'
services:
app:
image: node:18-alpine
user: "${UID}:${GID}"
volumes:
- ./data:/app/data
working_dir: /app
command: node server.js
Terus buat file .env di direktori yang sama:
# .env
UID=1000
GID=1000
Atau export dulu sebelum docker compose up:
export UID=$(id -u)
export GID=$(id -g)
docker compose up
Gotcha: Beberapa base image (terutama yang pakai entrypoint script kompleks) butuh jalan sebagai root dulu sebelum drop ke user lain. Kalau kamu set --user terlalu awal, entrypoint-nya bisa gagal. Cek dulu dokumentasi image yang kamu pakai.
Solusi 2: Fix Permission di Dockerfile
Kalau kamu punya kontrol atas Dockerfile, ini cara yang lebih proper untuk production.
FROM node:18-alpine
# Buat direktori yang dibutuhkan
RUN mkdir -p /app/data /app/logs
# Set ownership ke user 'node' yang sudah ada di image ini
RUN chown -R node:node /app
# Switch ke user non-root
USER node
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --only=production
COPY --chown=node:node . .
EXPOSE 3000
CMD ["node", "server.js"]
Perhatikan --chown=node:node di instruksi COPY. Ini penting supaya file yang di-copy langsung punya ownership yang benar, bukan root.
Untuk volume yang di-mount dari host, kamu masih perlu pastiin UID-nya match. Tapi untuk direktori yang dibuat di dalam container (bukan mount dari luar), cara ini sudah cukup.
Solusi 3: Entrypoint Script untuk Fix Permission Dinamis
Ini pattern yang sering dipakai di production image seperti PostgreSQL, Redis, dll. Idenya: jalanin entrypoint sebagai root, fix permission, lalu exec ke user yang bener.
Buat file entrypoint.sh:
#!/bin/sh
set -e
# Fix ownership direktori yang di-mount
chown -R node:node /app/data /app/logs
# Drop ke user 'node' dan jalanin command aslinya
exec su-exec node "$@"
Catatan:
su-execadalah alternatif ringan darigosuuntuk Alpine. Install dulu di Dockerfile:
FROM node:18-alpine
RUN apk add --no-cache su-exec
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN mkdir -p /app/data /app/logs
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --only=production
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "server.js"]
Dengan pattern ini, container startup sebagai root (untuk fix permission), terus langsung drop ke node user sebelum jalanin aplikasi.
Gotcha yang gue pernah kena: Kalau kamu pakai ENTRYPOINT dalam format shell (ENTRYPOINT entrypoint.sh) bukan exec form (ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]), signal handling-nya bisa bermasalah — container jadi lambat stop dan harus nunggu timeout. Selalu pakai exec form.
Solusi 4: Named Volume vs Bind Mount
Ini sering diabaikan. Ada dua jenis volume di Docker:
-
Bind mount: kamu mount direktori dari host langsung ke container. Permission issue paling sering terjadi di sini karena UID host vs container bisa beda.
-
Named volume: Docker yang manage storage-nya. Permission-nya lebih predictable karena Docker yang inisialisasi direktori-nya.
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data # Named volume — lebih aman
environment:
POSTGRES_PASSWORD: secret
app:
image: node:18-alpine
volumes:
- ./src:/app/src # Bind mount — untuk development, supaya hot reload jalan
- app_logs:/app/logs # Named volume — untuk logs
volumes:
postgres_data:
app_logs:
Untuk data yang perlu persist (database, uploads), pakai named volume. Untuk development workflow yang butuh live reload, bind mount masih perlu — tapi kamu harus handle permission-nya.
Solusi 5: Cek SELinux dan AppArmor
Kalau kamu di Linux (terutama Fedora, RHEL, atau CentOS), permission error kadang bukan soal UID — tapi SELinux yang block akses.
# Cek apakah SELinux aktif
getenforce
# Kalau output-nya 'Enforcing', coba label ulang direktori
chcon -Rt svirt_sandbox_file_t /path/ke/volume
Atau di docker run, tambahkan :z atau :Z di mount option:
# :z = shared antara multiple containers
# :Z = private, hanya untuk container ini
docker run -v $(pwd)/data:/app/data:z nama_image
Di Docker Compose:
volumes:
- ./data:/app/data:z
Gotcha: Jangan pakai :Z kalau direktori yang sama di-mount ke lebih dari satu container — bakal bikin container lain nggak bisa akses.
Quick Debug Checklist
Kalau kamu lagi stuck, jalanin ini satu per satu:
# 1. Cek UID user di host
id
# 2. Cek UID proses di container
docker exec -it nama_container id
# 3. Cek ownership direktori di host
ls -la /path/ke/volume
# 4. Cek ownership direktori di dalam container
docker exec -it nama_container ls -la /app/data
# 5. Coba jalanin container sebagai root untuk isolasi masalah
docker exec -it --user root nama_container sh
# 6. Dari dalam container sebagai root, cek bisa write atau tidak
touch /app/data/test_file && echo 'root bisa write'
# 7. Kalau root bisa tapi user biasa nggak — itu murni permission issue
# Kalau root pun nggak bisa — mungkin SELinux atau filesystem read-only
Yang Gue Lakuin di Setup Sehari-hari
Setelah berkali-kali ketemu masalah ini, setup yang gue pakai sekarang:
Untuk development: Selalu pakai user: "${UID}:${GID}" di docker-compose.yml dan export variabelnya di .bashrc:
# Tambahkan ini ke ~/.bashrc atau ~/.zshrc
export UID=$(id -u)
export GID=$(id -g)
Untuk production image: Pakai entrypoint script pattern (Solusi 3) — lebih fleksibel dan handle edge case lebih baik.
Untuk database dan persistent storage: Selalu named volume, bukan bind mount. Lebih sedikit headache.
Satu rule yang gue pegang: Jangan pernah solve permission issue dengan chmod 777. Itu bukan solusi — itu bom waktu, terutama kalau direktori itu accessible dari luar container atau berisi sensitive data.
Langkah lanjutan yang worth dicoba: kalau kamu sering develop dengan Docker, coba setup docker-compose.override.yml yang otomatis inject UID/GID kamu — jadi nggak perlu export manual tiap buka terminal baru. Atau kalau kamu pakai Rootless Docker, permission model-nya sedikit beda dan worth dipelajari tersendiri.