Error EACCES: permission denied sering terjadi saat menjalankan service backend di container lokal macOS yang membaca atau menulis ke direktori yang dimount dari host. Masalah ini umumnya memicu crash pada proses inisialisasi basis data lokal (seperti SQLite, embedded PostgreSQL) atau pembuatan file log aplikasi. Artikel ini menganalisis penyebab disparitas UID/GID antara host macOS dan Linux VM, investigasi lewat CLI, serta solusi praktis tanpa mengeksekusi container secara permanen sebagai user root.

Anatomi Masalah: Disparitas UID 501 macOS dan VM Filesystem

macOS bukan sistem operasi berbasis kernel Linux. Container runtime di macOS (Docker Desktop, Podman, orbstack, atau tdeverx/contained-app) menjalankan Linux kernel di dalam virtual machine (VM) hypervisor. Volume mount dari macOS ke container melewati lapisan abstraksi filesystem seperti virtiofs atau gRPC-FUSE.

Akar kegagalan hak akses ini berpijak pada dua aturan identitas user:

  • Default UID macOS: Akun pengguna pertama yang dibuat di macOS secara default memiliki UID 501 dan GID 20 (staff).
  • Default UID Container Linux: Sebagian besar base image resmi Linux (Node.js, PostgreSQL, Alpine, Debian) mengalokasikan UID user non-root pada nilai 1000 (misalnya user node) atau UID sistem khusus seperti 999 (user postgres).

Saat direktori lokal di host dimount ke container melalui bind mount, metadata kepemilikan file pada host diekspos sebagai pemilik UID 501. Ketika proses aplikasi backend di dalam container berjalan sebagai user UID 1000, proses tersebut ditolak oleh kernel Linux saat melakukan operasi write ke direktori yang memiliki izin akses POSIX standar 0755 (hanya pemilik yang memiliki izin tulis).

Investigasi dan Reproduksi Masalah via CLI

Untuk memverifikasi disparitas permission tanpa menebak-nebak, lakukan audit identitas pada host dan container menggunakan perintah CLI berikut.

1. Periksa UID dan GID di Host macOS

Jalankan perintah ini di terminal host macOS:

id -u
# Output: 501

id -g
# Output: 20

2. Audit Hak Akses di Sisi Container

Periksa bagaimana Linux VM container melihat direktori yang dimount:

# Uji inspeksi direktori volume mount
docker run --rm -v "$(pwd)/data":/data alpine ls -ldn /data
# Contoh output: drwxr-xr-x 2 501 20 64 May 10 10:00 /data

# Uji identitas proses non-root bawaan image aplikasi
docker run --rm node:20-alpine id
# Output: uid=1000(node) gid=1000(node) groups=1000(node)

Hasil di atas menunjukkan bahwa direktori dimiliki oleh 501:20 dengan permission drwxr-xr-x. User 1000 masuk dalam kategori others dengan izin r-x (read-only), sehingga operasi tulis akan langsung menghasilkan error EACCES.

Solusi Mengatasi Volume Mount EACCES pada Container Lokal macOS

Solusi yang sering diambil secara keliru adalah menjalankan chmod 777 -R . di host atau menambahkan flag user: "root" pada container. Praktik ini berisiko mencemari file host dengan kepemilikan root atau merusak permission git. Berikut tiga pendekatan teruji yang mempertahankan prinsip least privilege.

Opsi 1: Meneruskan Host UID/GID via Runtime Flag

Jika container tidak mewajibkan user spesifik dari /etc/passwd, instruksikan container engine untuk memetakan UID dan GID proses langsung ke user host macOS saat eksekusi.

Via CLI:

docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd)":/app -w /app backend-service:dev

Via Docker Compose (docker-compose.yml):

services:
  api:
    build: .
    user: "${UID:-501}:${GID:-20}"
    volumes:
      - .:/app
    working_dir: /app

Keterbatasan: Beberapa base image yang mengeksekusi library yang memvalidasi integritas file /etc/passwd (seperti SSH client atau tool cache tertentu) mungkin memunculkan peringatan I have no name! jika UID 501 belum terdaftar di dalam file /etc/passwd container.

Opsi 2: Dynamic Entrypoint Script Menggunakan gosu (Rekomendasi)

Pendekatan ini paling fleksibel untuk development stack tim campuran (Linux dan macOS). Container dimulai sebagai root, menyesuaikan UID user internal secara dinamis agar identik dengan UID pemilik volume mount, lalu menurunkan privilege ke user biasa sebelum aplikasi dijalankan menggunakan utility gosu.

Buat skrip entrypoint.sh:

#!/bin/sh
set -e

# Target user internal yang didefinisikan di Dockerfile
APP_USER="appuser"

# Ambil UID dari pemilik direktori yang dimount
TARGET_UID=$(stat -c "%u" /app 2>/dev/null || stat -f "%u" /app)
TARGET_GID=$(stat -c "%g" /app 2>/dev/null || stat -f "%g" /app)

# Ubah UID dan GID user internal jika berbeda dari host
if [ "$TARGET_UID" != "0" ] && [ "$TARGET_UID" != "$(id -u $APP_USER)" ]; then
    usermod -o -u "$TARGET_UID" "$APP_USER" 2>/dev/null || sed -i -e "s/:$APP_USER:/:$TARGET_UID:/g" /etc/passwd
    groupmod -o -g "$TARGET_GID" "$APP_USER" 2>/dev/null || true
fi

# Eksekusi command utama menggunakan user non-root
exec gosu "$APP_USER" "$@"

Konfigurasi Dockerfile:

FROM node:20-bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*

RUN useradd -m -s /bin/bash appuser

WORKDIR /app

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["npm", "run", "dev"]

Pola ini menjamin file yang dibuat oleh container di host macOS akan tetap memiliki UID 501, menghindari konflik izin saat file diedit kembali dari IDE di macOS.

Opsi 3: Menggunakan Named Volumes untuk Direktori Transient

Jika error EACCES terjadi pada data yang tidak perlu dimodifikasi langsung oleh developer di host (misalnya direktori data SQLite, cache compiler, atau runtime log), pisahkan direktori tersebut ke Docker Named Volume.

services:
  backend:
    image: my-backend:dev
    volumes:
      - .:/app
      - db_data:/app/data
      - log_data:/app/logs

volumes:
  db_data:
  log_data:

Alasan teknis: Named volume dikelola langsung oleh storage driver Linux VM, bukan disinkronisasi lewat host share layer virtiofs. Container memiliki kontrol penuh atas permission direktori internal tanpa terikat pada UID host macOS.

Perbandingan Pendekatan

MetodeKelebihanKekurangan / Trade-off
user: "$(id -u):$(id -g)"Konfigurasi instan, tanpa rebuild image.Bisa menimbulkan error no name pada tool yang membaca /etc/passwd.
Dynamic gosu EntrypointTransparan bagi seluruh tim; permission file host dan container sinkron.Membutuhkan instalasi utility gosu dan skrip entrypoint tambahan.
Named VolumesI/O disk tercepat di macOS, permission POSIX native.File tidak langsung terlihat/terbaca dari macOS Finder tanpa docker cp.

Untuk kebutuhan lokal development rutin, gunakan Named Volumes pada file data/database lokal. Jika file output perlu diakses langsung oleh IDE macOS, gunakan pola dynamic entrypoint dengan gosu.