Drupal

By amirul , 19 September 2026

The official drupal image is a convenient way to run Drupal 11, but the common Compose setup has a trap: it mounts only the web root. Anything you install with Composer ends up split between a persisted directory and the container's writable layer, and the next docker compose up after an image update loses half of it.

The layout inside the image

docker exec drupal_app ls -la /var/www/
# html -> /opt/drupal/web

docker exec drupal_app ls /opt/drupal
# composer.json  composer.lock  recipes  vendor  web

The image is a drupal/recommended-project install. /var/www/html is a symlink to /opt/drupal/web, and WORKDIR is /opt/drupal. The Composer project (composer.json, composer.lock, vendor/) lives one level above the web root.

Now the Compose file most tutorials show:

volumes:
  - ./data/drupal:/var/www/html

Only web/ is persisted. Run composer require drupal/pathauto drush/drush and:

  • the module lands in web/modules/contrib/, which is persisted
  • its PHP dependencies and Drush land in vendor/, which is not
  • the updated composer.json and composer.lock are not persisted either

Everything works until the container is recreated. Then vendor/ reverts to the image's copy, the module's classes are missing, and the site fatals.

Persist the whole project

Copy the project out of the running container once, keeping ownership (the files directory belongs to www-data):

mkdir /opt/drupal11/data/app
docker exec drupal_app tar -C /opt/drupal -cf - . | tar -C /opt/drupal11/data/app -xpf - --numeric-owner

Then mount it at /opt/drupal instead of the web root:

services:
  drupal:
    image: drupal:11-apache
    volumes:
      - /opt/drupal11/data/app:/opt/drupal

The symlink /var/www/html -> /opt/drupal/web still resolves, so Apache needs no changes. Composer, Drush and your config all survive recreation now.

This layout also gives you proper places for directories that should sit outside the web root:

// settings.php
$settings['config_sync_directory'] = '../config/sync';
$settings['file_private_path'] = '/opt/drupal/private';

Create them owned by the web user:

install -d -o www-data -g www-data -m 2770 data/app/config/sync data/app/private

Run Drush without typing docker exec

A small wrapper on the host:

#!/bin/bash
# /usr/local/bin/drush
flags=(-i)
[ -t 0 ] && [ -t 1 ] && flags=(-it)
exec docker exec "${flags[@]}" -u www-data drupal_app vendor/bin/drush "$@"

The TTY check matters. A hard-coded -it fails with "the input device is not a TTY" when the wrapper runs from cron or a script.

Point Drush at the public URL so generated links are correct:

# data/app/drush/drush.yml
options:
  uri: "https://example.com"

Settings that environment variables will not change

PHP_MEMORY_LIMIT in the Compose environment: block looks like it should work. The official image does not read it:

docker exec drupal_app php -i | grep ^memory_limit
# memory_limit => 128M => 128M

Put PHP settings in an ini file instead, either mounted or baked into a small derived image:

FROM drupal:11-apache
RUN pecl install apcu && docker-php-ext-enable apcu
COPY php/zz-drupal.ini /usr/local/etc/php/conf.d/zz-drupal.ini
memory_limit = 512M
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
realpath_cache_size = 4096K
apc.shm_size = 128M

The image's default opcache.max_accelerated_files is 4000, which is fewer files than Drupal core and its dependencies contain. Raising it stops opcache from evicting scripts it will need again on the next request. APCu gives Drupal's chained fast cache backend a local memory store for the bootstrap, config and discovery bins.

Verify inside the new container:

docker exec drupal_app php -r 'echo ini_get("memory_limit"), " ", extension_loaded("apcu") ? "apcu" : "no apcu", PHP_EOL;'

Keep credentials out of settings.php

Pass them through the environment from an .env file readable only by root:

environment:
  DB_HOST: db
  DB_NAME: ${DB_NAME}
  DB_USER: ${DB_USER}
  DB_PASSWORD: ${DB_PASSWORD}
  DRUPAL_HASH_SALT: ${DRUPAL_HASH_SALT}
$databases['default']['default'] = [
  'database' => getenv('DB_NAME'),
  'username' => getenv('DB_USER'),
  'password' => getenv('DB_PASSWORD'),
  'host' => getenv('DB_HOST') ?: 'db',
  'port' => '3306',
  'driver' => 'mysql',
  'namespace' => 'Drupal\\mysql\\Driver\\Database\\mysql',
  'autoload' => 'core/modules/mysql/src/Driver/Database/mysql/',
];
$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT');

With secrets gone from settings.php, you can commit it along with composer.json, composer.lock and config/sync, and ignore vendor/, web/core/ and the contrib directories.

Technology stack
Difficulty level
Intermediate
Estimated reading time
6 min
By amirul , 19 September 2026

A fresh Drupal 11 site running in the official drupal:11-apache container comes up with the page structure intact but no styling. Olivero renders as plain HTML. A few small core stylesheets load, but the main ones return 404.

The cause is usually one missing file: the .htaccess in the web root.

Symptoms

View the page source and request the stylesheets directly:

curl -s https://example.com/ | grep -o 'href="[^"]*\.css[^"]*"'
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  "https://example.com/sites/default/files/css/css_0eym....css?delta=0&language=en&theme=olivero&include=..."

The pattern to look for:

Request Result
/core/themes/olivero/css/components/...css 200 text/css
/sites/default/files/css/css_*.css 404 text/html; charset=iso-8859-1

The iso-8859-1 charset matters. That 404 page comes from Apache, not from Drupal. Drupal never saw the request.

Why aggregated CSS depends on .htaccess

Since Drupal 10.1, CSS and JS aggregates are built on demand. The page references a file under sites/default/files/css/ that does not exist yet. When the browser asks for it, Apache finds no file and rewrites the request to index.php. Drupal then builds the aggregate, writes it to disk and serves it. Later requests hit the file directly.

That rewrite lives in the web root's .htaccess:

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !=/favicon.ico
RewriteRule ^ index.php [L]

Without it, Apache answers the missing file with its own 404 and the aggregate is never generated. Individual core files still load because they exist on disk, which is why the site looks half-styled rather than completely broken.

How the file goes missing

The usual culprit is copying the web root with a glob:

cp -r /source/web/* /target/web/

The shell glob * skips dotfiles, so index.php, robots.txt and update.php arrive and .htaccess does not. Check with ls -la, not ls:

ls -la /opt/drupal/web | grep htaccess

Fix

Drupal core ships the canonical file in its scaffold assets. Restore it from there:

docker exec drupal_app cp /opt/drupal/web/core/assets/scaffold/files/htaccess /opt/drupal/web/.htaccess
docker exec drupal_app chmod 644 /opt/drupal/web/.htaccess

If you manage the project with Composer, composer drupal:scaffold puts back every scaffold file, including .htaccess and robots.txt.

No cache rebuild is needed. Reload the page and the aggregates are generated on the first request.

Check that it also protects you

The same .htaccess blocks direct access to files that should never be served. With it missing, those protections are gone too. After restoring it:

curl -s -o /dev/null -w "%{http_code}\n" https://example.com/core/core.services.yml
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/sites/default/settings.php

Both should return 403. If you get 200 for the YAML file, the rewrite and access rules are still not being applied: check that mod_rewrite is enabled and that the virtual host allows AllowOverride All for the web root.

When copying a web root in the future, copy the directory itself (cp -a web /target/) or use rsync -a so dotfiles come along.

Technology stack
Difficulty level
Foundational
Estimated reading time
4 min