How to Fix Docker Mounts Denied on macOS and Manage Dev vs Prod Dockerfiles

docker macos dockerfile docker compose file sharing

The Docker "Mounts denied" error on macOS means Docker Desktop doesn't have permission to bind-mount the host path you specified. You fix it by adding the path to Docker Desktop's file sharing allowlist (Settings → Resources → File Sharing) or, on newer versions using VirtioFS, by granting Full Disk Access. For the dev-vs-production Dockerfile question, the best approach is a single multi-stage Dockerfile combined with separate Compose files that override behavior per environment.

Fixing "Mounts Denied: the Path Is Not Shared From OS X"

Docker Desktop for macOS only allows bind mounts from explicitly shared directories. The default allowlist includes /Users, /tmp, and /private. If your project lives outside those paths (for example, /opt/projects or an external drive) Docker rejects the mount. Open Docker Desktop → Settings → Resources → File Sharing, add your path, and click Apply & Restart. On macOS Ventura and later with the VirtioFS backend, you might also need to grant Docker Desktop Full Disk Access under System Settings → Privacy & Security.

You can verify the fix by running a quick mount test.

# Test that Docker can mount your project directory.
docker run --rm -v "$(pwd)":/app alpine ls /app

Symlinks and Case Sensitivity Gotchas

Even after adding a path, macOS symlinks can fool Docker. If /var is actually /private/var, you need the canonical path in the allowlist. Run realpath to find it. Another subtle issue: APFS is case-insensitive by default, but Linux containers expect case-sensitive paths. A file called Makefile and one called makefile are the same file on your Mac but would be two different files inside the container. This causes phantom "file not found" bugs when you deploy images built on macOS to Linux.

# Resolve the real canonical path before adding to Docker file sharing.
realpath /var/data
# Output: /private/var/data

Multi-Stage Dockerfile for Dev and Production

Don't maintain two separate Dockerfiles. A single multi-stage Dockerfile with a base stage, a development stage, and a production stage keeps everything in one place and avoids drift. You target a specific stage at build time with --target. The production stage copies your built artifacts into a minimal image, while the development stage installs dev tools, keeps source mounted via volumes, and runs a hot-reload server.

# Shared base with runtime dependencies.
FROM node:20-slim AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

# Development stage adds dev deps and a live-reload entrypoint.
FROM base AS development
RUN npm ci
CMD ["npx", "nodemon", "src/index.js"]

# Production stage copies only the built app into a clean image.
FROM base AS production
COPY . .
CMD ["node", "src/index.js"]

Docker Compose Overrides for Each Environment

Docker Compose natively supports layered configuration files. Put shared service definitions in docker-compose.yml, development-specific settings (bind mounts, exposed debug ports, the dev build target) in docker-compose.override.yml, and production settings in docker-compose.prod.yml. Compose automatically merges docker-compose.yml and docker-compose.override.yml when you run docker compose up with no -f flags, so development is the zero-config default.

# docker-compose.yml — shared base config.
services:
  api:
    build:
      context: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
# docker-compose.override.yml — auto-loaded in development.
services:
  api:
    build:
      target: development
    volumes:
      - ./src:/app/src
    environment:
      NODE_ENV: development
      DEBUG: "api:*"
    ports:
      - "9229:9229"
# docker-compose.prod.yml — explicitly loaded for production.
services:
  api:
    build:
      target: production
    restart: always
    deploy:
      resources:
        limits:
          memory: 512M

Running Each Environment

In development, run docker compose up --build. Compose picks up the override file automatically and builds the development target with your source bind-mounted. For production, pass both files explicitly to skip the override.

# Development — override is merged automatically.
docker compose up --build

# Production — skip override, use prod file instead.
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d

Key Pitfalls to Watch For

Bind mount performance on macOS: Even with VirtioFS, bind-mounting node_modules into a container is painfully slow. The fix is to use a named volume for node_modules so it lives inside the VM's filesystem, and only bind-mount your source code. Add - node_modules:/app/node_modules as a named volume in your dev override.

File ownership mismatches: On Linux hosts, files that the container's root user creates inside a bind mount are owned by root on the host. On macOS, Docker Desktop silently remaps this, which means your setup can work on a Mac and break in CI. Use user: "1000:1000" in your Compose service or set USER in the Dockerfile to match your CI runner's UID.

Don't use .env files for secrets in production. Compose .env files are convenient for development but store plaintext on disk. In production, use Docker secrets, your cloud provider's secret manager, or inject environment variables from your CI/CD pipeline.

← Back to all articles