Permission Denied in Docker Volumes oplossen op Linux en macOS
Docker-volumes zijn een van de meest gebruikte mechanismen om data persistent te maken en om broncode of configuratiebestanden te delen tussen je host en containers. Tegelijkertijd zijn “Permission denied”-fouten veruit de meest voorkomende frustratie bij bind mounts en volumes, vooral wanneer je werkt met Linux-permissies, UID/GID-mapping, SELinux, of macOS-bestandsdeling via Docker Desktop.
Deze tutorial legt diepgaand uit waarom permissieproblemen ontstaan en geeft praktische, echte commando’s om ze op te lossen. We behandelen zowel Linux (met aandacht voor SELinux en rootless Docker) als macOS (Docker Desktop, bestandsdeling en performance/permissie-implicaties).
Inhoud
- 1. Begrippen: volume vs bind mount
- 2. Symptomen herkennen: typische foutmeldingen
- 3. De kern: UID/GID en eigenaarschap
- 4. Snelle diagnose-checklist (Linux en macOS)
- 5. Oplossingen op Linux
- 5.1 Check welke user in de container draait
- 5.2 Eigenaarschap en rechten corrigeren (chown/chmod)
- 5.3 Werk met dezelfde UID/GID als de host
- 5.4 Dockerfile: user aanmaken met host-UID
- 5.5 Compose:
user:engroup_add: - 5.6 Named volumes vs bind mounts: waarom named volumes vaak “makkelijker” zijn
- 5.7 SELinux (Fedora/RHEL/CentOS):
:Zen:z - 5.8 Rootless Docker: subuid/subgid en id-mapping
- 5.9 NFS/SMB mounts op de host: extra valkuilen
- 6. Oplossingen op macOS (Docker Desktop)
- 7. Debugging: zo inspecteer je mounts en permissies
- 8. Voorbeeldscenario’s (met echte commando’s)
- 9. Best practices om het structureel te voorkomen
1. Begrippen: volume vs bind mount
Bind mount: je mount een pad van je host direct in de container.
docker run --rm -v "$PWD:/app" alpine ls -la /app
Named volume: Docker beheert de opslaglocatie (meestal onder /var/lib/docker/volumes/... op Linux). Je verwijst naar de volumenaam, niet naar een hostpad.
docker volume create mydata
docker run --rm -v mydata:/data alpine sh -c "echo test > /data/hello && cat /data/hello"
Waarom relevant voor permissies?
- Bij bind mounts gelden de host-permissies (UID/GID, mode bits, ACLs, SELinux labels) direct op de container.
- Bij named volumes is Docker vaak “in controle” en initialisatie gebeurt meestal met rechten die passen bij de container, wat veel problemen voorkomt (maar niet altijd).
2. Symptomen herkennen: typische foutmeldingen
Veelvoorkomende errors:
- In logs van een container:
Permission deniedEACCES: permission deniedmkdir: can't create directory: Permission deniedchown: changing ownership of ...: Operation not permitted
- Bij het starten:
- Database images (Postgres, MySQL) weigeren te starten omdat de data directory niet beschrijfbaar is.
- Bij development:
- Tools kunnen geen cache schrijven (bijv. npm, yarn, pip, composer).
Voorbeeld:
docker run --rm -v "$PWD:/app" node:20-alpine sh -c "cd /app && npm ci"
Als je projectmap op de host niet schrijfbaar is voor de user in de container, kan dit falen met EACCES.
3. De kern: UID/GID en eigenaarschap
Linux-permissies zijn gebaseerd op:
- UID (user ID)
- GID (group ID)
- mode bits (rwx voor user/group/others)
- (optioneel) ACLs en SELinux labels
Een container heeft zijn eigen /etc/passwd, maar de kernel kijkt bij bestandstoegang naar numerieke UID/GID. Dus:
- Als een proces in de container draait als UID
1000, - en het gemounte hostpad is eigendom van UID
501(macOS) of1001(Linux), - dan kan schrijven mislukken, zelfs als de naam van de user “node” of “app” is.
Belangrijk: root in een container is niet altijd almachtig, zeker niet bij:
- rootless Docker
- bepaalde filesystem mounts
- macOS file sharing
- SELinux policies
4. Snelle diagnose-checklist (Linux en macOS)
- Welke user draait in de container?
docker run --rm alpine id
Of in een draaiende container:
docker exec -it <container> id
- Wat zijn de rechten van het gemounte pad in de container?
docker exec -it <container> sh -c "ls -la /pad && stat -c '%u %g %a %n' /pad"
- Wat zijn de rechten op de host?
Linux:
ls -la .
stat -c '%u %g %a %n' .
macOS:
ls -la .
stat -f '%u %g %Lp %N' .
- Gebruik je SELinux? (Linux)
getenforce 2>/dev/null || echo "SELinux tools niet aanwezig"
- Is dit een bind mount of named volume?
docker inspect <container> --format '{{json .Mounts}}' | jq
5. Oplossingen op Linux
5.1 Check welke user in de container draait
Veel officiële images draaien niet als root (bijv. node, nginx, sommige postgres setups). Check:
docker run --rm node:20-alpine id
docker run --rm nginx:alpine id
Je kunt ook de default user van een image inspecteren:
docker image inspect node:20-alpine --format '{{.Config.User}}'
Leeg betekent vaak “root” (UID 0), maar dat is niet gegarandeerd in alle varianten.
5.2 Eigenaarschap en rechten corrigeren (chown/chmod)
Snelste fix: maak de hostdirectory eigendom van jouw user (of van de UID die in de container gebruikt wordt).
Stel: je mount ./data naar /var/lib/postgresql/data en Postgres draait als UID 999 (voorbeeld). Dan:
sudo mkdir -p data
sudo chown -R 999:999 data
sudo chmod -R u+rwX,g+rwX data
Daarna:
docker run --rm -v "$PWD/data:/var/lib/postgresql/data" postgres:16
Let op: dit verandert eigenaarschap op je host. In teams kan dit onwenselijk zijn.
Alternatief: geef write-permissie via group of “others”, maar dat is vaak minder veilig:
chmod -R o+rwX data
Gebruik dit alleen als je begrijpt wat je openzet.
5.3 Werk met dezelfde UID/GID als de host
Voor development is het vaak het beste om processen in de container te draaien met jouw host-UID/GID, zodat bestanden die de container aanmaakt op de host “van jou” zijn.
Je host-UID/GID op Linux:
id -u
id -g
Run container met die user:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" \
-w /app \
alpine sh -c "id && touch testfile && ls -la testfile"
Als dit werkt, is de kern van je probleem opgelost: UID/GID matchen.
Valkuil: sommige images verwachten root om packages te installeren of directories aan te maken. Oplossing: maak directories vooraf aan of gebruik een entrypoint dat init als root doet en daarna dropt naar een user.
5.4 Dockerfile: user aanmaken met host-UID
Voor een eigen image kun je een user creëren die overeenkomt met de host-UID. Vaak doe je dit via build args:
Dockerfile (voorbeeld)
FROM alpine:3.20
ARG UID=1000
ARG GID=1000
RUN addgroup -g ${GID} appgroup \
&& adduser -D -u ${UID} -G appgroup appuser
WORKDIR /app
USER appuser
CMD ["sh", "-lc", "id && ls -la && sleep 3600"]
Build met jouw UID/GID:
docker build -t myapp \
--build-arg UID="$(id -u)" \
--build-arg GID="$(id -g)" \
.
Run met bind mount:
docker run --rm -v "$PWD:/app" myapp
Dit voorkomt dat je hostbestanden “root-owned” worden.
5.5 Compose: user: en group_add:
In docker-compose.yml (of compose.yaml) kun je de user instellen:
id -u
id -g
Dan in Compose:
services:
app:
image: node:20-alpine
user: "1000:1000"
working_dir: /app
volumes:
- ./:/app
command: sh -lc "id && npm ci && npm test"
Je kunt dit dynamisch maken met environment variables:
export UID="$(id -u)"
export GID="$(id -g)"
docker compose up
En in Compose:
services:
app:
image: node:20-alpine
user: "${UID}:${GID}"
volumes:
- ./:/app
group_add is nuttig als je toegang nodig hebt tot een extra group (bijv. docker group of een shared group op de host):
services:
app:
image: alpine
user: "${UID}:${GID}"
group_add:
- "998"
5.6 Named volumes vs bind mounts: waarom named volumes vaak “makkelijker” zijn
Bij databases is een named volume vaak de beste keuze:
docker volume create pgdata
docker run --rm \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
Waarom dit vaak werkt zonder gedoe:
- De image entrypoint initialiseert de directory met de juiste owner.
- Docker beheert het pad; je host-umask en projectdirectory spelen minder mee.
Wil je toch data exporteren? Gebruik docker cp of een tijdelijke container:
docker run --rm -v pgdata:/data -v "$PWD:/backup" alpine \
sh -c "tar -czf /backup/pgdata.tgz -C /data ."
5.7 SELinux (Fedora/RHEL/CentOS): :Z en :z
Op SELinux-systemen kan een bind mount correct lijken qua UID/GID, maar toch falen door labels.
Check SELinux status:
getenforce
Als dit Enforcing is, en je mount een hostpad, gebruik dan vaak :Z of :z:
:Z= private label voor één container:z= shared label voor meerdere containers
Voorbeeld:
docker run --rm \
-v "$PWD/data:/data:Z" \
alpine sh -c "touch /data/test && ls -la /data"
In Compose:
services:
app:
image: alpine
volumes:
- ./data:/data:Z
Als je zonder :Z werkt, kan je error krijgen zoals:
permission deniedondanks correcte Unix-permissies- audit logs die SELinux blocks tonen
Je kunt ook labels bekijken:
ls -Z .
ls -Z data
5.8 Rootless Docker: subuid/subgid en id-mapping
Bij rootless Docker draait de Docker daemon en containers zonder root privileges. Dit is veiliger, maar permissies kunnen complexer worden door user namespaces.
Check of je rootless gebruikt:
docker info | grep -i rootless
Of:
ps -ef | grep dockerd
Rootless gebruikt vaak subuid/subgid ranges (bijv. in /etc/subuid en /etc/subgid). Daardoor kan UID 0 in de container gemapt zijn naar een niet-root UID op de host.
Bekijk ranges:
cat /etc/subuid
cat /etc/subgid
Gevolg: chown in de container kan “Operation not permitted” geven op bind mounts, omdat de mapping niet overeenkomt of omdat het host filesystem het niet toestaat.
Praktische aanpak:
- Gebruik
-u "$(id -u):$(id -g)"zodat containerprocessen als jouw user draaien. - Vermijd
chownop bind mounts in entrypoints. - Overweeg named volumes (Docker beheert die binnen de rootless context).
5.9 NFS/SMB mounts op de host: extra valkuilen
Als je projectdirectory op Linux zelf al een network mount is (NFS, CIFS/SMB), kunnen chown en Unix-permissies anders werken.
Diagnose:
mount | grep -E 'nfs|cifs|smb'
df -T .
Bij CIFS kan chmod/chown genegeerd worden afhankelijk van mount options. Mogelijke oplossingen:
- Mount met juiste options (bijv.
uid=,gid=,file_mode=,dir_mode=bij CIFS). - Gebruik named volumes in plaats van bind mounts voor write-heavy data.
- Zet caches/binaries (zoals
node_modules) in een named volume.
6. Oplossingen op macOS (Docker Desktop)
6.1 Begrijp de macOS-architectuur: VM en file sharing
Op macOS draait Docker meestal via Docker Desktop, waarbij containers in een Linux VM draaien. Jouw macOS-bestanden worden gedeeld met die VM via een file sharing mechanisme (gRPC FUSE, VirtioFS, of een vergelijkbare laag, afhankelijk van je Docker Desktop versie en instellingen).
Belangrijk gevolg:
- De “echte” Linux-kernel die permissies afdwingt zit in de VM.
- Mapping van macOS ownership/permissies naar Linux is niet altijd 1-op-1.
- Sommige operaties zoals
chownop gedeelde directories kunnen beperkt zijn of anders gedragen.
Je kunt nog steeds Permission denied krijgen, zelfs als ls -la op macOS “goed” lijkt.
6.2 File Sharing instellingen controleren
Controleer in Docker Desktop:
- Settings → Resources → File Sharing
- Zorg dat je projectpad (bijv.
/Users/<naam>/Projects/...) gedeeld wordt.
Als het pad niet gedeeld is, zie je vaak errors bij mounten of “empty directory” gedrag, maar permissies kunnen ook vreemd uitpakken.
Test met een simpele container:
docker run --rm -v "$PWD:/mnt" alpine sh -c "ls -la /mnt && touch /mnt/.write-test && ls -la /mnt/.write-test"
Als touch faalt met Permission denied, dan is het een write-permission issue in de sharing laag of UID/GID mismatch.
6.3 UID/GID op macOS vs Linux container
Op macOS heeft je user vaak UID 501 en GID 20 (staff), maar dit kan verschillen.
Check:
id -u
id -g
In de container is de default user vaak root (0) of een app user (bijv. node = 1000). Als je bind mount gebruikt, kan het helpen om de container als jouw macOS UID/GID te draaien:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" \
-w /app \
alpine sh -c "id && touch mac-test && ls -la mac-test"
Dit werkt vaak goed voor development, maar niet altijd (afhankelijk van Docker Desktop’s mapping).
6.4 Praktische fixes: user mapping, chown, en alternatieven
Fix A: draai containerprocessen als jouw UID/GID
Voor development stacks (Node, Python, PHP) is dit vaak de meest stabiele oplossing:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" \
-w /app \
node:20-alpine sh -lc "npm ci"
In Compose:
services:
app:
image: node:20-alpine
user: "${UID}:${GID}"
volumes:
- ./:/app
working_dir: /app
En start met:
export UID="$(id -u)"
export GID="$(id -g)"
docker compose run --rm app sh -lc "id && npm ci"
Fix B: vermijd chown op bind mounts (zeker op macOS)
Veel entrypoints doen chown -R op startup. Op macOS shared mounts kan dit:
- extreem traag zijn
- falen met “Operation not permitted”
- of leiden tot onverwacht gedrag
Oplossing: pas je image/entrypoint aan zodat:
- je niet recursief
chowndoet op een bind mount - je alleen
chowndoet op een named volume (die binnen de VM “native Linux” is)
Fix C: zet write-heavy directories in named volumes
Klassiek voorbeeld: node_modules in een bind mount op macOS is vaak traag en kan permissiegedoe geven. Gebruik:
- projectcode via bind mount
- dependencies in named volume
Compose-voorbeeld:
services:
app:
image: node:20-alpine
working_dir: /app
volumes:
- ./:/app
- node_modules:/app/node_modules
command: sh -lc "npm ci && npm test"
volumes:
node_modules:
Dit voorkomt dat de container node_modules op je host hoeft te beheren.
6.5 Wanneer je beter een named volume gebruikt op macOS
Gebruik named volumes voor:
- databases (
/var/lib/postgresql/data,/var/lib/mysql) - package caches (
/root/.cache,~/.npm,~/.cache/pip) - directories met veel kleine files (performance + minder permissieproblemen)
Voorbeeld Postgres op macOS:
docker volume create pgdata
docker run --rm -p 5432:5432 \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
Als je toch een bind mount wil voor backups, mount dan een aparte map alleen voor export/import.
7. Debugging: zo inspecteer je mounts en permissies
Inspecteer mounts in een container
docker inspect <container> --format '{{range .Mounts}}{{println .Type .Source "->" .Destination}}{{end}}'
Check filesystem type en mount options (in container)
docker exec -it <container> sh -c "mount | head -n 50"
Check of je kunt schrijven
docker exec -it <container> sh -c "touch /pad/can-i-write && echo OK"
Check owner en mode bits
Linux in container:
docker exec -it <container> sh -c "stat -c '%u %g %a %n' /pad /pad/* 2>/dev/null | head"
macOS host:
stat -f '%u %g %Lp %N' .
Check ACLs (Linux)
getfacl -p data | sed -n '1,80p'
ACLs kunnen toegang blokkeren of juist verlenen, los van chmod.
8. Voorbeeldscenario’s (met echte commando’s)
8.1 Node.js schrijft naar node_modules en faalt
Symptoom: npm ci geeft EACCES: permission denied, mkdir '/app/node_modules'.
Diagnose:
docker run --rm -it -v "$PWD:/app" -w /app node:20-alpine sh -lc "id; ls -la; npm ci"
Als id toont dat je als node (UID 1000) draait, maar je projectmap is eigendom van een andere UID en niet group-writable, dan faalt het.
Oplossing 1 (UID/GID match):
docker run --rm -it \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" -w /app \
node:20-alpine sh -lc "id; npm ci"
Oplossing 2 (node_modules in named volume):
docker volume create nm
docker run --rm -it \
-v "$PWD:/app" -v nm:/app/node_modules \
-w /app \
node:20-alpine sh -lc "npm ci"
8.2 PostgreSQL data directory “permission denied”
Fout (typisch): Postgres start niet en klaagt over /var/lib/postgresql/data niet schrijfbaar.
Slechte aanpak (bind mount zonder juiste owner):
mkdir -p pgdata
docker run --rm -e POSTGRES_PASSWORD=secret -v "$PWD/pgdata:/var/lib/postgresql/data" postgres:16
Oplossing A (named volume):
docker volume create pgdata
docker run --rm -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:16
Oplossing B (Linux bind mount + chown naar juiste UID)
Achterhaal UID in image (vaak postgres user). Je kunt dit checken:
docker run --rm postgres:16 id
Stel dit geeft UID 999. Dan:
sudo chown -R 999:999 pgdata
sudo chmod -R u+rwX pgdata
docker run --rm -e POSTGRES_PASSWORD=secret -v "$PWD/pgdata:/var/lib/postgresql/data" postgres:16
SELinux variant (Fedora/RHEL):
docker run --rm -e POSTGRES_PASSWORD=secret -v "$PWD/pgdata:/var/lib/postgresql/data:Z" postgres:16
8.3 Nginx kan geen logs schrijven
Stel je mount een logdirectory:
mkdir -p logs
docker run --rm -p 8080:80 -v "$PWD/logs:/var/log/nginx" nginx:alpine
Nginx draait vaak als nginx user. Als /var/log/nginx niet schrijfbaar is, krijg je errors.
Diagnose:
docker run --rm nginx:alpine id
ls -la logs
Fix (Linux): maak logs eigendom van de nginx UID/GID, of draai Nginx als jouw user (voor development).
Eigenaarschap aanpassen:
# voorbeeld: stel nginx user is 101
sudo chown -R 101:101 logs
sudo chmod -R u+rwX logs
Of run als root (niet ideaal voor productie):
docker run --rm -u 0:0 -p 8080:80 -v "$PWD/logs:/var/log/nginx" nginx:alpine
Beter: in productie schrijf je logs naar stdout/stderr en laat je Docker logging doen, i.p.v. naar een bind mount.
9. Best practices om het structureel te voorkomen
- Gebruik named volumes voor stateful data (databases, queues, caches).
- Gebruik bind mounts vooral voor broncode (development).
- Laat containerprocessen draaien met host UID/GID bij development:
- Linux: bijna altijd ideaal
- macOS: vaak goed, maar combineer met named volumes voor heavy directories
- Vermijd recursieve
chown -Rop bind mounts (traag, foutgevoelig, soms onmogelijk). - Check SELinux op Linux en gebruik
:Z/:zwaar nodig. - Maak permissies expliciet:
- Maak directories aan op de host met juiste owner/mode
- Gebruik
umaskbewust
- Documenteer UID/GID verwachtingen in je project:
.envmetUID/GID- Compose die
user: "${UID}:${GID}"gebruikt
- Debug systematisch:
idin containerstatop host en in containerdocker inspectmountsgetenforce(SELinux)
Extra: compacte “recepten” (copy/paste)
Recept 1: snel testen of UID/GID mismatch het probleem is
docker run --rm -it -u "$(id -u):$(id -g)" -v "$PWD:/app" -w /app alpine sh -lc "id; touch .permtest; ls -la .permtest"
Recept 2: SELinux bind mount fix
docker run --rm -v "$PWD/data:/data:Z" alpine sh -lc "touch /data/x"
Recept 3: code bind mount + dependencies in named volume (macOS-friendly)
docker volume create node_modules
docker run --rm -it \
-v "$PWD:/app" \
-v node_modules:/app/node_modules \
-w /app \
node:20-alpine sh -lc "npm ci"
Samenvatting
“Permission denied” bij Docker-volumes is bijna altijd terug te voeren op één (of een combinatie) van:
- UID/GID mismatch tussen containerprocessen en hostbestanden
- verkeerde chmod/chown op de hostdirectory
- SELinux labels (Linux)
- rootless Docker mapping (Linux)
- Docker Desktop file sharing beperkingen (macOS)
Door systematisch te debuggen (wie ben ik in de container, wie is eigenaar van het pad, welke mount is het, en speelt SELinux mee?) kun je het probleem snel isoleren. Kies vervolgens de passende oplossing: UID/GID matchen, permissies aanpassen, SELinux labels zetten, of overstappen op named volumes voor data en heavy directories.
Als je wilt, kun je een concreet voorbeeld delen (je docker run/Compose snippet + de exacte foutmelding + output van id in container en stat op host), dan kan ik gericht aangeven welke fix in jouw situatie het meest robuust is.