Keeping Composer changes in the official drupal Docker image

    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.