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_onwithcondition: service_healthyfor 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 tovendor/. - 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_onwith ahealthcheckon 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:9000in Nginx — Docker resolves service names internally. - Add
extra_hosts: host.docker.internal:host-gatewayfor 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-projectanddrush site:install. - Connect Drupal to Redis by setting the
redis.connection.hostto the service name (redis).