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

docker run drupal

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 php image (the Apache variant, with mod_php, or the PHP-FPM variant), on Debian or Alpine

  • it enables the Apache modules expires and rewrite that the Drupal .htaccess file references

  • it installs the PHP extensions gd (with AVIF, FreeType, JPEG and WebP support), pdo_mysql, pdo_pgsql and zip

  • it writes recommended OPcache settings and turns on output_buffering

  • it copies Composer 2 into the image

  • it runs composer create-project drupal/recommended-project for one pinned Drupal version into /opt/drupal and replaces /var/www/html with a symbolic link to /opt/drupal/web

  • it makes web/sites, web/modules and web/themes writable by www-data and adds /opt/drupal/vendor/bin to 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 own composer.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/drupal but does not touch volumes, so the mounted modules, themes and sites directories 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

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:

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"

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 .ddev/config.yaml; DDEV generates the Compose files

Every service, network and volume is written and reviewed by you

Web server and PHP

Chosen by webserver_type and php_version; one image built by DDEV

Whatever your Dockerfile contains, identical to production if you reuse the image

HTTPS and hostnames

A router with local certificates and *.ddev.site names, no /etc/hosts edits

You add a reverse proxy such as Traefik or Caddy

Drupal settings

Writes settings.ddev.php with the database connection, and includes it from settings.php

You provide the connection through environment variables read by settings.php

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

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. Add drush/drush as a normal (non-dev) requirement, because deployment and cron need it (see Drush).

  • Run Composer from the lock file. composer install honours composer.lock; composer update inside a Dockerfile produces an image nobody reviewed. The workflow is in Updates, Composer and Security Advisories.

  • Do not copy settings.local.php, development.services.yml or the .ddev directory. Add them to .dockerignore together with .git, node_modules and web/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_pgsql and zip; add apcu, redis or opcache explicitly 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

sites/default/files (public://)

Uploaded media, image derivatives, aggregated CSS and JavaScript

A named volume, a shared file system or an object storage module

The private files directory (private://)

Files with access control

A separate volume that is outside the web root

The temporary directory (file_temp_path)

Upload scratch space

Container-local storage; it need not be shared

config/sync

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. The settings.php file is code and belongs to the image; mounting the whole sites/default hides 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 with securityContext.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:

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 sleep loop 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 calling docker 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 --uri to Drush, or links generated by cron (notification emails, absolute URLs in queued items) point at http://default.

  • A long queue such as the meridian_collection synchronization worker can run as its own container with a loop around drush 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).

flowchart LR CI[CI pipeline builds one image] --> R[(Registry)] R --> J[Job: drush deploy] J --> D[Deployment: web pods] R --> D R --> C[CronJob: drush cron] I[Ingress with TLS] --> S[Service] --> D D --> DB[(Managed database)] C --> DB J --> DB D --- F[(Shared files volume or object storage)] C --- F

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 ReadWriteMany claim (the storage class must support it) or object storage through a stream wrapper module. A ReadWriteOnce volume mounted by one pod works only for a single replica.

  • Secrets: the database password and hash_salt live in a Secret exposed as environment variables or files, never in the manifest or the image.

  • Release job: drush deploy runs 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 Host header, as with the Compose health check above, or the trusted_host_patterns check 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_proxy and its trusted headers; from Drupal 11.3 the proxy must also forward the HX-Request and HX-Redirect headers 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

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-dependencies to 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.php and the Status report reachable only to authenticated administrators; the hardening checklist is in Security Hardening.

  • The .htaccess that protects the files directories works only under Apache. With nginx, PHP execution inside sites/default/files must 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 settings.php after mounting a volume on sites

An empty volume hid the image’s sites/default directory

Pre-seed the volume as in the evaluation Compose file, or mount only the files directories

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 (www-data), or set fsGroup in Kubernetes

Every request returns 400 "The provided host name is not valid"

trusted_host_patterns does not list the name the request used, including a health check using localhost

Add the host name or send a Host header from the probe

Redirect loops or http:// links behind a proxy

reverse_proxy and the trusted headers are not configured

Configure them in settings.php

Image changes do not show up

The old container still runs, or OPcache keeps old files with validate_timestamps=0

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 --uri, no files volume or no database access

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 . before composer install invalidates the layer

Copy composer.json and composer.lock first, as in the example

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 drupal10 project type; a new site uses drupal11 (or drupal12 once 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 drupal image still shows a Compose example with the drupal:10-apache tag; Drupal 10 ends its life on 2026-12-09, so use an 11.x tag.

  • The drupal/legacy-project template, which some older Dockerfiles use as a base, is abandoned as of Drupal 11.4; use drupal/recommended-project, which places the document root in web/.

  • From Drupal 11.3 a reverse proxy, CDN or ingress in front of the containers must pass the HX-Request and HX-Redirect headers.

  • 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.