Understanding the WordPress Docker “403 Forbidden” .htaccess Error on macOS: A Deep Dive

Mudos Digital Mudos Digital
19 min read

Executive Summary

When deploying a WordPress site using Docker Compose on macOS, developers frequently encounter a frustrating 403 Forbidden error with the message: “Server unable to read htaccess file, denying access to be safe.” This issue is particularly maddening because the .htaccess file exists, has the correct permissions, and works perfectly on production servers or Linux systems. This comprehensive guide explains exactly why this happens and how to fix it permanently.


Part 1: Understanding the Root Causes

Root Cause #1: Apache’s AllowOverride Configuration

What is AllowOverride?

Apache’s AllowOverride directive is a security feature that controls whether the server will process directives found in .htaccess files. Think of it as a gatekeeper: even if a .htaccess file exists on disk with perfect syntax, Apache simply refuses to read it if AllowOverride is set to None.

The Default Problem

In most standard Apache installations (including official Docker images like php:apache), the default Apache configuration looks like this:

<Directory /var/www/>
    Options Indexes FollowSymLinks
    AllowOverride None  # ← THE CULPRIT
    Require all granted
</Directory>

Why This Creates a 403 Error

When AllowOverride None is set, Apache behaves defensively:

  1. A request comes in for a URL (e.g., /blog/my-post/)
  2. Apache looks for the file /var/www/html/blog/my-post/ – it doesn’t exist
  3. WordPress uses .htaccess URL rewriting to rewrite this to index.php?p=123
  4. But Apache refuses to read the .htaccess file because AllowOverride None
  5. Apache has no rewrite rules to apply
  6. Apache returns a generic 403 Forbidden error

The Security Reasoning

Apache’s developers set AllowOverride None as a default for security reasons:

  • If a malicious actor gains write access to a .htaccess file, they could modify it to execute code, bypass authentication, or redirect traffic
  • By requiring AllowOverride to be explicitly enabled in the main Apache configuration (which typically requires root/admin access), the security burden is moved to a single, more protected location
  • This prevents unauthorized .htaccess files from modifying server behavior

The WordPress Problem

WordPress absolutely depends on .htaccess file rewriting for several critical features:

  • Pretty Permalinks: Converting /blog/my-post/ into /index.php?p=123
  • Category/Tag URLs: Handling /category/technology/ URLs
  • Hiding index.php: Making URLs cleaner
  • REST API routing: Rewriting requests to /wp-json/ endpoints
  • Password protection: Certain security-related features rely on .htaccess

Root Cause #2: Missing mod_rewrite Module

What is mod_rewrite?

mod_rewrite is an Apache module that provides URL rewriting functionality. It processes the rewrite rules contained in .htaccess files. Even if Apache reads your .htaccess file, it cannot execute the rewrite rules without this module.

Why It’s Often Missing

Many minimal Docker images (including Alpine Linux-based PHP images) don’t include mod_rewrite by default to reduce image size. The assumption is that developers will add what they need.

The Cascading Failure

This is a classic two-layer problem:

  1. Layer 1: Apache doesn’t read .htaccess (AllowOverride issue) → 403 error
  2. Layer 2: Even if Layer 1 is fixed, if mod_rewrite isn’t enabled, the rewrite rules in .htaccess won’t execute

Both problems can exist simultaneously and must both be fixed for WordPress to function correctly.


Root Cause #3: macOS Docker Bind Mount File Locking Issues

Understanding the Architecture

This is where things get truly complex. Let’s understand how Docker works on macOS:

macOS (Darwin) Kernel
    ↓
Docker Desktop for Mac (native application)
    ↓
Linux VM (using Hypervisor Framework)
    ↓
Docker containers (Linux processes)

Docker is fundamentally a Linux technology. On Linux, running Docker is native because the kernel is Linux. On macOS, Docker Desktop creates a lightweight Linux virtual machine using the Hypervisor Framework and runs containers inside that VM.

The File System Bridging Problem

When you use a bind mount on macOS:

volumes:
  - ./wordpress:/var/www/html

Here’s what actually happens:

  1. Docker Desktop intercepts the mount request
  2. It cannot simply mount the macOS filesystem into the Linux VM (different kernels, different filesystems – APFS vs ext4)
  3. Instead, Docker Desktop uses a file sharing mechanism to synchronize files between macOS and the Linux VM
  4. This mechanism has changed over Docker versions: originally it used osxfs, then moved to gRPC FUSE, now uses VirtioFS

The File Locking Issue

Different operating systems have fundamentally different file locking semantics:

macOS (BSD-style locking):

  • Uses BSD advisory locks
  • Locks are “advisory” – a process can ignore them
  • Multiple readers can hold locks simultaneously
  • Locks are more permissive

Linux (POSIX-style locking):

  • Uses POSIX mandatory locks
  • Locks are enforced by the kernel
  • More restrictive behavior
  • Different lock conflict resolution

When VirtioFS (or previous mechanisms) tries to bridge these two systems, conflicts arise.

When the Deadlock Occurs

The specific error message in the document reveals the exact moment:

[core:alert] /var/www/html/.htaccess: Error reading /var/www/html/.htaccess 
at line 1: Resource deadlock avoided

This occurs when:

  1. PHP processes (FPM, running in the container) execute code that reads files from /var/www/html/
  2. Apache processes simultaneously try to read the same files
  3. Multiple requests come in at the same time, each trying to access .htaccess and other files
  4. The file sharing mechanism tries to enforce consistency between macOS and Linux locking semantics
  5. It detects a potential deadlock condition (circular lock dependencies)
  6. To be safe, it reports “Resource deadlock avoided” and denies access

Common Patterns That Trigger This:

  • WordPress loading plugins (many require statements)
  • WordPress theme functions.php loading (file includes)
  • Repeated file_get_contents() calls on the same files
  • Multiple HTTP requests hitting the server simultaneously
  • File modification triggers (especially plugin/theme updates through WordPress admin)

Why This Doesn’t Happen on Linux

On Linux servers (your production environment), the filesystem is native ext4, the kernel is native Linux, and all locking semantics are consistent. There’s no translation layer causing conflicts.

On your macOS development machine with Docker, the translation layer introduces this fragility.


Part 2: Detailed Solutions and Implementations

Solution 1: Fixing Apache Configuration in the Dockerfile

What We’re Doing

We’re making two Apache configuration changes inside the Dockerfile so they apply automatically whenever the container starts:

  1. Enabling the mod_rewrite module
  2. Changing AllowOverride None to AllowOverride All

The Dockerfile Solution

# At the appropriate place in your Dockerfile (typically after the base image)
# This works for the official PHP Apache images

RUN a2enmod rewrite && \
    sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf

Breaking Down the Commands

Command 1: a2enmod rewrite

a2enmod rewrite
  • a2enmod stands for “Apache 2 enable module”
  • This enables the mod_rewrite Apache module
  • It works by creating a symbolic link in Apache’s mods-enabled directory
  • The module must be present in the Apache installation (it usually is in official PHP images)

Command 2: The sed replacement

sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf

Let’s break this down piece by piece:

  • sed -i: Stream editor with in-place file modification
  • '/<Directory \/var\/www\/>/,/<\/Directory>/': Address range – this tells sed to only apply the following command between the line containing <Directory /var/www/> and the line containing </Directory>
  • s/AllowOverride None/AllowOverride All/: Substitute command – replace the first occurrence of “AllowOverride None” with “AllowOverride All”

What Gets Changed

Before:

<Directory /var/www/>
    Options Indexes FollowSymLinks
    AllowOverride None
    Require all granted
</Directory>

After:

<Directory /var/www/>
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

Security Considerations

You might wonder: if AllowOverride None is a security best practice, are we making our Docker container less secure by changing it to AllowOverride All?

The answer is nuanced:

  • In production, ideally you’d want AllowOverride Limit or AllowOverride FileInfo (more restrictive than All)
  • However, for Docker containers, the security model is different:
    • The container filesystem is ephemeral and isolated
    • Only your code (or code you explicitly add) exists in the container
    • There’s no concept of “untrusted users” with write access to the WordPress directory
    • The container is typically run as a non-root user anyway
  • A more secure but slightly more complex approach:
RUN a2enmod rewrite && \
    sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride FileInfo/' /etc/apache2/apache2.conf

AllowOverride FileInfo permits only certain safe directives in .htaccess files (like mod_rewrite rules) but not dangerous ones (like php_value directives).

Complete Example Dockerfile

FROM php:8.2-apache

# Install PHP extensions needed for WordPress
RUN apt-get update && apt-get install -y \
    libpng-dev \
    libjpeg-dev \
    libfreetype6-dev \
    mysql-client \
    git \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-configure gd --with-freetype --with-jpeg && \
    docker-php-ext-install gd mysqli

# Enable Apache modules and configure AllowOverride
RUN a2enmod rewrite && \
    sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf

# Set up WordPress directory
WORKDIR /var/www/html

Solution 2: Using Named Volumes Instead of Bind Mounts

Understanding the Difference

Bind Mounts:

volumes:
  - ./wordpress:/var/www/html
  • Maps a directory from your macOS machine directly into the container
  • Changes on macOS appear in the container (through the file sharing mechanism)
  • Changes in the container appear on macOS (through the file sharing mechanism)
  • The file sharing mechanism causes the locking issues we discussed

Named Volumes:

volumes:
  - wordpress_files:/var/www/html

volumes:
  wordpress_files:
  • Creates a managed volume inside the Docker Desktop’s Linux VM
  • The volume exists only within Docker’s VM, not synced with macOS
  • Eliminates the file sharing mechanism entirely
  • No locking conflicts between macOS and Linux semantics

Why Named Volumes Solve the Problem

By using named volumes, you remove the macOS → Linux file system translation layer entirely. Here’s the comparison:

With Bind Mounts (Problematic):

macOS filesystem (APFS)
    ↓ [VirtioFS Translation + Locking]
Linux VM filesystem (ext4)
    ↓
Container process trying to read .htaccess
    ↓
Locking conflict detection
    ↓
"Resource deadlock avoided"

With Named Volumes (Solved):

Linux VM filesystem (ext4)
    ↓
Container process trying to read .htaccess
    ↓
Normal Linux file locking
    ↓
✅ Works perfectly

Docker Compose Configuration

Before (Problematic):

version: '3.8'

services:
  wordpress:
    image: php:8.2-apache
    container_name: wordpress
    ports:
      - "8000:80"
    volumes:
      - ./wordpress:/var/www/html  # ❌ Bind mount
      - ./wp-config.php:/var/www/html/wp-config.php
    environment:
      - WORDPRESS_DB_HOST=mysql
      - WORDPRESS_DB_NAME=wordpress

After (Fixed):

version: '3.8'

services:
  wordpress:
    image: php:8.2-apache
    container_name: wordpress
    ports:
      - "8000:80"
    volumes:
      - wordpress_files:/var/www/html  # ✅ Named volume
    environment:
      - WORDPRESS_DB_HOST=mysql
      - WORDPRESS_DB_NAME=wordpress

  mysql:
    image: mysql:8.0
    container_name: wordpress-db
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=wordpress

volumes:
  wordpress_files:
  mysql_data:

Trade-offs Analysis

Advantages of Named Volumes:

  • ✅ Eliminates file locking issues completely
  • ✅ Better performance (no translation layer overhead)
  • ✅ Works consistently across Windows, macOS, Linux
  • ✅ Docker manages volume lifecycle
  • ✅ Easy to backup: docker run --rm -v wordpress_files:/data -v $(pwd):/backup ubuntu tar czf /backup/wordpress.tar.gz -C /data .

Disadvantages of Named Volumes:

  • ❌ Files are stored inside Docker Desktop’s Linux VM
  • ❌ Can’t easily edit files from macOS (can’t open in VS Code directly)
  • ❌ Requires docker cp or docker exec to access files
  • ❌ Slightly more complex workflow during development

Accessing Files in Named Volumes

Method 1: Using docker exec to run editor inside container

docker exec -it wordpress bash
cd /var/www/html
nano wp-config.php

Method 2: Using docker cp to copy files

# Copy FROM container TO macOS
docker cp wordpress:/var/www/html/wp-config.php ./wp-config.php

# Edit on macOS, then copy back
docker cp ./wp-config.php wordpress:/var/www/html/wp-config.php

Method 3: Using docker exec with a text editor command

docker exec wordpress bash -c 'cat > /var/www/html/test.txt << EOF
Your content here
EOF'

Solution 3: Using Mutagen for Live File Synchronization (Alternative)

What is Mutagen?

Mutagen is a tool specifically designed for development workflows that need the flexibility of bind mounts but without the file locking issues. It’s a file synchronization system that runs alongside Docker.

How Mutagen Works

Instead of the kernel’s file sharing mechanism, Mutagen:

  1. Runs as a background daemon
  2. Uses intelligent bidirectional sync (not just copying)
  3. Understands file changes on both sides
  4. Syncs changes when it detects them
  5. Handles conflicts intelligently
  6. Works around the file locking issues because it doesn’t use real-time kernel integration

Installation

# Using Homebrew on macOS
brew install mutagen
brew install mutagen-compose  # For Docker Compose integration

Configuration File (mutagen.yml)

Create a mutagen.yml file in your project root:

# Mutagen configuration for WordPress development

sync:
  # Default synchronization settings
  defaults:
    mode: "two-way-resolved"  # Bidirectional sync, auto-resolve conflicts
    
  # WordPress core files and uploads
  wordpress-files:
    alpha: "./wordpress"  # Your local macOS directory
    beta: "docker://wordpress/var/www/html"  # Container target
    
    # What to sync
    includes:
      - "/"  # Sync everything by default
    
    # What NOT to sync
    ignores:
      - ".git"
      - ".gitignore"
      - ".DS_Store"
      - "node_modules"
      - "vendor"
      - "wp-content/cache"
      - "wp-content/backup"
    
    # Watch for changes and sync automatically
    watch-mode: "portable"  # Works cross-platform
    watch-poll-interval: 5  # Poll every 5 seconds
    
  # Optional: Just sync your theme if you have massive uploads
  theme-only:
    alpha: "./wordpress/wp-content/themes/mudos-theme"
    beta: "docker://wordpress/var/www/html/wp-content/themes/mudos-theme"
    
    ignores:
      - ".DS_Store"
      - ".git"

Using Mutagen

# Start Mutagen sync
mutagen project start

# Check sync status
mutagen project status

# Watch for sync issues
mutagen project monitor

# Stop Mutagen
mutagen project stop

# View detailed logs
mutagen project logs

Comparing Approaches

ApproachSetupPerformanceFile AccessBest For
Bind MountSimple❌ Slow on macOS✅ DirectQuick testing (if it works)
Named VolumeModerate✅ Fast❌ Need docker execProduction-like setup
MutagenComplex✅ Fast✅ Direct + SyncedActive development

Part 3: Step-by-Step Implementation Guide

Complete Example: Fixed WordPress Docker Setup

Directory Structure

project/
├── docker-compose.yml
├── Dockerfile
├── mutagen.yml (optional)
└── wordpress/
    └── (WordPress files will be stored here or in named volume)

Step 1: Create the Dockerfile

# Dockerfile
FROM php:8.2-apache

# Install system dependencies
RUN apt-get update && apt-get install -y \
    libpng-dev \
    libjpeg-dev \
    libfreetype6-dev \
    mysql-client \
    libmcrypt-dev \
    zip \
    unzip \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg && \
    docker-php-ext-install -j$(nproc) \
    gd \
    mysqli \
    pdo \
    pdo_mysql \
    json

# Copy PHP configuration
COPY php.ini /usr/local/etc/php/conf.d/php.ini

# Enable Apache modules
RUN a2enmod rewrite && \
    a2enmod headers && \
    a2enmod http2

# Fix Apache configuration for .htaccess
RUN sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf

# Set working directory
WORKDIR /var/www/html

# Set proper permissions
RUN chown -R www-data:www-data /var/www/html

Step 2: Create php.ini for WordPress

# php.ini
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 256M
max_execution_time = 300
default_charset = UTF-8

Step 3: Create Docker Compose File (Named Volumes Approach)

# docker-compose.yml
version: '3.8'

services:
  wordpress:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: mudos-wordpress
    restart: unless-stopped
    ports:
      - "8000:80"
      - "443:443"
    volumes:
      - wordpress_files:/var/www/html
      - ./ssl:/etc/apache2/ssl:ro  # Optional: SSL certificates
    environment:
      - WORDPRESS_DB_HOST=mysql
      - WORDPRESS_DB_NAME=wordpress
      - WORDPRESS_DB_USER=wordpress
      - WORDPRESS_DB_PASSWORD=wordpress_pass
      - WORDPRESS_AUTH_KEY=your-key-here
      - WORDPRESS_SECURE_AUTH_KEY=your-key-here
    depends_on:
      - mysql
    networks:
      - wordpress-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  mysql:
    image: mysql:8.0
    container_name: mudos-mysql
    restart: unless-stopped
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=root_password
      - MYSQL_DATABASE=wordpress
      - MYSQL_USER=wordpress
      - MYSQL_PASSWORD=wordpress_pass
    networks:
      - wordpress-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  phpmyadmin:
    image: phpmyadmin:latest
    container_name: mudos-phpmyadmin
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      - PMA_HOST=mysql
      - PMA_USER=wordpress
      - PMA_PASSWORD=wordpress_pass
    depends_on:
      - mysql
    networks:
      - wordpress-network

volumes:
  wordpress_files:
    driver: local
  mysql_data:
    driver: local

networks:
  wordpress-network:
    driver: bridge

Step 4: Initialize Everything

# Navigate to project directory
cd your-project

# Build the Docker image
docker compose build --no-cache

# Start all services
docker compose up -d

# Check if services are running
docker compose ps

# View logs to debug any issues
docker compose logs -f wordpress

# Download WordPress (if using named volume)
docker exec mudos-wordpress bash -c 'cd /tmp && \
  wget https://wordpress.org/latest.tar.gz && \
  tar -xzf latest.tar.gz && \
  cp -r wordpress/* /var/www/html/ && \
  chown -R www-data:www-data /var/www/html'

# Or if you have WordPress locally, copy it
docker cp ./wordpress/. mudos-wordpress:/var/www/html/
docker exec mudos-wordpress chown -R www-data:www-data /var/www/html

Step 5: Verification

# Test HTTP response
curl -I http://localhost:8000/

# Should return something like:
# HTTP/1.1 301 Moved Permanently
# OR for fresh WordPress:
# HTTP/1.1 302 Found

# Check mod_rewrite is enabled
docker exec mudos-wordpress apache2ctl -M | grep rewrite
# Output: rewrite_module (shared)

# Check AllowOverride setting
docker exec mudos-wordpress grep -A 5 '<Directory /var/www/>' /etc/apache2/apache2.conf
# Should show: AllowOverride All

# Check .htaccess is readable
docker exec mudos-wordpress ls -la /var/www/html/.htaccess

# Test WordPress is responding
docker exec mudos-wordpress curl -I http://localhost/

# Check for Apache errors
docker exec mudos-wordpress tail -20 /var/log/apache2/error.log

Part 4: Troubleshooting Guide

Issue: Still Getting 403 Error After Fixing

Diagnostic Steps:

# Step 1: Verify the Dockerfile changes applied
docker exec wordpress grep AllowOverride /etc/apache2/apache2.conf
# Should show: AllowOverride All (not None)

# Step 2: Check if you actually rebuilt
docker images
# Your image should have a recent timestamp

# Step 3: Verify the container is using the new image
docker ps
# Check the image column

# Step 4: Check Apache error log for specific error
docker exec wordpress tail -50 /var/log/apache2/error.log

Solution: If AllowOverride still shows “None”, you didn’t rebuild. Run:

docker compose down
docker compose build --no-cache
docker compose up -d

Issue: .htaccess Still Says Permission Denied

Possible Causes:

  1. File ownership issue: docker exec wordpress ls -la /var/www/html/.htaccess # Should show: -rw-r--r-- www-data:www-data Fix: docker exec wordpress chown www-data:www-data /var/www/html/.htaccess docker exec wordpress chmod 644 /var/www/html/.htaccess
  2. Directory permissions issue: docker exec wordpress ls -la /var/www/html/ | head -1 # Should show drwxr-xr-x Fix: docker exec wordpress chmod 755 /var/www/html
  3. Still using bind mount: This is the most common issue. If you’re still using ./wordpress:/var/www/html, switch to named volumes.

Issue: WordPress Admin Dashboard Loads But Frontend Shows 404

This is a classic mod_rewrite problem:

# Verify mod_rewrite is actually enabled
docker exec wordpress apache2ctl -M | grep rewrite

# If not present, rebuild didn't work
# If present, check that .htaccess exists
docker exec wordpress cat /var/www/html/.htaccess

Possible .htaccess content:

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

If .htaccess is empty or missing, WordPress didn’t install correctly.

Issue: Performance is Still Slow

If you’re using Mutagen:

# Check sync status
mutagen project status

# If stuck, force a resync
mutagen project pause
mutagen project resume

# Monitor detailed activity
mutagen project monitor

If using named volumes: Performance should be good. If slow:

# Check Docker Desktop stats
# In Docker Desktop UI, look at CPU and Memory usage

# Restart Docker
docker compose restart wordpress

Issue: Files Modified in Container Don’t Appear on macOS (With Named Volumes)

This is expected behavior. Use docker cp:

# Copy modified file from container to macOS
docker cp wordpress:/var/www/html/wp-config.php ./wp-config.php

# Edit on macOS, then copy back
nano wp-config.php
docker cp ./wp-config.php wordpress:/var/www/html/wp-config.php

Or use docker exec with inline editing:

docker exec wordpress bash -c 'sed -i "s/old_value/new_value/g" /var/www/html/wp-config.php'

Part 5: Production Considerations

Should You Use These Solutions in Production?

Short answer: Partially.

Production Setup Should:

✅ Use AllowOverride FileInfo (not All) – more secure ✅ Use named volumes ✅ NOT use bind mounts ✅ Enable SSL/HTTPS ✅ Use environment-specific configs ✅ Implement proper backup strategy

Production Should NOT:

❌ Use Mutagen (adds unnecessary overhead) ❌ Have direct file editing capability ❌ Have debug mode enabled ❌ Expose phpmyadmin

Production-Ready Dockerfile

FROM php:8.2-apache

# Install dependencies
RUN apt-get update && apt-get install -y \
    libpng-dev \
    libjpeg-dev \
    libfreetype6-dev \
    mysql-client \
    && rm -rf /var/lib/apt/lists/*

# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg && \
    docker-php-ext-install gd mysqli pdo pdo_mysql

# Security: Use FileInfo instead of All
RUN a2enmod rewrite && \
    sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride FileInfo/' /etc/apache2/apache2.conf && \
    a2enmod ssl

# Copy WordPress (assuming you build context includes WordPress)
COPY wordpress/ /var/www/html/

# Set proper permissions (non-root user)
RUN chown -R www-data:www-data /var/www/html && \
    chmod -R 755 /var/www/html

WORKDIR /var/www/html

Production Docker Compose (Simplified)

version: '3.8'

services:
  wordpress:
    build: .
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - wordpress_files:/var/www/html
      - ./ssl:/etc/apache2/ssl:ro
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_NAME: wordpress
    depends_on:
      - mysql

  mysql:
    image: mysql:8.0
    restart: always
    volumes:
      - mysql_data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress

volumes:
  wordpress_files:
  mysql_data:

Summary: What We Learned

The Three Root Causes:

  1. Apache Configuration: AllowOverride None prevents Apache from reading .htaccess
  2. Missing Modules: mod_rewrite isn’t enabled to process rewrite rules
  3. macOS Virtualization: File sharing mechanism causes locking conflicts

The Three Solutions:

  1. Fix Apache: Enable mod_rewrite and set AllowOverride All in Dockerfile
  2. Use Named Volumes: Eliminate the macOS file sharing layer entirely
  3. Alternative – Mutagen: Sync files intelligently without kernel integration

The Key Takeaway:

The 403 error isn’t actually about file permissions or missing files—it’s about Apache’s security policy preventing it from reading your .htaccess file, combined with macOS’s virtualization layer creating file locking conflicts. Both problems must be addressed for WordPress to work reliably in Docker on macOS.

Next Steps:

  1. Update your Dockerfile with the Apache configuration fix
  2. Convert bind mounts to named volumes in your docker-compose.yml
  3. Rebuild and restart: docker compose build --no-cache && docker compose up -d
  4. Verify the fix works with the provided curl and docker exec commands
  5. For active development, consider adding Mutagen

Additional Resources:

Share this Post

Let's Build Something Great Together

Have a project in mind or just want to say hello? We'd love to hear from you. Fill out the form and we'll get back to you as soon as possible.

Fast response times
Free project consultation

Or Contact Us Directly