Fix Permission Denied Errors in Docker Volumes on Linux and macOS
Permission errors with Docker volumes are among the most common (and most confusing) problems when running containers that write files: databases refusing to start, web apps unable to create cache folders, build tools failing to write artifacts, or logs not being created. The root cause is almost always the same: the user inside the container does not have permission to read/write the mounted directory.
This tutorial explains why it happens and provides practical, real commands to diagnose and fix it on Linux and macOS (Docker Desktop). It also covers best practices to prevent permission problems in the first place.
Table of Contents
- 1. Understand the 3 types of storage in Docker
- 2. The core concept: UID/GID and ownership
- 3. Identify whether you’re using a bind mount or a named volume
- 4. Reproduce and recognize common permission error patterns
- 5. Linux fixes (bind mounts)
- 5.1 Diagnose: check ownership and permissions
- 5.2 Fix: run the container as your host user (quick fix)
- 5.3 Fix: chown the host directory to match the container user
- 5.4 Fix: use ACLs (fine-grained, safer than chmod 777)
- 5.5 Fix: set group permissions and use a shared GID
- 5.6 SELinux/AppArmor notes (Linux-specific)
- 6. Linux fixes (named volumes)
- 7. macOS fixes (Docker Desktop)
- 8. Docker Compose patterns that prevent permission issues
- 9. Common scenarios and exact fixes
- 10. Anti-patterns to avoid
- 11. A systematic checklist
1. Understand the 3 types of storage in Docker
Docker containers have a writable layer, but it’s ephemeral: delete the container and the changes are gone. To persist data or share it with the host, you use:
-
Bind mounts
You mount a host path into the container:docker run --rm -v "$PWD:/app" alpine ls -la /app- Pros: easy to edit code on the host, container sees changes immediately.
- Cons: permissions depend on host filesystem + container user; most permission issues happen here.
-
Named volumes
Docker manages the storage:docker volume create mydata docker run --rm -v mydata:/var/lib/myapp alpine ls -la /var/lib/myapp- Pros: more consistent behavior; often fewer permission surprises.
- Cons: not directly visible in your project directory; you manage it via Docker commands.
-
tmpfs mounts (Linux only; Docker Desktop supports it in some cases)
In-memory filesystem:docker run --rm --tmpfs /tmp alpine sh -c 'echo hi > /tmp/x && cat /tmp/x'- Pros: fast, ephemeral.
- Cons: not persistent; not a direct fix for volume permissions.
This tutorial focuses on bind mounts and named volumes.
2. The core concept: UID/GID and ownership
Linux permissions are based on numeric IDs:
- UID: user ID (e.g.,
1000) - GID: group ID (e.g.,
1000) - File ownership is stored as numbers, not names.
Inside a container, a process runs as some UID/GID. If it tries to write into a mounted directory, the kernel checks whether that UID/GID has permission.
Key implication:
- If your host directory is owned by UID 1000, but the container process runs as UID 1001 (or 0/root), permissions may not match what you expect.
- If the container is running as a non-root user (common for security), it may not be allowed to write to a root-owned directory.
You can check your host IDs:
id
id -u
id -g
Example output:
uid=1000(alice) gid=1000(alice) groups=1000(alice),27(sudo)
Inside a container:
docker run --rm alpine sh -c 'id; whoami; ls -ld .'
3. Identify whether you’re using a bind mount or a named volume
This determines the best fix.
In docker run
- Bind mount often looks like:
-v /home/alice/project:/app - Named volume looks like:
-v mydata:/var/lib/postgresql/data
In Docker Compose
Bind mount:
services:
app:
volumes:
- ./src:/app/src
Named volume:
services:
db:
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Confirm by inspecting a container
docker inspect <container_name_or_id> --format '{{json .Mounts}}' | jq .
Look for:
"Type": "bind"vs"Type": "volume""Source"path (host path for bind mounts; Docker volume path for named volumes)
If you don’t have jq, you can do:
docker inspect <container> | grep -A5 -B2 '"Mounts"'
4. Reproduce and recognize common permission error patterns
Typical errors:
EACCES: permission denied, mkdir ...permission deniedwhen writing logs- Database images failing to initialize due to inability to write data directory
Operation not permittedwhen trying tochowna bind mount from inside the container
A quick reproduction:
mkdir -p demo && cd demo
mkdir data
chmod 700 data
sudo chown root:root data
docker run --rm -v "$PWD/data:/data" alpine sh -c 'id; touch /data/test'
You’ll likely see:
touch: /data/test: Permission denied
Because the container user (often root in Alpine by default, but you can simulate non-root) doesn’t match permissions, or the directory is too restrictive.
5. Linux fixes (bind mounts)
Bind mounts on Linux are the “rawest” and most direct: the container sees the exact host filesystem permissions.
5.1 Diagnose: check ownership and permissions
On the host:
ls -ld ./data
stat ./data
getfacl -p ./data 2>/dev/null || true
Inside the container, check what user you are and what the mount looks like:
docker run --rm -it -v "$PWD/data:/data" alpine sh
# inside:
id
ls -ld /data
touch /data/test
If touch fails, the output of ls -ld /data will often reveal why.
5.2 Fix: run the container as your host user (quick fix)
If you’re bind-mounting your project directory and want the container to write files that your host user can edit, run the container with your UID/GID:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" \
-w /app \
alpine sh -c 'id; touch created-by-container'
This makes the container process run as you, so file ownership matches naturally.
Docker Compose equivalent:
services:
app:
image: alpine
user: "${UID}:${GID}"
volumes:
- ./:/app
working_dir: /app
command: sh -c "id && touch created-by-container"
Then export variables:
export UID GID
UID=$(id -u) GID=$(id -g) docker compose up
Or create a .env file (Compose reads it automatically):
printf "UID=%s\nGID=%s\n" "$(id -u)" "$(id -g)" > .env
docker compose up
Trade-offs:
- Works great for dev workflows.
- Some images expect to run as a specific user and may break if you override
user:(especially database images). In that case, prefer one of the ownership initialization approaches below.
5.3 Fix: chown the host directory to match the container user
Many images run as a dedicated user, e.g. www-data (UID 33), node (often UID 1000), postgres (often UID 999), etc.
First, find the UID/GID used in the image:
docker run --rm --entrypoint sh postgres:16 -c 'id postgres'
docker run --rm --entrypoint sh nginx:alpine -c 'id nginx || id www-data || true'
docker run --rm --entrypoint sh node:20-alpine -c 'id node'
Example output might be:
uid=999(postgres) gid=999(postgres) groups=999(postgres)
Then on the host, set ownership of the directory you mount:
sudo chown -R 999:999 ./pgdata
sudo chmod -R u+rwX ./pgdata
Now run the container with the bind mount:
docker run --rm -v "$PWD/pgdata:/var/lib/postgresql/data" postgres:16
Important notes:
chown -Rcan be expensive on large directories.- If you are mounting a directory that contains source code, changing ownership to a service UID might be annoying for your editor. Prefer running as your UID for source code mounts, and use a separate directory for service data.
5.4 Fix: use ACLs (fine-grained, safer than chmod 777)
A common “fix” is chmod -R 777, but that makes the directory writable by anyone and can create security and correctness issues.
A better approach is POSIX ACLs: grant a specific UID (or group) access without changing ownership.
Install ACL tools if needed:
- Debian/Ubuntu:
sudo apt-get update && sudo apt-get install -y acl - Fedora:
sudo dnf install -y acl
Grant a container UID (example: 999) read/write/execute on a directory:
sudo setfacl -m u:999:rwx ./pgdata
sudo setfacl -d -m u:999:rwx ./pgdata
-mmodifies current ACL.-d -msets default ACL so new files inherit permissions.
Verify:
getfacl -p ./pgdata
This is especially useful when:
- You want to keep host ownership as your user.
- You need to allow a service user in the container to write.
- You want new files to inherit the right permissions automatically.
5.5 Fix: set group permissions and use a shared GID
Another robust pattern is to make a directory group-writable and run the container with a matching group.
- Create a group on the host (optional) and add yourself:
sudo groupadd -f dockershare
sudo usermod -aG dockershare "$USER"
newgrp dockershare
- Make the directory owned by that group and group-writable, with the setgid bit so new files inherit the group:
sudo chown -R "$USER":dockershare ./shared
sudo chmod -R 2775 ./shared
# 2 in 2775 sets the setgid bit on directories
- Run the container with that GID:
GID_DOCKERSHARE=$(getent group dockershare | cut -d: -f3)
docker run --rm -it \
-u "$(id -u):$GID_DOCKERSHARE" \
-v "$PWD/shared:/shared" \
alpine sh -c 'id; touch /shared/from-container'
This works well for teams: everyone in the group can collaborate on the same directory.
5.6 SELinux/AppArmor notes (Linux-specific)
On SELinux-enabled systems (Fedora, RHEL, CentOS with SELinux enforcing), you can get permission errors even when Unix permissions look correct.
Symptoms:
- Directory permissions look fine.
- Container still gets
permission deniedon bind mounts.
For Docker/Podman with SELinux, you often need to relabel the mount:
- With Docker, use
:zor:Zon the volume:docker run --rm -v "$PWD/data:/data:Z" alpine touch /data/x:Zgives a private label for one container.:zgives a shared label for multiple containers.
In Compose:
services:
app:
volumes:
- ./data:/data:Z
AppArmor (common on Ubuntu) can also restrict operations, but bind-mount write failures are more commonly SELinux-related. If you suspect AppArmor, check:
sudo aa-status
dmesg | tail -n 50
6. Linux fixes (named volumes)
Named volumes are managed by Docker and stored under Docker’s data directory (commonly /var/lib/docker/volumes/... on Linux). They’re often easier because you can initialize permissions in a controlled way.
6.1 Inspect the volume location
docker volume create myvol
docker volume inspect myvol
Example output includes "Mountpoint":
[
{
"Name": "myvol",
"Mountpoint": "/var/lib/docker/volumes/myvol/_data"
}
]
You generally should not manually edit files there as your primary workflow, but it’s useful for debugging.
6.2 Fix: initialize volume ownership via a one-shot container
A standard technique: run a temporary container as root that chowns the volume directory, then run the real service as its normal user.
Example for Postgres (UID/GID 999 in many images):
docker volume create pgdata
# Initialize ownership (one-shot)
docker run --rm \
-v pgdata:/var/lib/postgresql/data \
--entrypoint sh \
postgres:16 -c 'chown -R 999:999 /var/lib/postgresql/data'
# Now run normally
docker run --rm \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:16
This is safer than changing ownership of host directories and keeps your project tree clean.
If you don’t know the UID/GID, query it:
docker run --rm --entrypoint sh postgres:16 -c 'id -u postgres; id -g postgres'
6.3 Fix: use image-supported environment variables (e.g., postgres)
Some images provide variables or entrypoint logic to fix permissions automatically. Always check the image documentation.
Examples:
- Official
postgresimage initializes/var/lib/postgresql/dataand expects it to be writable by thepostgresuser. With named volumes, it typically works out of the box. - Some images accept
PUID/PGID(common in LinuxServer.io images) to run as a specific user matching the host.
For LinuxServer.io style images:
docker run --rm \
-e PUID="$(id -u)" \
-e PGID="$(id -g)" \
-v "$PWD/config:/config" \
lscr.io/linuxserver/someimage:latest
If the image supports it, this is one of the cleanest fixes for bind mounts.
7. macOS fixes (Docker Desktop)
macOS is different because Docker typically runs inside a Linux VM (Docker Desktop). Your “host” filesystem is macOS, but the container sees it through a file sharing layer.
7.1 Understand why macOS behaves differently
On macOS:
- Containers run in a Linux VM.
- Bind mounts from your Mac (e.g.,
./project:/app) are provided via a sharing mechanism. - UID/GID mapping and permission semantics can be surprising:
- A container writing as root might create files that appear owned by you on macOS, or sometimes with odd permissions.
- Some operations like
chownon bind mounts may not behave like native Linux.
The good news: many Linux permission fixes still apply conceptually, but the most reliable macOS approach is often to prefer named volumes for service data and use bind mounts mainly for source code.
7.2 Fix: prefer named volumes for writable data
Instead of bind-mounting a database directory from your project, use a named volume:
docker volume create pgdata
docker run --rm \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
In Compose:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata:
This avoids macOS file sharing permission quirks for database workloads (which also improves performance).
7.3 Fix: use Docker Desktop file sharing correctly
If you bind-mount a path that Docker Desktop is not allowed to share, you can see errors that resemble permission issues.
Check Docker Desktop settings:
- Docker Desktop → Settings → Resources → File Sharing
- Ensure your project directory (or its parent, like
/Users/yourname) is listed.
Then restart Docker Desktop if needed.
Test with a simple container:
docker run --rm -v "$PWD:/mnt" alpine sh -c 'ls -la /mnt; touch /mnt/testfile'
If touch fails, confirm:
- The directory is shared in Docker Desktop.
- The directory isn’t protected by macOS privacy controls (e.g., Desktop/Documents restrictions in some setups).
7.4 Fix: avoid root-owned artifacts on the host
Even on macOS, it’s a good habit to run dev containers as your UID/GID when they write into bind mounts.
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" \
-w /app \
node:20-alpine sh -c 'npm ci'
This reduces the chance of generating files that your tools can’t modify later.
If you already have problematic files, reset ownership on macOS:
sudo chown -R "$(id -un)":"$(id -gn)" .
And fix permissions (careful with what you change):
chmod -R u+rwX .
8. Docker Compose patterns that prevent permission issues
Pattern A: Pass host UID/GID into the container (dev-friendly)
services:
app:
image: node:20-alpine
user: "${UID}:${GID}"
working_dir: /app
volumes:
- ./:/app
command: sh -c "npm ci && npm test"
Create .env:
printf "UID=%s\nGID=%s\n" "$(id -u)" "$(id -g)" > .env
docker compose up --build
Pattern B: Use named volumes for data, bind mounts for code
services:
web:
image: nginx:alpine
volumes:
- ./site:/usr/share/nginx/html:ro
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Pattern C: Init container to set permissions (Compose)
Compose doesn’t have “init containers” exactly like Kubernetes, but you can emulate it with a one-shot service:
services:
fix-perms:
image: postgres:16
entrypoint: ["sh", "-c", "chown -R 999:999 /var/lib/postgresql/data"]
volumes:
- pgdata:/var/lib/postgresql/data
restart: "no"
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
depends_on:
- fix-perms
volumes:
pgdata:
Run:
docker compose run --rm fix-perms
docker compose up db
9. Common scenarios and exact fixes
9.1 Node.js / npm permission denied
Symptom:
EACCES: permission denied, mkdir '/.npm'EACCESwriting tonode_moduleson a bind mount
Cause:
- Container runs as
node(non-root) or root, but the bind-mounted directory permissions don’t match. - npm cache directory points to a location not writable by the current user.
Fix 1: run as your UID/GID
docker run --rm -it \
-u "$(id -u):$(id -g)" \
-v "$PWD:/app" -w /app \
node:20-alpine sh -c "npm ci"
Fix 2: set npm cache to a writable directory
docker run --rm -it \
-u "$(id -u):$(id -g)" \
-e NPM_CONFIG_CACHE=/tmp/.npm \
-v "$PWD:/app" -w /app \
node:20-alpine sh -c "npm ci"
Fix 3: avoid bind-mounting node_modules
A common pattern is to bind-mount source but keep node_modules in a named volume:
services:
app:
image: node:20-alpine
working_dir: /app
volumes:
- ./:/app
- node_modules:/app/node_modules
command: sh -c "npm ci && npm run dev"
volumes:
node_modules:
This reduces permission conflicts and can improve performance on macOS.
9.2 Postgres “could not open file” / “permission denied”
Symptom:
FATAL: data directory "/var/lib/postgresql/data" has wrong ownershipPermission deniedcreating files in the data directory
Cause:
- The data directory is bind-mounted from the host and owned by the wrong UID/GID.
Fix (Linux bind mount):
- Find postgres UID/GID:
docker run --rm --entrypoint sh postgres:16 -c 'id postgres'
- Apply ownership on host:
sudo chown -R 999:999 ./pgdata
sudo chmod -R u+rwX ./pgdata
- Run:
docker run --rm \
-e POSTGRES_PASSWORD=secret \
-v "$PWD/pgdata:/var/lib/postgresql/data" \
postgres:16
Fix (macOS recommended): use named volume
docker volume create pgdata
docker run --rm \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
9.3 Nginx cannot write cache/temp
Symptom:
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)
Cause:
- You mounted
/var/cache/nginxor similar paths with restrictive permissions. - Or you run nginx as non-root and its cache directories aren’t writable.
Fix: ensure writable cache dirs or don’t mount them If you need persistence, create a directory and set ownership to nginx user (varies by image).
Check nginx user:
docker run --rm --entrypoint sh nginx:alpine -c 'id nginx || id www-data || true'
Then on Linux:
mkdir -p nginx-cache
sudo chown -R 101:101 nginx-cache 2>/dev/null || true
# If user is different, adjust UID/GID accordingly.
Run:
docker run --rm -p 8080:80 \
-v "$PWD/nginx-cache:/var/cache/nginx" \
nginx:alpine
If you don’t need the cache persisted, remove the mount and let it use container filesystem.
9.4 “Permission denied” when writing build artifacts
Symptom:
- A build container writes
dist/ortarget/and files become unwritable on host, or build fails.
Cause:
- Container runs as root and creates root-owned files in a bind mount.
- Or container runs as non-root and cannot write to the mounted directory.
Fix: run as your UID/GID
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/work" -w /work \
golang:1.22 sh -c 'go build ./...'
If you already have root-owned artifacts:
sudo chown -R "$(id -u):$(id -g)" dist target build 2>/dev/null || true
10. Anti-patterns to avoid
Avoid chmod -R 777
It “works” by making everything writable by everyone, but:
- It’s a security risk.
- It can mask real ownership problems.
- It can break apps that expect stricter permissions.
If you must temporarily unblock yourself, do it narrowly:
chmod u+rwX,g+rwX ./some-dir
Or use ACLs.
Avoid bind-mounting database directories on macOS (when possible)
It’s often slower and more error-prone than named volumes.
Avoid chown inside the container on bind mounts as a primary strategy
On Linux it can work, but it’s easy to accidentally chown huge trees, and on macOS it may not behave as expected. Prefer host-side ownership fixes or named volumes.
11. A systematic checklist
When you see “Permission denied” with Docker volumes, run through this checklist:
-
What mount type is it?
- Bind mount (
Type: bind) → host permissions matter directly. - Named volume (
Type: volume) → initialize permissions inside volume.
- Bind mount (
-
What user is the container running as?
docker exec -it <container> sh -c 'id' -
What are the permissions on the mounted path (inside container)?
docker exec -it <container> sh -c 'ls -ld /path && ls -la /path | head' -
On Linux bind mounts: do UID/GID match?
- Host:
ls -ld ./path id - Compare with container
id.
- Host:
-
If SELinux is enforcing: did you relabel?
- Try
:Zon the mount.
- Try
-
Pick the least invasive fix:
- For dev source mounts: run container as your UID/GID.
- For service data: use named volumes or chown the data directory to the service UID.
- For shared directories: group + setgid or ACLs.
-
Verify with a minimal write test:
docker run --rm -v "$PWD/path:/mnt" alpine sh -c 'touch /mnt/.permtest && ls -la /mnt/.permtest'
Closing note
The “right” solution depends on what you’re mounting:
- Source code: usually best with bind mounts +
user: "${UID}:${GID}". - Databases and stateful services: usually best with named volumes and (if needed) a one-shot permission initialization.
- Shared writable folders: use ACLs or a shared group rather than opening permissions broadly.
If you share the exact error message, your docker run/Compose snippet, and whether it’s Linux or macOS, you can pinpoint the cleanest fix quickly.