Docker and Containers
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
A Drupal site is a PHP application, a database and a directory of uploaded files, and all three fit naturally into
containers. This page explains the three ways Drupal usually meets Docker: the official drupal image on Docker Hub,
DDEV (a local development tool that runs on Docker) and a Docker Compose stack or Kubernetes deployment built from your own
image. It follows the fictitious Meridian Museum site from the local DDEV project to a production image, a cron
container and a Kubernetes deployment.
The page was written against Drupal 11.4.x (the official image tracks 11.4.11, released on 2026-10-21), Drush 13 and 14 and PHP 8.3 or later (11.4 runs on PHP 8.3, 8.4 and 8.5), and checked on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and Drupal 10 reaches its end of life on 2026-12-09, so a new container build starts from Drupal 11 (see Versions, Release Cycle and Support). Docker itself is explained in the site’s Docker section (start with Docker getting started and Dockerfile fundamentals); this page covers only what is specific to Drupal.
Choosing an approach
| Approach | Best for | Main limit |
|---|---|---|
|
A throwaway evaluation of the installer in a few seconds |
A generic site: no Drush, no custom code, no configuration management workflow |
DDEV |
Daily development on a laptop, with the same stack for every developer |
A development tool; it is not a hosting platform |
Docker Compose with your own image |
CI, review environments, staging and small single-host production |
You maintain the image, the update process and the backups |
Kubernetes with your own image |
Several replicas, rolling releases, managed platforms |
Needs shared file storage, a release job and an operations team |
All four rest on the same principle: the image holds the code (Drupal core, contributed projects and the project’s custom modules and themes), while the database and the file directories are state that lives outside the image.
The official drupal image
The drupal image is a Docker Official Image published at Docker Hub: drupal; its source
is the docker-library/drupal repository on GitHub. Reading its Dockerfile tells you exactly what it does:
-
it starts from the official
phpimage (the Apache variant, withmod_php, or the PHP-FPM variant), on Debian or Alpine -
it enables the Apache modules
expiresandrewritethat the Drupal.htaccessfile references -
it installs the PHP extensions
gd(with AVIF, FreeType, JPEG and WebP support),pdo_mysql,pdo_pgsqlandzip -
it writes recommended OPcache settings and turns on
output_buffering -
it copies Composer 2 into the image
-
it runs
composer create-project drupal/recommended-projectfor one pinned Drupal version into/opt/drupaland replaces/var/www/htmlwith a symbolic link to/opt/drupal/web -
it makes
web/sites,web/modulesandweb/themeswritable bywww-dataand adds/opt/drupal/vendor/binto the path
The tags combine a Drupal version, a PHP version, a web server variant and an operating system, for example
11.4.11-php8.5-apache-trixie; shorter aliases such as 11.4-php8.5-apache-trixie move with the patch releases, and
latest points to the newest stable Drupal on the Apache variant. At the time of writing the tag list also carries Drupal
12.0 and 11.5 release candidates and 10.6, Apache and FPM flavours, and Debian trixie, Debian bookworm and Alpine bases.
Pin a full tag, never latest, so a build is reproducible, and read the list of tags in the repository rather than
relying on the abbreviated list on the Docker Hub page.
What the documentation says about production
The task of the image is easy to misread, so the wording matters. The Docker Hub description does not declare the image "for development only". Its text is a quickstart: start a container, open port 80 and run the installer, with SQLite as the default database "to avoid a second container". It then adds that detailed instructions for different, "more production-ready" database types follow, and it warns that the image "does not provide any additional PHP extensions or other libraries". The practical conclusion is a property of the design rather than a prohibition:
-
the code is the stock
recommended-project, so the project’s owncomposer.json, custom modules, themes, patches and configuration cannot be part of the image unless you build a different one -
there is no Drush in it, so updates, configuration import and cron have no command-line tool unless you add one with Composer
-
updating means pulling a new image tag, which replaces the code in
/opt/drupalbut does not touch volumes, so the mountedmodules,themesandsitesdirectories can end up out of step with the new core -
the Compose example in the description still uses a Drupal 10 tag (
drupal:10-apache) and PostgreSQL 16, which makes it a syntax illustration and not a recommended stack
Treat the official image as an excellent evaluation tool and as a base to read and learn from. For a site with its own code and a release process, build your own image (see below), with the same pinned PHP and web server choices.
Running it for a first look
# SQLite, no second container: the installer picks SQLite by default.
docker run --name meridian-eval -d -p 8080:80 drupal:11.4-php8.5-apache-trixie
# Open http://localhost:8080 and run the installer, then remove the container.
docker rm -f meridian-eval
Source: Docker Hub: drupal
The volumes of the image
"By default, this image does not include any volumes." The description says that /var/www/html/modules,
/var/www/html/profiles and /var/www/html/themes are generally good candidates for volumes, and that
/var/www/html/sites is "somewhat more complex": a new, empty volume mounted there hides the default directory that the
image placed in it (including default.settings.php and the services.yml template), so the installer cannot find its
files. The documented fix is to pre-seed the volume with the contents of the image’s sites directory before using it.
The Compose file below does the same with a one-shot container.
# compose.eval.yaml: the official image with MariaDB, for evaluation only.
name: meridian-eval
services:
drupal:
image: drupal:11.4-php8.5-apache-trixie
ports:
- "8080:80"
volumes:
- sites:/var/www/html/sites
depends_on:
sites-init:
condition: service_completed_successfully
db:
condition: service_healthy
# Copies the image's sites directory into the empty named volume once.
sites-init:
image: drupal:11.4-php8.5-apache-trixie
user: root
command: ["sh", "-c", "cp -a -n /opt/drupal/web/sites/. /seed/ && chown -R www-data:www-data /seed"]
volumes:
- sites:/seed
db:
image: mariadb:11.4
environment:
MARIADB_DATABASE: meridian
MARIADB_USER: meridian
MARIADB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in the shell or in an .env file}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
volumes:
sites:
dbdata:
Source: Docker Hub: drupal
In the installer choose MySQL, MariaDB, Percona Server or equivalent, enter the database name and user from the file and use
the service name db as the host, because containers on the same Compose network reach each other by service name. The
password comes from an environment variable, so the file contains no secret; the variable is set in the shell or in an
untracked .env file next to the Compose file. MariaDB 11.4 satisfies Drupal’s minimum (10.6; 10.11 for Drupal 12); the
version rules are in Requirements and Hosting.
The FPM variant (-fpm tags) does not serve HTTP; it speaks FastCGI and needs a web server such as nginx in front. The
description warns that FastCGI is inherently trusting and "extremely insecure to expose outside of a private container
network": never publish its port with -p, and keep it on a private Compose or cluster network that only the web server
container can reach.
DDEV and Docker Compose
DDEV is an open source tool that launches local web development environments on top of Docker. It is configured by a
.ddev directory committed to the project, provides a router with a local HTTPS address (meridian.ddev.site), a database,
a mail catcher and Drush and Composer commands inside the web container, and is covered step by step in
Installation with Composer and DDEV and
Project Layout and Local Development. Its documentation describes
it as a local development tool and notes that the ddev-webserver image contains development tools one would not include
in a production image; nothing in it recommends hosting a public site with DDEV.
The .ddev/config.yaml of the Meridian Museum project pins the choices that matter:
# .ddev/config.yaml
name: meridian
type: drupal11
docroot: web
php_version: "8.4"
webserver_type: nginx-fpm
database:
type: mariadb
version: "10.11"
nodejs_version: "22"
hooks:
post-start:
- exec: "composer install --no-interaction"
Source: DDEV: Quickstart (Drupal)
DDEV project types for Drupal are drupal11, drupal10 and, for the upcoming release, drupal12. Useful everyday
commands:
ddev start
ddev composer install
ddev drush deploy # updatedb, config:import, cache rebuild, deploy hooks
ddev snapshot # fast database snapshot before risky work
ddev export-db --file=/tmp/meridian.sql.gz
ddev add-on get ddev/ddev-redis # extra services as add-ons
Source: DDEV: Commands
DDEV compared with Compose
| Aspect | DDEV | Hand-written Compose |
|---|---|---|
Configuration |
A small |
Every service, network and volume is written and reviewed by you |
Web server and PHP |
Chosen by |
Whatever your Dockerfile contains, identical to production if you reuse the image |
HTTPS and hostnames |
A router with local certificates and |
You add a reverse proxy such as Traefik or Caddy |
Drupal settings |
Writes |
You provide the connection through environment variables read by |
Parity with production |
Close in versions, but a different image |
Exact, because CI, staging and production run the same image |
Extras |
Mail catcher, Xdebug switch, snapshots, add-ons for Redis and Solr |
Each extra is another service you define |
The sensible split is DDEV on developers' machines and a Compose file, built from the production image, for continuous
integration and staging. This keeps day-to-day work fast and still proves that the image that will be deployed boots, installs
and passes the test suite. Both read the database host and credentials from the environment, so the same settings.php
works in both (see settings.php, services.yml and Environments).
Building a production image
A production image contains the exact code of one release: Drupal core and contributed projects installed by Composer from
the committed composer.lock, the custom meridian_collection module, the meridian theme with its compiled assets and the
exported configuration. It contains no secrets and no uploaded files.
The multi-stage build below uses a Composer stage to install dependencies without development packages, an optional Node stage for the theme build (see Front-end tooling) and a slim runtime stage. Multi-stage builds and layer caching are explained in Build cache and multi-stage builds.
# syntax=docker/dockerfile:1
# ---- Stage 1: PHP dependencies ------------------------------------------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# Local path repositories and scaffold files need the sources present.
COPY web/modules/custom web/modules/custom
COPY web/themes/custom web/themes/custom
RUN composer install --no-dev --no-interaction --prefer-dist \
--optimize-autoloader --classmap-authoritative
# ---- Stage 2: theme assets ---------------------------------------------
FROM node:22-bookworm-slim AS assets
WORKDIR /app/web/themes/custom/meridian
COPY web/themes/custom/meridian/package.json web/themes/custom/meridian/package-lock.json ./
RUN npm ci
COPY web/themes/custom/meridian/ ./
RUN npm run build
# ---- Stage 3: runtime ---------------------------------------------------
FROM php:8.4-apache-trixie AS runtime
RUN set -eux; \
a2enmod expires rewrite remoteip headers; \
apt-get update; \
apt-get install -y --no-install-recommends \
libfreetype6-dev libjpeg-dev libpng-dev libwebp-dev libzip-dev libpq-dev; \
docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp; \
docker-php-ext-install -j"$(nproc)" gd pdo_mysql pdo_pgsql zip; \
rm -rf /var/lib/apt/lists/*
# Production php.ini plus Drupal-friendly settings.
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/drupal.ini $PHP_INI_DIR/conf.d/zz-drupal.ini
ENV APACHE_DOCUMENT_ROOT=/var/www/html/web
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
WORKDIR /var/www/html
COPY --chown=root:root --from=vendor /app/ ./
COPY config/sync ./config/sync
COPY --from=assets /app/web/themes/custom/meridian/dist web/themes/custom/meridian/dist
# Only the files directories are writable; the code is not.
RUN mkdir -p /var/www/files/public /var/www/files/private \
&& chown -R www-data:www-data /var/www/files \
&& ln -s /var/www/files/public web/sites/default/files
ENV PATH="/var/www/html/vendor/bin:${PATH}"
EXPOSE 80
The docker/php/drupal.ini file holds the settings the official Drupal image also sets, plus limits suited to the site:
; docker/php/drupal.ini
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
; The image is immutable, so PHP never needs to look for changed files.
opcache.validate_timestamps=0
output_buffering=On
memory_limit=256M
upload_max_filesize=32M
post_max_size=40M
expose_php=Off
Source: PHP requirements
Points that make the difference between a working and a broken image:
-
Install with
--no-dev. Development dependencies (PHPUnit, Drupal’s test tooling, Devel) have no place in a production image, and they widen the attack surface. Adddrush/drushas a normal (non-dev) requirement, because deployment and cron need it (see Drush). -
Run Composer from the lock file.
composer installhonourscomposer.lock;composer updateinside a Dockerfile produces an image nobody reviewed. The workflow is in Updates, Composer and Security Advisories. -
Do not copy
settings.local.php,development.services.ymlor the.ddevdirectory. Add them to.dockerignoretogether with.git,node_modulesandweb/sites/default/files. -
Keep the code read-only at run time. Drupal 11 does not install or update code from the user interface (that was removed in 11.0 and 11.2), so the web server user needs write access to the files directories only. The one exception is the Package Manager that Automatic Updates and Project Browser use, which needs a writable codebase; those modules do not fit an immutable image, which is replaced rather than patched (see Automatic Updates and Project Browser).
-
Set
opcache.validate_timestamps=0. The code never changes inside a running container, so skipping file checks is safe and faster. Do not do it on a bind-mounted development tree. -
Match the PHP extensions to the site. The official image installs
gd,pdo_mysql,pdo_pgsqlandzip; addapcu,redisoropcacheexplicitly when the site needs them. -
Mind the user the workers run as. The Apache image starts as root so that it can bind port 80 and then serves requests as
www-data. Running the whole container as an unprivileged user needs a high port (for example 8080) and a changed Apache ports configuration; the FPM variant keeps its workers unprivileged without that change.
The image is built once and promoted: the same digest goes from CI to staging to production, and only the environment
differs. Tag it with the Git commit or the release date, not latest, and scan it as described in
Image best practices and security.
Reading configuration from the environment
The image has no secrets, so settings.php must read them at run time. A settings.php that is committed and identical in
every environment looks like this:
<?php
// web/sites/default/settings.php (committed; no secrets)
$databases['default']['default'] = [
'driver' => 'mysql',
'host' => getenv('DRUPAL_DB_HOST') ?: 'db',
'port' => getenv('DRUPAL_DB_PORT') ?: '3306',
'database' => getenv('DRUPAL_DB_NAME') ?: 'meridian',
'username' => getenv('DRUPAL_DB_USER') ?: 'meridian',
'password' => getenv('DRUPAL_DB_PASSWORD'),
'prefix' => '',
'namespace' => 'Drupal\\mysql\\Driver\\Database\\mysql',
'autoload' => 'core/modules/mysql/src/Driver/Database/mysql/',
];
// The salt comes from a secret, never from the repository.
$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT');
$settings['config_sync_directory'] = '../config/sync';
$settings['file_public_path'] = 'sites/default/files';
$settings['file_private_path'] = '/var/www/files/private';
$settings['file_temp_path'] = '/tmp';
// Host names the site answers to, from a comma-separated variable.
$hosts = array_filter(array_map('trim', explode(',', (string) getenv('DRUPAL_TRUSTED_HOSTS'))));
$settings['trusted_host_patterns'] = array_map(
static fn (string $host): string => '^' . preg_quote($host, '/') . '$',
$hosts,
);
// The site sits behind a TLS-terminating proxy or ingress.
if (getenv('DRUPAL_REVERSE_PROXY_ADDRESSES')) {
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = explode(',', getenv('DRUPAL_REVERSE_PROXY_ADDRESSES'));
}
Details of each setting (the database array, hash_salt, trusted_host_patterns and proxy handling) are in
settings.php, services.yml and Environments. Two container-specific
traps: an empty DRUPAL_TRUSTED_HOSTS produces an empty pattern list, which is the same as no restriction on a site that
should have one, so fail the container start when the variable is missing; and the variable names are the project’s own,
since Drupal itself reads no DRUPAL_* variable.
Persistent files
A container’s file system is discarded when the container is replaced, so everything users upload must live in a volume or in object storage. For Drupal that means:
| Directory | Content | Storage |
|---|---|---|
|
Uploaded media, image derivatives, aggregated CSS and JavaScript |
A named volume, a shared file system or an object storage module |
The private files directory ( |
Files with access control |
A separate volume that is outside the web root |
The temporary directory ( |
Upload scratch space |
Container-local storage; it need not be shared |
|
Exported configuration |
Inside the image (it is code, versioned in Git) |
The database data directory |
Content and configuration |
A volume of the database service, or a managed database |
Details of the schemes, permissions and upload validation are in File System and Private Files; the container-specific rules are these:
-
Mount the files directories, not
sites/default. Thesettings.phpfile is code and belongs to the image; mounting the wholesites/defaulthides it. -
The files directories must be writable by the user the web server runs as:
www-data, which has the numeric user identifier 33 in Debian-based images. A freshly created named volume copies the ownership of the image’s directory at the mount point, but a bind mount or a Kubernetes volume keeps the host’s ownership. When uploads fail with a permission error, check the numeric owner first. Kubernetes can set it withsecurityContext.fsGroup. -
The private files directory is never inside the document root. With the image above it lives in
/var/www/files/private, which Apache does not serve. -
Back up the database and the files together; a database restored without its files has broken image and document references (see Backups and Disaster Recovery).
-
A volume is attached to one host. Two web containers on different hosts need a shared file system or a stream wrapper module such as S3 File System, which turns the bucket into the shared store and lets containers stay disposable.
A production Compose file for a single host brings the pieces together:
# compose.yaml: Meridian Museum on one host (staging or small production).
name: meridian
x-drupal-env: &drupal-env
DRUPAL_DB_HOST: db
DRUPAL_DB_NAME: meridian
DRUPAL_DB_USER: meridian
DRUPAL_DB_PASSWORD: ${DRUPAL_DB_PASSWORD:?required}
DRUPAL_HASH_SALT: ${DRUPAL_HASH_SALT:?required}
DRUPAL_TRUSTED_HOSTS: ${DRUPAL_TRUSTED_HOSTS:?required}
services:
web:
image: registry.example/meridian:2026-10-30
restart: unless-stopped
environment: *drupal-env
volumes:
- public-files:/var/www/files/public
- private-files:/var/www/files/private
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS -H \"Host: $${DRUPAL_TRUSTED_HOSTS%%,*}\" http://localhost/user/login -o /dev/null"]
interval: 30s
timeout: 5s
retries: 3
ports:
- "127.0.0.1:8080:80" # a reverse proxy on the host terminates TLS
cron:
image: registry.example/meridian:2026-10-30
restart: unless-stopped
environment: *drupal-env
volumes:
- public-files:/var/www/files/public
- private-files:/var/www/files/private
depends_on:
- web
command:
- sh
- -c
- |
while true; do
drush --uri="https://$${DRUPAL_TRUSTED_HOSTS%%,*}" cron
sleep 900
done
db:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_DATABASE: meridian
MARIADB_USER: meridian
MARIADB_PASSWORD: ${DRUPAL_DB_PASSWORD:?required}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
volumes:
public-files:
private-files:
dbdata:
Source: Docker Docs: Compose
The values come from the deployment environment or an untracked .env file and must be created with a random generator;
the file contains only the names. The web health check sends the first trusted host name as the Host header, because a
request addressed to localhost is rejected with a 400 once trusted_host_patterns is configured. More Compose
techniques (profiles, health checks, overrides and secrets) are in
Compose fundamentals,
Compose in practice and
Compose for local development, and volumes in general in
Storage, volumes and bind mounts.
Release steps in a container
The image carries the code; moving a site to a new release also means running Drupal’s update steps against the database. The
deployment sequence is the usual one: put the code in place, run drush deploy (database updates, configuration import,
cache rebuild, deploy hooks and, from Drupal 11.2, cache warming), and check the Status report. In containers it runs as a
one-shot command from the new image before the new web containers receive traffic:
docker compose pull
docker compose run --rm web drush deploy
docker compose up -d
Source: Drush: deploy
Three rules keep this safe. Take a database backup first (a snapshot of the volume or drush sql:dump). Run the command once,
not once per replica: two concurrent updatedb runs race. And never run the Drupal installer as part of the container start
script on a production database; installation is a one-time act, and an entrypoint that installs when it finds an empty
database turns a lost volume into a freshly installed, empty site that overwrites the evidence. The overall delivery process,
including pipelines and rollbacks, is in Deployment and CI/CD.
Cron and queue containers
Drupal’s Automated Cron runs cron inside a web request, which on a busy site competes with visitors for PHP workers and, in a scaled-out deployment, runs on a random replica. Containers should run cron as a separate scheduled job from the same image as the web containers (the pattern shown in the Compose file above). The scheduling mechanics, the cron URL and its key and the queue system are explained in Cron, Queues and Maintenance and Queue, Batch and Cron APIs; for containers:
-
Turn Automated Cron off (
drush config:set automated_cron.settings interval 0, exported in the configuration) so the container is the only scheduler. -
A
sleeploop in a dedicated container is simple and works, but it drifts and restarts from zero; where an orchestrator offers a scheduler (a Kubernetes CronJob, or a host timer callingdocker compose run --rm), prefer it. -
Give the cron container the same environment and the same file volumes as the web container; a job that cannot write to the files directories fails on tasks such as stale file cleanup.
-
Pass
--urito Drush, or links generated by cron (notification emails, absolute URLs in queued items) point athttp://default. -
A long queue such as the
meridian_collectionsynchronization worker can run as its own container with a loop arounddrush queue:run meridian_collection_sync --time-limit=240, which keeps heavy work away from the cron run (see Queue, Batch and Cron APIs for the worker plugin).
Kubernetes notes
Kubernetes adds replicas, rolling updates and self-healing, and with them three requirements that Drupal does not meet by default. The Kubernetes mechanics are covered in the site’s Kubernetes section (start with Kubernetes, and see ReplicaSets and Deployments, Persistent volumes and storage classes, Stateful applications in practice, Jobs and CronJobs, Health probes and lifecycle, Secrets and Ingress).
The pieces map as follows:
-
Database: Drupal’s database is not a stateless sidecar. Use a managed database service or a StatefulSet with backups (see StatefulSets); a Deployment with a single replica and a plain volume is fragile.
-
Files: several web pods that accept uploads need storage that supports concurrent writers: a
ReadWriteManyclaim (the storage class must support it) or object storage through a stream wrapper module. AReadWriteOncevolume mounted by one pod works only for a single replica. -
Secrets: the database password and
hash_saltlive in a Secret exposed as environment variables or files, never in the manifest or the image. -
Release job:
drush deployruns as a Job (or a Helm pre-upgrade hook) before the Deployment rolls out; the Deployment only starts pods that reuse the already updated database. -
Cron: a CronJob with
concurrencyPolicy: Forbid(the manifest is in Cron, Queues and Maintenance). -
Probes: a readiness probe must send an accepted
Hostheader, as with the Compose health check above, or thetrusted_host_patternscheck answers 400 and the pod never becomes ready; a liveness probe should be lenient, because a slow page during cache rebuilds must not restart the pod. -
Ingress and proxy headers: the ingress controller terminates TLS, so configure
reverse_proxyand its trusted headers; from Drupal 11.3 the proxy must also forward theHX-RequestandHX-Redirectheaders used by HTMX (see settings.php, services.yml and Environments and AJAX and HTMX).
A trimmed Deployment and release Job for the Meridian Museum:
apiVersion: apps/v1
kind: Deployment
metadata:
name: meridian-web
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: meridian-web
template:
metadata:
labels:
app: meridian-web
spec:
securityContext:
fsGroup: 33
containers:
- name: web
image: registry.example/meridian:2026-10-30
ports:
- containerPort: 80
envFrom:
- secretRef:
name: meridian-settings
readinessProbe:
httpGet:
path: /user/login
port: 80
httpHeaders:
- name: Host
value: www.meridian-museum.org
periodSeconds: 10
volumeMounts:
- name: public-files
mountPath: /var/www/files/public
- name: private-files
mountPath: /var/www/files/private
volumes:
- name: public-files
persistentVolumeClaim:
claimName: meridian-public-files
- name: private-files
persistentVolumeClaim:
claimName: meridian-private-files
---
apiVersion: batch/v1
kind: Job
metadata:
name: meridian-deploy-20261030
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: deploy
image: registry.example/meridian:2026-10-30
command: ["drush", "--uri=https://www.meridian-museum.org", "deploy", "-y"]
envFrom:
- secretRef:
name: meridian-settings
Source: Kubernetes: Deployments
The Secret meridian-settings holds the DRUPAL_* variables defined earlier and is created outside the repository (by a
secrets manager, sealed secrets or the cluster operator), and fsGroup: 33 gives the www-data group of the image write access to the mounted volumes. A Job name must
be unique per release, so generate it from the tag.
Security
-
Pin images by tag and, for stricter pipelines, by digest; scan them in CI; rebuild on every Drupal security release (a base image update alone does not update the Composer packages inside it). Drupal publishes core security releases on the third Wednesday of a month and covers the two most recent minors, so a container pipeline needs a quick path from
composer update drupal/core-recommended --with-all-dependenciesto a new image (see Updates, Composer and Security Advisories). -
Never bake credentials, the hash salt or API keys into an image layer: layers are readable by anyone with the image, even if a later layer deletes the file. Inject them at run time. More on image hygiene and runtime hardening is in Image best practices and security and Docker security.
-
Keep the database off the public network: publish no database port, and let only the application and cron containers join the network it is on.
-
Do not expose the PHP-FPM port; run the web server and PHP in a private network.
-
Do not mount the Docker socket into a Drupal container, and do not run containers with
--privileged. -
Leave
update.php,install.phpand the Status report reachable only to authenticated administrators; the hardening checklist is in Security Hardening. -
The
.htaccessthat protects the files directories works only under Apache. With nginx, PHP execution insidesites/default/filesmust be blocked in the server configuration (see Requirements and Hosting).
Common errors and pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The installer says it cannot find or write |
An empty volume hid the image’s |
Pre-seed the volume as in the evaluation Compose file, or mount only the |
Uploads fail with "unable to create directory" |
The volume is owned by root or by another numeric user |
Make the directory writable by user 33 ( |
Every request returns 400 "The provided host name is not valid" |
|
Add the host name or send a |
Redirect loops or |
|
Configure them in |
Image changes do not show up |
The old container still runs, or OPcache keeps old files with |
Recreate containers from the new image; only disable timestamp checks in immutable images |
"Table already exists" or a second installation on restart |
An entrypoint ran the installer on a fresh volume |
Remove installation from the entrypoint; install once with an explicit command |
Cron works locally but never on the cluster |
A CronJob without |
Give the job the same environment and volumes as the web pods |
A pod starts but images 404 on another pod |
Local files with several replicas |
Use shared storage or object storage |
The build is slow after every code change |
|
Copy |
Version notes and corrections of older material
-
Older books and tutorials such as Drupal 10 Development Cookbook (2023) and Drupal 10 Masterclass (2023) were written for Drupal 10.0 and 10.1 on PHP 8.1, with MariaDB 10.3 and MySQL 5.7 in the DDEV and hosting examples. For Drupal 11.4 the minimums are PHP 8.3, MySQL 8.0 and MariaDB 10.6, and the database images in a Compose file must follow them. Their DDEV examples use the
drupal10project type; a new site usesdrupal11(ordrupal12once it is released). -
Drupal 12 requires PHP 8.5 (with argon2 support) and Symfony 8; a Dockerfile that is meant to run both generations of Drupal should keep the PHP version as a build argument and test both.
-
Tutorials that set up a site by uploading a module or a core update through the administration interface do not apply to containers or to Drupal 11: code arrives only through Composer, in the build, and the interface cannot install or update code (removed in 11.0 and 11.2).
-
The Docker Hub description of the
drupalimage still shows a Compose example with thedrupal:10-apachetag; Drupal 10 ends its life on 2026-12-09, so use an 11.x tag. -
The
drupal/legacy-projecttemplate, which some older Dockerfiles use as a base, is abandoned as of Drupal 11.4; usedrupal/recommended-project, which places the document root inweb/. -
From Drupal 11.3 a reverse proxy, CDN or ingress in front of the containers must pass the
HX-RequestandHX-Redirectheaders. -
From Drupal 11.4 the Standard installation profile no longer creates the Article and Page content types, so an evaluation container no longer shows sample content types after installation; the Meridian content types come from the exported configuration instead (see Installation with Composer and DDEV).
-
The official image is published for Drupal 12 as release candidates; its tag list changes every release cycle, so check it when you update a base image, and see Preparing for Drupal 12 for the upgrade itself.