← Back to Tutorials

Fix Permission Denied Errors in Docker Volumes on Linux and macOS

dockerdocker-volumespermissionslinuxmacosbind-mountsuid-giddocker-desktopdevopstroubleshooting

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

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:

  1. 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.
  2. 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.
  3. 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:

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:

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

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:

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:

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:


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:


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:

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

Verify:

getfacl -p ./pgdata

This is especially useful when:


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.

  1. Create a group on the host (optional) and add yourself:
sudo groupadd -f dockershare
sudo usermod -aG dockershare "$USER"
newgrp dockershare
  1. 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
  1. 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:

For Docker/Podman with SELinux, you often need to relabel the mount:

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:

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:

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:

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:


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:

Cause:

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:

Cause:

Fix (Linux bind mount):

  1. Find postgres UID/GID:
docker run --rm --entrypoint sh postgres:16 -c 'id postgres'
  1. Apply ownership on host:
sudo chown -R 999:999 ./pgdata
sudo chmod -R u+rwX ./pgdata
  1. 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:

Cause:

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:

Cause:

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:

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:

  1. What mount type is it?

    • Bind mount (Type: bind) → host permissions matter directly.
    • Named volume (Type: volume) → initialize permissions inside volume.
  2. What user is the container running as?

    docker exec -it <container> sh -c 'id'
  3. What are the permissions on the mounted path (inside container)?

    docker exec -it <container> sh -c 'ls -ld /path && ls -la /path | head'
  4. On Linux bind mounts: do UID/GID match?

    • Host:
      ls -ld ./path
      id
    • Compare with container id.
  5. If SELinux is enforcing: did you relabel?

    • Try :Z on the mount.
  6. 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.
  7. 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:

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.