Docker Compose for a Drupal Development Stack: Nginx, PHP-FPM, MariaDB

Running Drupal locally with a Docker Compose stack gives you a reproducible environment that matches production. Every team member gets the same PHP version, the same MariaDB version, and the same Nginx configuration — no more "works on my machine" debugging sessions. This guide builds a complete, production-mirroring stack from scratch: Nginx as the web server, PHP-FPM for request processing, MariaDB for the database, and optional Redis for caching.

The result is a stack you understand completely, unlike opaque tools that hide the configuration from you.


Project Structure

my-drupal-project/
├── docker-compose.yml
├── docker/
│   ├── nginx/
│   │   └── default.conf       # Nginx server block
│   └── php/
│       ├── Dockerfile          # Custom PHP-FPM image
│       └── php.ini             # PHP configuration overrides
├── web/                        # Drupal webroot (Composer project)
│   └── index.php
└── composer.json

The docker-compose.yml

version: "3.9"

services:

  # ── Nginx ─────────────────────────────────────────────────────────────────
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "8080:80"
    volumes:
      - ./web:/var/www/html/web:ro        # Drupal webroot (read-only)
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php
    networks:
      - drupal

  # ── PHP-FPM ───────────────────────────────────────────────────────────────
  php:
    build:
      context: ./docker/php
      dockerfile: Dockerfile
    volumes:
      - ./:/var/www/html                  # Full project root (Composer needs it)
      - ./docker/php/php.ini:/usr/local/etc/php/conf.d/custom.ini:ro
    environment:
      DB_HOST: mariadb
      DB_PORT: "3306"
      DB_NAME: drupal
      DB_USER: drupal
      DB_PASS: drupal
    depends_on:
      mariadb:
        condition: service_healthy
    networks:
      - drupal

  # ── MariaDB ───────────────────────────────────────────────────────────────
  mariadb:
    image: mariadb:10.11
    environment:
      MARIADB_ROOT_PASSWORD: root
      MARIADB_DATABASE: drupal
      MARIADB_USER: drupal
      MARIADB_PASSWORD: drupal
    volumes:
      - mariadb_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - drupal

  # ── Redis (optional — for Drupal cache backend) ────────────────────────────
  redis:
    image: redis:7-alpine
    networks:
      - drupal

volumes:
  mariadb_data:

networks:
  drupal:
    driver: bridge

A few design decisions worth explaining:

  • depends_on with condition: service_healthy for MariaDB ensures PHP-FPM does not start before the database is accepting connections. Without this, Drupal's installer fails on the first startup.
  • The webroot volume is mounted read-only for Nginx. Nginx only needs to serve static files and pass PHP requests — it has no reason to write. PHP-FPM gets the full project root read-write because Drupal writes to web/sites/default/files/ and Composer writes to vendor/.
  • Named volume for MariaDB data. Using a named volume instead of a bind mount avoids permission issues on Linux hosts and keeps the database data outside the project directory.

The Nginx Configuration

Create docker/nginx/default.conf. This is the standard Drupal 11 Nginx configuration adapted for the Docker environment:

server {
    listen 80;
    server_name localhost;

    root  /var/www/html/web;
    index index.php;

    # Drupal clean URLs
    location / {
        try_files $uri /index.php?$query_string;
    }

    # PHP-FPM — pass requests to the php container
    location ~ \.php$ {
        try_files $uri =404;

        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass   php:9000;         # Container name + FPM port
        fastcgi_index  index.php;
        include        fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param  PATH_INFO       $fastcgi_path_info;

        fastcgi_read_timeout    120;  # Longer timeout for Drush commands via browser
        fastcgi_buffer_size     128k;
        fastcgi_buffers         4 256k;
    }

    # Prevent access to hidden files and Drupal internals
    location ~ /\. {
        deny all;
    }

    location ~* \.(engine|inc|install|make|module|profile|po|sh|sql|theme|twig|twig\.php|xtmpl|yml|bak|orig|save|swo|swp)$ {
        deny all;
        return 404;
    }

    # Managed files — no PHP execution
    location ~* /sites/[^/]+/files/ {
        try_files $uri =404;
        location ~* \.php\d?$ {
            deny all;
        }
    }

    # Static file caching
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
}

The critical difference from a production config is fastcgi_pass php:9000 — Docker's internal DNS resolves the service name php to the PHP-FPM container's IP automatically. On a production server this would be a Unix socket path instead.


The PHP-FPM Dockerfile

Create docker/php/Dockerfile:

FROM php:8.3-fpm-alpine

# Install system dependencies
RUN apk add --no-cache \
    git \
    curl \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    libwebp-dev \
    libzip-dev \
    icu-dev \
    mariadb-client \
    # Required for Composer
    unzip

# Install PHP extensions required by Drupal
RUN docker-php-ext-configure gd \
        --with-freetype \
        --with-jpeg \
        --with-webp && \
    docker-php-ext-install \
        gd \
        pdo_mysql \
        zip \
        intl \
        opcache \
        bcmath \
        exif

# Install the Redis PHP extension
RUN pecl install redis && docker-php-ext-enable redis

# Install Xdebug for local debugging
RUN pecl install xdebug && docker-php-ext-enable xdebug

# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# Create a non-root user matching typical host UIDs (avoids file permission issues)
RUN addgroup -g 1000 drupal && adduser -D -u 1000 -G drupal drupal

WORKDIR /var/www/html

USER drupal

PHP Configuration Overrides

Create docker/php/php.ini:

; Development overrides
memory_limit = 512M
max_execution_time = 120
upload_max_filesize = 64M
post_max_size = 64M
date.timezone = Europe/Copenhagen

; OPcache — disabled by default in dev so file changes take effect immediately
; Enable in staging/production
opcache.enable = 0
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 10000

; Xdebug — configure for your IDE
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = host.docker.internal  ; resolves to host machine on Mac/Windows
xdebug.client_port = 9003
xdebug.log_level = 0

Note on Xdebug and Linux: host.docker.internal resolves automatically on Docker Desktop (Mac/Windows). On Linux with Docker Engine, add this to the PHP service in docker-compose.yml:

  php:
    extra_hosts:
      - "host.docker.internal:host-gateway"

Installing Drupal

With the stack defined, install Drupal via Composer inside the container:

# Start the stack
docker compose up -d

# Create a Composer project inside the container
# (run from the project root on your host)
docker compose exec php composer create-project drupal/recommended-project .

# Install Drush
docker compose exec php composer require drush/drush

# Run Drupal's installation
docker compose exec php vendor/bin/drush site:install standard \
  --db-url="mysql://drupal:drupal@mariadb/drupal" \
  --account-name=admin \
  --account-pass=admin \
  --site-name="My Drupal Site" \
  -y

# Get a one-time login link
docker compose exec php vendor/bin/drush uli

Visit http://localhost:8080 and log in with the link from drush uli.


Connecting Drupal to Redis

With the Redis service running in the stack, install the Redis module and configure Drupal to use it as the cache backend:

docker compose exec php composer require drupal/redis
docker compose exec php vendor/bin/drush en redis -y

Add to web/sites/default/settings.php:

// Redis cache backend
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host']      = 'redis';
$settings['redis.connection']['port']      = 6379;
$settings['cache']['default'] = 'cache.backend.redis';
$settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['config']    = 'cache.backend.chainedfast';

Day-to-Day Workflow Commands

# Start the stack
docker compose up -d

# Stop without deleting data
docker compose stop

# Stop and remove containers (data volume persists)
docker compose down

# Tail PHP-FPM and Nginx logs simultaneously
docker compose logs -f php nginx

# Run Drush commands
docker compose exec php vendor/bin/drush cache:rebuild
docker compose exec php vendor/bin/drush updatedb
docker compose exec php vendor/bin/drush config:export -y

# Open a shell in the PHP container
docker compose exec php sh

# Connect to MariaDB directly
docker compose exec mariadb mysql -u drupal -pdrupal drupal

# Rebuild PHP image after Dockerfile changes
docker compose build php
docker compose up -d --force-recreate php

File Permission Gotchas

The most common frustration with Docker + Drupal is file permissions. The user inside the PHP container (UID 1000) must be able to write to web/sites/default/files/. On Linux hosts, ensure your host user also has UID 1000, or adjust the adduser line in the Dockerfile to match your actual UID:

# Find your host UID
id -u  # e.g. 1001

# Update the Dockerfile
RUN addgroup -g 1001 drupal && adduser -D -u 1001 -G drupal drupal

On Docker Desktop for Mac and Windows, file permissions are handled transparently by the VM layer and this is rarely an issue.


Summary Checklist

  • Use depends_on with a healthcheck on MariaDB to prevent race conditions on first start.
  • Mount the Drupal webroot read-only to Nginx; give PHP-FPM read-write access to the full project.
  • Use fastcgi_pass php:9000 in Nginx — Docker resolves service names internally.
  • Add extra_hosts: host.docker.internal:host-gateway for Xdebug on Linux hosts.
  • Disable OPcache in development (opcache.enable = 0) so code changes take effect immediately.
  • Use a named volume for MariaDB data to avoid permission issues and keep data outside the project directory.
  • Install Drupal via docker compose exec php composer create-project and drush site:install.
  • Connect Drupal to Redis by setting the redis.connection.host to the service name (redis).

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.