This image provides a basic runtime for Drupal projects. It's designed for CI but is also suitable for local development environments using docker-compose. This image is similar to the official Drupal image but does not include the Drupal core files, allowing you to mount your own codebase.
This repository publishes four variants of the Drupal base image:
apache-trixie(default): Based on Debian Trixie with Apache. Backs thelatestandphp8.Xtags.apache-bookworm: Based on Debian Bookworm with Apache. Kept for users on older hosts; not the recommended default.fpm-alpine: Based on Alpine Linux with PHP-FPM. Pair with a separate web server like Nginx.frankenphp-trixie: Based on Debian Trixie with FrankenPHP (Caddy + PHP in one process).
Choose the image that best fits your needs. The Apache image is a good choice for a simple, all-in-one container, the FPM image is ideal for use with a separate web server like Nginx, and FrankenPHP offers a modern single-binary alternative with built-in HTTPS support.
This image supports the following PHP versions:
- PHP 8.2
- PHP 8.3
- PHP 8.4
- PHP 8.5 (latest)
PHP 8.2–8.5 are available in all four variants, including frankenphp-trixie.
php8.5,latest- PHP 8.5 with Apache on Debian Trixiephp8.5-apache-trixie- PHP 8.5 with Apache on Debian Trixiephp8.5-apache-bookworm- PHP 8.5 with Apache on Debian Bookwormphp8.5-alpine,php8.5-fpm-alpine,latest-alpine- PHP 8.5 FPM on Alpine Linuxphp8.4,php8.3,php8.2- Older PHP versions with Apache on Debian Trixiephp8.4-alpine,php8.3-alpine,php8.2-alpine- Older PHP versions FPM on Alpine Linuxphp8.5-frankenphp-trixie,php8.4-frankenphp-trixie,php8.3-frankenphp-trixie,php8.2-frankenphp-trixie- FrankenPHP on Debian Trixie
All images support both linux/amd64 and linux/arm64 architectures.
The Apache image is straightforward to use. Mount your Drupal codebase to /var/www/html in the container.
Here is an example docker-compose.yml snippet:
services:
drupal:
image: hussainweb/drupal-base:php8.5
volumes:
- ./path/to/your/drupal/root:/var/www/html
ports:
- "8080:80"
restart: alwaysThe FPM-Alpine image requires a separate web server. The following example uses Nginx.
Here is an example docker-compose.yml snippet:
services:
drupal:
image: hussainweb/drupal-base:php8.5-alpine
volumes:
- ./path/to/your/drupal/root:/var/www/html
restart: always
nginx:
image: nginx:latest
ports:
- "8080:80"
volumes:
- ./path/to/your/drupal/root:/var/www/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- drupal
restart: alwaysYou will need an nginx.conf file in your project root. Here is a production-ready example:
server {
listen 80 default_server;
server_name localhost _;
root /var/www/html/web;
index index.php index.html index.htm;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Deny access to sensitive files
location ~* \.(engine|inc|info|install|make|module|profile|test|po|sh|.*sql|theme|tpl(\.php)?|xtmpl|svn|git|bzr|hg|CVS)(~|\.sw[op]|\.bak|\.orig|\.save)?$ {
deny all;
}
# Deny access to backup files
location ~ ~$ {
deny all;
access_log off;
log_not_found off;
}
# Theme and frontend assets
location ~* ^/(themes|core)/.*\.(css|js|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot)$ {
access_log off;
}
# Handle PHP files
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass drupal:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_PROXY "";
fastcgi_read_timeout 300;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Handle Drupal clean URLs
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Drupal aggregate CSS/JS paths (multisite-safe)
# Required since Drupal 10.1 as aggregate files are created on first request.
# See https://www.drupal.org/node/3301716
location ~* ^/sites/[^/]+/files/(css|js)/ {
try_files $uri /index.php?$query_string;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Image styles (must go through Drupal if missing)
location ~* ^/sites/[^/]+/files/styles/ {
try_files $uri /index.php?$query_string;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# All other static assets
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Cache HTML files
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public";
}
# Deny access to sensitive directories
location ~* ^/(sites/.*/private/|sites/.*/tmp/) {
deny all;
}
}Note: Adjust root path based on your Drupal installation structure. If your Drupal files are directly in the mounted directory, use /var/www/html. If you have a web subdirectory (as in Composer-based installs), use /var/www/html/web.
Important: For image style generation and CSS/JS aggregation to work properly, the Drupal source code must be available in both the Nginx and PHP-FPM containers. When Nginx receives a request for a missing image style or aggregate file, it passes the request to Drupal, which generates the file. Nginx then needs filesystem access to serve the generated file on subsequent requests.
- Using volumes: Mount your Drupal codebase to both containers (as shown in the docker-compose example above).
- Using a custom Dockerfile: If you copy files into the PHP-FPM image instead of mounting them, you must also build a custom Nginx image that contains the same static files (themes, modules, and the
sites/*/filesdirectory if pre-populated).
The FrankenPHP image uses Caddy as the web server with FrankenPHP for PHP execution. It is configured to serve the Drupal site from /app/web.
Here is an example docker-compose.yml snippet:
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
volumes:
- ./path/to/your/drupal/root:/app
ports:
- "8080:80"
restart: alwaysNote: Mount your entire Drupal project root to /app. The image expects Drupal's index.php to be in /app/web.
The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive paths, denies PHP execution from upload directories, and sets long-lived cache headers on static assets:
{
frankenphp {
php_ini memory_limit {$PHP_MEMORY_LIMIT:128M}
}
order php_server before file_server
# Harmless unless the WAF snippet is enabled
order coraza_waf first
# Rate limiting runs before the WAF and basic auth, so floods and password
# guessing are cut off cheaply. Harmless unless its snippet is enabled.
order rate_limit before coraza_waf
servers {
# Proxies whose client IP headers are believed. None by default.
trusted_proxies static {$TRUSTED_PROXIES:}
# Read X-Forwarded-For from the right, skipping trusted proxies, so a
# client cannot pick its own address by sending the header itself.
trusted_proxies_strict
client_ip_headers {$CLIENT_IP_HEADERS:X-Forwarded-For}
}
}
:80 {
encode zstd br gzip
root * /app/web
# Opt-in features, off by default (see README). Noindex comes first so the
# 401 from basic auth carries the X-Robots-Tag header too.
import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy}
import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy}
import {$WAF_SNIPPET:/etc/frankenphp/waf/disabled.caddy}
import {$RATE_LIMIT_SNIPPET:/etc/frankenphp/rate-limit/disabled.caddy}
# Block hidden PHP files
@hiddenPhp path_regexp \..*/.*.php$
error @hiddenPhp 403
# Block PHP in vendor
@vendorPhp path_regexp /vendor/.*\.php$
error @vendorPhp 404
# Block PHP in files directories
@filesPhp path_regexp ^/sites/[^/]+/files/.*\.php$
error @filesPhp 404
# Block private directories
@private path_regexp ^/sites/.*/private/
error @private 403
# Block sensitive files (allow .well-known)
@protected {
not path /.well-known*
path_regexp \.(engine|inc|install|make|module|profile|po|sh|.*sql|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^/(\..+|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config|yarn\.lock|package\.json)$|^\/#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$
}
error @protected 403
# Static assets caching
@static {
file
path *.avif *.css *.eot *.gif *.gz *.ico *.jpg *.jpeg *.js *.otf *.pdf *.png *.svg *.ttf *.webp *.woff *.woff2
}
header @static Cache-Control "public, max-age=31536000, immutable"
php_server
}Three features are driven by environment variables, so a private staging or demo site can run on the stock Caddyfile. All are off (or unchanged) by default.
| Variable | Default | Purpose |
|---|---|---|
BASIC_AUTH_USER |
unset | User name for HTTP basic auth. |
BASIC_AUTH_PASSWORD |
unset | Plaintext password. The entrypoint hashes it at start. |
BASIC_AUTH_HASH |
unset | A ready-made hash (from frankenphp hash-password) instead of a password. Wins over BASIC_AUTH_PASSWORD if both are set. |
BASIC_AUTH_SNIPPET |
/etc/frankenphp/basic-auth/disabled.caddy |
Caddy snippet imported for basic auth. Set automatically to /etc/frankenphp/basic-auth/enabled.caddy when the user and a password or hash are given. |
NOINDEX_SNIPPET |
/etc/frankenphp/noindex/disabled.caddy |
Set to /etc/frankenphp/noindex/enabled.caddy to send X-Robots-Tag: noindex, nofollow, noarchive on every response (the 401 included) and to serve a disallow-all /robots.txt. |
PHP_MEMORY_LIMIT |
128M |
memory_limit for the web server. 128M is PHP's built-in default, so nothing changes unless you set it. The CLI keeps memory_limit = -1, so drush and composer are unaffected. |
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
volumes:
- ./path/to/your/drupal/root:/app
ports:
- "8080:80"
environment:
BASIC_AUTH_USER: demo
BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD}
NOINDEX_SNIPPET: /etc/frankenphp/noindex/enabled.caddy
PHP_MEMORY_LIMIT: 512MBasic auth protects every path except /robots.txt, so crawlers can still read the disallow rule. The password is hashed by docker-drupal-entrypoint (a bcrypt hash via frankenphp hash-password, with the password on stdin), so you never write a hash containing $ into a compose file. BASIC_AUTH_PASSWORD is unset before the server starts, so it does not reach PHP's environment. The user name must not contain spaces.
Fails closed. If basic auth is requested (BASIC_AUTH_SNIPPET points at the enabled file) but the user name or the password and hash are missing, the container exits with an error instead of starting an open site.
Downstream entrypoints. The image sets ENTRYPOINT ["docker-drupal-entrypoint"] and re-declares the upstream CMD. If your image has its own entrypoint, end it with exec docker-drupal-entrypoint "$@" to keep these features (it hands over to docker-php-entrypoint). If you replace the entrypoint without doing so, the variables have no effect, and basic auth is never enabled.
If you mount your own Caddyfile, it only gets these features if it contains the same import lines (and the php_ini line in the global block).
The FrankenPHP binary in this image is built with the Coraza WAF module for Caddy and ships the OWASP Core Rule Set (CRS, embedded in the binary), plus a set of default blocks for scanner traffic. The WAF is off by default: the Caddyfile imports an empty snippet and nothing changes for you until you opt in. The binary also has the rate limit module (see below) and is otherwise the upstream one (same modules, including Brotli, Mercure and Vulcain). It keeps its cap_net_bind_service capability.
Turn it on with one environment variable, for example in your Dockerfile or compose file:
ENV WAF_SNIPPET=/etc/frankenphp/waf/enabled.caddy| Variable | Default | Purpose |
|---|---|---|
WAF_SNIPPET |
/etc/frankenphp/waf/disabled.caddy |
Caddy snippet imported for the WAF. Set to /etc/frankenphp/waf/enabled.caddy to enable it. |
enabled.caddy sets the engine to On, turns response body inspection off (requests only, so large or streamed Drupal responses are not buffered), and includes, in this order: the recommended Coraza settings, the CRS setup and rules, and every /etc/frankenphp/waf/rules/*.conf in file name order. Blocked requests are logged to the container log as WAF rule violation detected with the URI, and CRS rule matches are logged with their rule ids.
Default blocks (rules/10-scanner-paths.conf, phase 1, they do not depend on CRS scoring and answer 403 before Drupal starts):
- Any path containing
.php(in any case) except the Drupal front controllersindex.php,update.php,cron.php,authorize.php,install.phpandrebuild.php, at the web root or under/core/./index.php/foostill works. Forms such as/wp-login.php/index.php,/wp-login.php%2findex.phpand/wp-login.php;.pngare blocked, because Caddy runs the script that ends at the first.php. This removeswp-login.php,xmlrpc.phpandeval-stdin.phpprobes. - Hidden files and directories such as
/.env,/.git,/.svn,/.awsand/.ssh./.well-known/stays reachable. - Paths of other applications:
/wp-admin,/wp-content,/wp-includes,/wp-json,/phpmyadmin,/_ignition,/cgi-bin,/vendorand a few more.
The rule ids are 10001 to 10003. If a site legitimately needs one of these, remove the rule in your own file (see below) with SecRuleRemoveById 10003.
Project rules. Add your own *.conf files to /etc/frankenphp/waf/rules/. They are included last, so they can override anything above. Use names that sort after 10-scanner-paths.conf, for example 90-project.conf:
FROM hussainweb/drupal-base:php8.5-frankenphp-trixie
ENV WAF_SNIPPET=/etc/frankenphp/waf/enabled.caddy
COPY waf/90-project.conf /etc/frankenphp/waf/rules/90-project.conf# waf/90-project.conf
# Drupal admin forms with HTML bodies (CKEditor) can trip CRS rules. Either
# exclude the rule that fires for one field on those paths (the id is in the
# log; 942100 is only an example) ...
SecRule REQUEST_URI "@rx ^/node/(?:add/|\d+/edit)" \
"id:90001,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942100;ARGS:body[0][value]"
# ... or raise the anomaly score threshold (the CRS default is 5) for the site
SecAction "id:90002,phase:1,pass,nolog,setvar:tx.inbound_anomaly_score_threshold=10"
# Match the request body limits to the uploads you expect
SecRequestBodyLimit 134217728
# Coraza does not enforce SecRequestBodyNoFilesLimit, so only the total limit applies
CRS exclusions are site specific. Use the log to find the rule ids that block legitimate requests instead of copying a list.
Rolling it out. Start in detection mode, so that requests are not blocked (the default blocks are not enforced either) and CRS rule matches are only logged, watch the log for false positives while real traffic and editors use the site, add exclusions, and then switch to blocking:
# waf/90-project.conf, first weeks
SecRuleEngine DetectionOnly
Delete that line (the engine is On by default) when the log is clean.
Custom 403 page. The WAF answers with a plain 403. To show your own page, use handle_errors in a Caddyfile of your own (see below).
As with the other snippets, a Caddyfile you mount yourself needs the import line and order coraza_waf first in the global block to get the WAF.
The binary includes caddy-ratelimit, and the Caddyfile can limit how many requests each client makes. It is off by default. Turn it on with one environment variable:
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
environment:
RATE_LIMIT_SNIPPET: /etc/frankenphp/rate-limit/enabled.caddy
# Behind a load balancer or CDN, trust it so each visitor gets their own limit
TRUSTED_PROXIES: private_ranges| Variable | Default | Purpose |
|---|---|---|
RATE_LIMIT_SNIPPET |
/etc/frankenphp/rate-limit/disabled.caddy |
Caddy snippet imported for rate limiting. Set to /etc/frankenphp/rate-limit/enabled.caddy to enable it. |
RATE_LIMIT_EVENTS |
120 |
Requests a client may make in the window. |
RATE_LIMIT_WINDOW |
1m |
The sliding window, as a Caddy duration (30s, 1m, 1h). |
TRUSTED_PROXIES |
unset (none) | Space-separated IP ranges of proxies in front of the site, or private_ranges. Their client IP headers are believed. |
CLIENT_IP_HEADERS |
X-Forwarded-For |
Space-separated headers that carry the client IP, read only from trusted proxies. For example CF-Connecting-IP X-Forwarded-For for Cloudflare. |
A client that goes over the limit gets a 429 with a Retry-After header, and the container log has a rate limit exceeded line with the client's address. Clients are told apart by IP address. IPv6 addresses share a limit per /64, because one host usually has a whole /64. Static assets (CSS, JS, images and fonts) are not counted, so a page load uses one request of the budget, plus any AJAX calls it makes. Rate limiting runs before the WAF and basic auth, so it also slows down password guessing.
Behind a proxy, set TRUSTED_PROXIES. Without it every request seems to come from the load balancer, so all visitors share one limit and a busy site starts answering 429. Only list proxies you control. A trusted range can claim any client IP, so trusting too much lets anyone pick their own limit. The header is read from the right (trusted_proxies_strict), skipping trusted proxies, so an address a client adds to X-Forwarded-For itself is ignored when your proxy appends to the header. TRUSTED_PROXIES sets Caddy's trusted_proxies, so it also changes the client IP in Caddy's logs. PHP's REMOTE_ADDR is not affected. Configure Drupal's reverse_proxy settings for that.
Choosing a limit. The default of 120 requests a minute (excluding static assets) is a starting point. Many editors behind one office NAT share an address, and some admin pages make many AJAX requests. Raise the limit if you see legitimate 429s in the log. The counts are kept in memory, per container. With several replicas, each one counts separately, and a restart resets them.
As with the other snippets, a Caddyfile you mount yourself needs the import line, and order rate_limit before coraza_waf (or another order for rate_limit) in the global block, to get rate limiting.
To customize the Caddy configuration, mount your own Caddyfile:
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
volumes:
- ./path/to/your/drupal/root:/app
- ./Caddyfile:/etc/frankenphp/Caddyfile
ports:
- "8080:80"
restart: alwaysHere is an example custom Caddyfile with additional security and caching:
{
frankenphp
order php_server before file_server
}
:80 {
encode zstd gzip
root * /app/web
# Security: deny access to sensitive files
@sensitive {
path *.engine *.inc *.install *.module *.profile *.po *.sh *.sql *.theme *.twig *.xtmpl *.yml *.yaml
path /composer.json /composer.lock /web.config
path /.* /vendor/*
}
respond @sensitive 403
# Cache static assets
@static {
path *.css *.js *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2 *.ttf *.eot
}
header @static Cache-Control "public, max-age=31536000, immutable"
php_server
file_server
}FrankenPHP supports automatic HTTPS. To enable it, update your Caddyfile:
{
frankenphp
order php_server before file_server
}
your-domain.com {
encode zstd gzip
root * /app/web
php_server
file_server
}And expose port 443 in your docker-compose.yml:
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
volumes:
- ./path/to/your/drupal/root:/app
- ./Caddyfile:/etc/frankenphp/Caddyfile
- caddy_data:/data
ports:
- "80:80"
- "443:443"
restart: always
volumes:
caddy_data:Caddy will automatically obtain and renew TLS certificates from Let's Encrypt.
Every variant ships with a HEALTHCHECK that opens a TCP connection to the web server (port 80, or 9000 on fpm-alpine):
HEALTHCHECK --interval=30s --timeout=5s --start-period=5m --start-interval=5s --retries=3Failures during the five-minute start period are not counted until the first successful check. That success marks the container healthy and ends the grace period, even if the five minutes have not passed, so later consecutive failures count toward --retries as usual. During the start period the check runs every 5 seconds (--start-interval), so a container that starts quickly is reported healthy within seconds. The long window suits images that install or update the site (for example drush site:install or drush deploy) in their entrypoint before the web server starts.
Health check options are fixed at build time and cannot come from environment variables. To use a different window, override them in Docker Compose:
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
healthcheck:
start_period: 10m
start_interval: 5sOr declare a HEALTHCHECK in a downstream Dockerfile. It replaces the inherited one, so repeat the command (use port 9000 on fpm-alpine):
FROM hussainweb/drupal-base:php8.5-apache-trixie
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --start-interval=5s --retries=3 \
CMD ["php", "-r", "exit(@fsockopen('127.0.0.1', 80, $e, $s, 2) ? 0 : 1);"]--start-interval needs Docker Engine 25 or later (and Docker Compose 2.20.2+ for start_interval in a compose file). Older engines do not know the field in the image config and ignore it, so the check simply runs at the normal 30 second interval during the start period. The 5 minute start period still applies, so containers are not marked unhealthy early; they only take up to one interval (30 s) to be reported healthy. This is based on how the engine decodes the image config, not on a test against an old engine.