Configuration Management

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.

This page was checked on 2026-10-30 against Drupal 11.4.x, Drush 13 and 14 and PHP 8.3 or later. Drupal 10 reaches end of life on 2026-12-09, and Drupal 12.0.0 is scheduled for the week of 2026-12-07, so nothing here recommends starting a new site on Drupal 10. The configuration system described below is the same on all of those versions; where a detail differs it is called out. The page is aimed at site builders, administrators and developers who need to move a site’s structure between a laptop, a continuous-integration job, staging and production without redoing it by hand.

The running example is the Meridian Museum, a fictitious regional science museum. Its site has a custom module meridian_collection, a theme meridian, a recipe meridian_events and three environments: a DDEV site on each developer’s laptop, a staging site and the production site. Every example below belongs to that site.

The problem configuration management solves

A Drupal site is made of two very different kinds of data. The first is content: the exhibitions, news items and collection objects that editors write every day and that exist only in the database of the site where they were written. The second is configuration: the content types and their fields, the views, the roles and permissions, the text formats, the enabled modules and themes, the image styles and every setting on an administration form. Configuration is what a developer builds once on a laptop and then needs to see on staging and production, identical to the last setting.

Before Drupal 8 that second kind of data was scattered over database tables, and the usual way to move it was to rebuild it by hand or to export it in code with a module such as Features. Since Drupal 8 the configuration is a first-class concept with its own API, its own storage and a synchronisation workflow: export the active configuration to YAML files, commit them to Git, and import them on the other environment. The official overview of the idea is the Drupal User Guide chapter on extending and customising a site (sections 11.9 and 11.10) and the Configuration API documentation.

Three terms are often confused, and the distinction drives everything else on this page:

  • Configuration is data that describes how the site is built and that should travel from development to production. It is usually created while building the site and not by ordinary visitors.

  • Content is data that belongs to the site where it is created: nodes, users, comments, media items, files and taxonomy terms. Content entities are not exported by configuration management.

  • State (the State API) holds values that are specific to one environment and never synchronised: the last cron run time, the maintenance-mode flag set by Drush, the timestamp of the last search index run. State does not belong in configuration, and resetting state does not touch configuration.

The Configuration API overview states the same split: configuration is the information meant to "synchronize from development to production", and values that should not move belong in the State API.

Where configuration lives

Active storage

By default Drupal keeps the active configuration, the configuration the running site actually uses, in the database, in the config table. Reading it from the database is faster and scales better than parsing files on every request. The active configuration is reached through the config.factory service and, below it, the config.storage service, which in a default install is backed by the database storage class. Nothing on disk is read at runtime for active configuration, which is why editing a YAML file does nothing until an import is run.

The sync directory

The second location is the sync directory, a folder of .yml files, one per configuration object, that holds an exported copy of the active configuration. It is the folder under version control. Its path is set in settings.php:

<?php

// settings.php
// A directory outside the web root keeps the exported files out of reach of visitors.
$settings['config_sync_directory'] = '../config/sync';

A relative path is resolved from the Drupal root, so ../config/sync means a config/sync folder next to the web directory in a Composer project, and it is the layout used by the drupal/recommended-project template and by DDEV. The folder is read through the config.storage.sync service. The sync directory should not be inside web because the files describe the site’s structure, its enabled modules and some environment details, and there is no reason to serve them. See settings.php and Environments for the rest of the file and Project Layout and Local Development for the folder layout.

Older tutorials written for Drupal 8.0 to 8.7 set the directory with $config_directories['sync'] or $config_directories[CONFIG_SYNC_DIRECTORY], often pointing at a randomly named folder inside sites/default/files. That variable was replaced by $settings['config_sync_directory'] in Drupal 8.8, and the old form no longer works.

Extension default configuration

The third location is inside modules, themes and install profiles. An extension ships its default configuration as YAML in two folders:

  • config/install is imported when the extension is installed, and installation fails if one of its dependencies is missing.

  • config/optional is imported on installation only for those files whose dependencies are all satisfied, and the rest are skipped silently. It is the right place for configuration that refers to an optional module, for example a view that needs the Search API module.

After installation the shipped files are only a starting point: the active copy lives in the database and the module’s files are never read again, which is why changing config/install in an installed module needs an update function or a manual import, not just a cache clear. Values that depend on runtime data are set in hook_install(), which, like hook_uninstall(), must stay a procedural function in the .install file even where other hooks can be object-oriented.

The Configuration Storage page adds a workflow for developing a module’s configuration: edit the YAML in the sync folder, run drush cim, run drush cr, and copy the result into the module’s config/install folder when it is right. In practice most developers do the reverse, which is to build the configuration in the administration interface, export it and copy the files that belong to the module.

Overview of the places

flowchart LR EXT[Module and theme defaults<br/>config/install and config/optional] -->|install| ACT[(Active configuration<br/>database)] ACT -->|config:export| SYNC[Sync directory<br/>config/sync YAML files] SYNC -->|config:import| ACT SYNC --- GIT[Git repository] SET[settings.php $config overrides<br/>and config.factory.override services] -.->|applied at read time| RUN[Values the site uses] ACT --> RUN

Simple configuration and configuration entities

The Configuration API has two flavours, and the choice is made by the module author.

Simple configuration is for singletons: exactly one object per name. The site name and slogan in system.site, the performance settings in system.performance and the settings of the Meridian module in meridian_collection.settings are simple configuration. The file name is the object name plus .yml, and the name is conventionally module_name.something.

Configuration entities are for sets: many objects of one type, each identified by an ID. Content types (node.type.*), fields (field.field.* and field.storage.*), views (views.view.*), roles (user.role.*), image styles, text formats and block placements are configuration entities. They have a UUID, a label, a dependency list and the ability to be created, edited and deleted through the Entity API.

A module’s own settings might look like this. The first file is shipped in the module and the second is what a configuration export produces for a configuration entity that the museum’s editors build in the interface.

# modules/custom/meridian_collection/config/install/meridian_collection.settings.yml
# Simple configuration: one object, named after the file.
sync_enabled: true
api_base_url: 'https://collections.example.test/api/v2'
batch_size: 50
display_policy:
  show_on_display_only: true
  default_gallery: 'main-hall'
# config/sync/node.type.exhibition.yml
# A configuration entity exported by drush config:export.
uuid: 5b1f2d9a-3e5e-4a6f-9d21-0b4f8a7c1e11
langcode: en
status: true
dependencies:
  module:
    - menu_ui
third_party_settings:
  menu_ui:
    available_menus:
      - main
    parent: 'main:'
name: Exhibition
type: exhibition
description: 'A temporary or permanent exhibition with dates, a gallery and related objects.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: false

Two practical consequences follow. Simple configuration has no uuid of its own, and its identity is its name. A configuration entity with the same ID on two sites is only the same object if the UUIDs match, which is the subject of the section on UUIDs below. Code that reads and writes configuration, the config factory, overrides, the Config and ConfigEntityBase classes and how to write a configuration entity type, is covered in Configuration API and Schema; this page treats it from the point of view of moving configuration around.

Reading and writing simple configuration from code and from the command line is quick to try:

# Show a value; --include-overridden shows the value after settings.php overrides.
ddev drush config:get system.site name
ddev drush config:get system.performance --include-overridden

# Change one value in the active configuration (then export it).
ddev drush config:set system.site name 'Meridian Museum' -y
ddev drush config:set meridian_collection.settings batch_size 100 -y

# Edit a whole object in $EDITOR.
ddev drush config:edit meridian_collection.settings

Configuration schema

Every configuration object that Drupal core and well-behaved modules write has a schema: a description, in YAML, of the keys the object may contain and the type of each value. Schema files live in the config/schema directory of the extension and are named module_name.schema.yml. Shared base types such as config_object, config_entity, label, text and boolean are defined in core/config/schema/core.data_types.schema.yml.

The schema exists for four reasons:

  • Typecasting. When configuration is saved the values are cast to the declared types, so '1' becomes 1 for an integer key.

  • Translation. Only values whose type is translatable (label, text and date_format) appear on the configuration translation forms. Declaring a string key as string makes it invisible to translators, while label or text makes it translatable. This is the schema mistake most often seen in custom modules, and it is described with the multilingual workflow in Multilingual.

  • Validation. Schema-aware validation constraints check imported and edited configuration. Tests can enforce strict schema checking, which turns a missing schema into a failing test.

  • Recipes and config actions. Recipes use the schema to apply configuration actions safely, see Recipes.

A schema for the museum’s settings file above:

# modules/custom/meridian_collection/config/schema/meridian_collection.schema.yml
meridian_collection.settings:
  type: config_object
  label: 'Meridian collection settings'
  mapping:
    sync_enabled:
      type: boolean
      label: 'Synchronise objects from the collections API'
    api_base_url:
      type: uri
      label: 'Collections API base URL'
    batch_size:
      type: integer
      label: 'Objects per batch'
    display_policy:
      type: mapping
      label: 'Display policy'
      mapping:
        show_on_display_only:
          type: boolean
          label: 'Show only objects on display'
        default_gallery:
          type: string
          label: 'Default gallery'

The vocabulary used above: a mapping is a key-value structure in which every key is declared and has its own type; a sequence is a list whose keys are not known in advance, so the schema declares only the type of the values and may set orderby: key or orderby: value to sort the entries on save. The basic types are boolean, email, integer, float, string and uri; core adds label, plural_label, text, uuid, path, date_format, color_hex, machine_name and weight. When a key has no schema its type is undefined, and ignore means that no typing is possible. The _core key, which holds the default_config_hash of configuration installed from an extension, and everything under it is reserved for Drupal core, and langcode is inherited from the config_object base type.

A missing or wrong schema is not reported by a normal request. It surfaces as an error when the configuration translation form is opened, as an SchemaIncompleteException in tests that enable strict checking, or as a value that silently changes type. The Configuration Inspector contrib module lists schema problems for the whole site and is a standard tool before any release.

UUIDs and the site UUID

Each configuration entity carries a uuid. When an import finds a file whose name matches an existing entity but whose uuid differs, Drupal treats it as a different object and refuses to overwrite one with the other. The same safeguard applies to the whole site: system.site has a uuid key, and configuration can only be imported into a site that has the same one. This is checked before an import begins, by an event subscriber in the System module (SystemConfigSubscriber::onConfigImporterValidateSiteUUID).

The result is the first problem every team meets. Two developers who each run the installer on their own laptop get two different site UUIDs, and the second developer’s import of the first developer’s exported configuration fails with a message that the site UUID in the source storage does not match the target storage. The same happens when a staging site is created by installing Drupal fresh instead of cloning the production database.

The rule that prevents it: install the site once, export, and make every other environment from a copy of that site or from that exported configuration, never from a second independent installation. There are three ways to do it.

# 1. Best: create the other environments from a database dump of the first one.
ddev drush sql:dump --result-file=../backups/meridian.sql
ddev import-db --file=../backups/meridian.sql.gz   # run on the other machine

# 2. Install a fresh site directly from the exported configuration. The sync
#    directory is imported during the installation, so the UUIDs match.
ddev drush site:install minimal --existing-config --account-name=admin -y

# 3. Last resort on an existing empty site: copy the UUID from the sync files.
ddev drush config:set system.site uuid "$(grep '^uuid:' config/sync/system.site.yml | cut -d' ' -f2)" -y
ddev drush config:import -y

--existing-config is documented as "Configuration from sync directory should be imported during installation". The installation profile named in the command must be listed in the exported core.extension, and the third option should be reserved for fresh sites that hold no content, because it only edits the number and does not reconcile the configuration entities that were created by the second installation. When a fresh install already created entities such as the default shortcut set, their UUIDs differ from the exported ones and the import can fail on them as well; deleting those entities before importing is the usual remedy.

The Standard installation profile, in Drupal 11.4, no longer creates the Article and Page content types. Tutorials that begin with "open the Article content type" and use a fresh install with site:install standard therefore have to start by creating the content types, and a configuration export taken from a Standard install no longer contains node.type.article and node.type.page unless a team creates them.

Configuration dependencies

A configuration entity cannot exist without the things it refers to. A field storage needs its entity type’s module, a field needs its storage and its bundle, a view needs the module that provides its base table. Drupal records this in the entity’s dependencies key, in four lists: module, theme, config and content. The entity-level rule is stated in the documentation as: a configuration entity’s dependencies must be installed before the entity can be installed.

# config/sync/field.field.node.exhibition.field_hero_image.yml (excerpt)
langcode: en
status: true
dependencies:
  config:
    - field.storage.node.field_hero_image
    - media.type.image
    - node.type.exhibition
  module:
    - media
id: node.exhibition.field_hero_image
field_name: field_hero_image
entity_type: node
bundle: exhibition
label: 'Hero image'

The dependency list is calculated, not written by hand. ConfigEntityBase::preSave() calls ConfigEntityInterface::calculateDependencies(), and an implementation adds each dependency with addDependency(), usually after calling the parent method, which resets the list and adds the providers of any plugins the entity uses. Developers who extend a core base class and use the standard plugin APIs get the right dependencies for free. The enforced key lets a module say that a configuration entity must be removed when that module is uninstalled; it is mostly used by install profiles and recipes.

Dependencies matter for three operations:

  • Import. The importer sorts the changes so that an entity is created after those it depends on and deleted before them. A file that refers to a module that is not in core.extension fails validation.

  • Uninstall. Uninstalling a module first lists the configuration entities that depend on it and deletes them with it. The confirmation page lists them, and Drush shows the same information when a module is uninstalled.

  • Export hygiene. A dependency on a content entity (content:) refers to its UUID and is the reason some configuration, for example a block that embeds a specific block-content item, behaves badly when the content is missing on the target.

Exporting and importing with Drush

The everyday commands, with their documented aliases and options (Drush 13 and 14):

Command Alias What it does

drush config:export

cex, config-export

Writes the whole active configuration to the sync directory. Options: --diff (show a diff instead of a list of changes), --destination (export to another directory, or to a backup directory when no value is given), --add (run git add -p afterwards), --commit and --message (run git add -A and commit).

drush config:import

cim, config-import

Makes the active configuration equal to the sync directory: it creates, updates and deletes. Options: --source (another directory), --partial (process only new and changed files and leave other active configuration untouched) and --diff.

drush config:status

cst

Lists the objects that differ between the active configuration and the sync directory (only in code, only in database, different).

drush config:get, config:set, config:edit, config:delete

cget, cset, cedit, cdel

Read or change one object in the active configuration.

# On the laptop: see what changed, then write it to the sync directory.
ddev drush config:status
ddev drush config:export --diff
ddev drush config:export -y

# On another environment, after pulling the commit: preview then apply.
drush config:import --diff
drush config:import -y

# Import only two files from a folder without deleting anything else.
drush config:import --partial --source=/var/tmp/meridian-fixes -y

config:import is a mirror, not a merge: anything active that has no file in the sync directory is deleted. This is why a person who creates a view directly on production, and a developer who deploys a branch that does not contain it, make the view disappear. --partial is the exception and never deletes, but it also does not apply any configuration filter (Config Split and Config Ignore, below, are skipped), so it is a tool for emergencies and not a deployment mode.

config:export rewrites every file in the sync directory and removes files for objects that no longer exist. Developers should commit it with a Git workflow that makes a changed file visible in the diff; see Git commits and pull requests.

The complete Drush reference, including the global options and the generic site-alias syntax, is in Drush.

The configuration UI

The administration interface offers the same operations under Configuration > Development > Configuration synchronization (/admin/config/development/configuration) when the Configuration Manager module (config) is enabled. That module is enabled by the Standard profile but is typically uninstalled on production, where the Drush commands are used instead.

  • Synchronize (/admin/config/development/configuration) shows the pending differences between the sync directory and the active configuration, lets an administrator view the line-by-line difference of a changed item and has the Import all button.

  • Import > Full archive (/admin/config/development/configuration/full/import) uploads a tar.gz produced by the full export; Export > Full archive (/admin/config/development/configuration/full/export) downloads it. The User Guide’s section 11.10 describes this flow: export the archive, upload it on the destination site, review the pending changes and click Import all.

  • Import > Single item (/admin/config/development/configuration/single/import) and Export > Single item (/admin/config/development/configuration/single/export) move one object. The exporter shows the YAML in a text area for the chosen type (for example View) and name; the importer takes pasted YAML, validates it and shows a confirmation page.

The User Guide’s section 11.9 walks through moving a single view from a development site to production in exactly this way: on the development site open Export > Single item, choose the configuration type View and the view, copy the text; on the production site open Import > Single item, choose View, paste, click Import and then Confirm. The guide notes that a content type with fields comprises several configuration items that must be transferred in the correct order, and suggests syncing the full configuration of a cloned site or, for bundles of related functionality, the contributed Features module.

The single-item importer is useful for experimenting and for copying an example from documentation. It is a poor deployment tool for three reasons: the change is not in version control, the next full import from the sync directory deletes it, and the pasted uuid and dependencies are not checked against the destination site as strictly as a full import. In a team the right place to change configuration is the laptop, followed by an export and a commit.

How an import works internally

The import is performed by Drupal\Core\Config\ConfigImporter, which compares two storages with a StorageComparer: the source (the sync directory) and the target (the active storage). The comparer produces a change list per collection (create, update, delete and rename), and the importer runs it in steps, as a batch in the UI and in a loop in Drush:

  1. Validation. ConfigImporter dispatches the event ConfigEvents::IMPORT_VALIDATE; subscribers throw ConfigImporterException to stop the import. Core subscribers check the site UUID, that no module in the new core.extension is missing from the codebase, that the configuration of an uninstalled module is also removed and that the dependency graph is consistent.

  2. Extension changes. Modules and themes in the new core.extension are installed and uninstalled first, so that their configuration schema and entity types exist before the entities are processed.

  3. Configuration changes. Simple configuration and configuration entities are created, updated and deleted in dependency order. A configuration entity is saved through ConfigEntityStorage::importCreate() and importUpdate(), which means entity presave and postsave hooks run for each of them and a slow hook_entity_presave() implementation slows every deployment.

  4. Finalise. ConfigEvents::IMPORT is dispatched so that modules can react, caches are cleared and the import is recorded.

A module can take part by subscribing to these events, for example to refuse an import that would disable the museum’s sync module while an import job is running:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\EventSubscriber;

use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Config\ConfigImporterEvent;
use Drupal\Core\State\StateInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Stops a configuration import while the collections sync is running.
 */
final class ConfigImportGuard implements EventSubscriberInterface {

  public function __construct(private readonly StateInterface $state) {}

  public static function getSubscribedEvents(): array {
    return [ConfigEvents::IMPORT_VALIDATE => 'onImportValidate'];
  }

  public function onImportValidate(ConfigImporterEvent $event): void {
    if ($this->state->get('meridian_collection.sync_running', FALSE)) {
      $event->getConfigImporter()->logError('The collections sync is running; try again later.');
    }
  }

}

The subscriber is registered in meridian_collection.services.yml with the event_subscriber tag; see Events and Subscribers. Note the use of the State API for a flag that is specific to one environment, which is precisely what configuration is not for.

Configuration is also stored in collections. The default collection holds the base configuration; additional collections hold per-language overrides (language.en, language.es) so that translated configuration, such as the Spanish version of the site name, is exported as sync/language/es/system.site.yml and imported along with the rest. The languages and the translation workflow are in Multilingual.

The deployment workflow

A reliable workflow has the same shape on every project, whatever the hosting. Configuration and code move together, as one commit, and the target environment applies them in a fixed order.

sequenceDiagram actor Dev as Developer (DDEV) participant Git as Git repository participant CI as CI pipeline participant Stg as Staging participant Prod as Production Dev->>Dev: change configuration in the UI or code Dev->>Dev: drush config:export Dev->>Git: commit code and config/sync, open pull request Git->>CI: trigger build CI->>CI: composer install --no-dev, drush config:status check CI->>Stg: deploy build artefact Stg->>Stg: drush deploy (updatedb, config:import, cache:rebuild, deploy:hook) Dev->>Stg: review result CI->>Prod: deploy same artefact after approval Prod->>Prod: drush deploy

drush deploy

drush deploy is a single command that runs the steps in the right order. The Drush documentation lists them as drush updatedb, drush config:import, drush cache:rebuild, drush deploy:hook and, in Drupal 11.2 and later, drush cache:warm. The command takes no configuration of its own; to change how one step behaves, set Drush configuration for that subcommand (for example for updatedb or config:import) in a drush.yml.

# Deployment script for staging and production (run from the project root).
composer install --no-dev --optimize-autoloader
vendor/bin/drush state:set system.maintenance_mode 1 --input-format=integer
vendor/bin/drush deploy -v -y
vendor/bin/drush state:set system.maintenance_mode 0 --input-format=integer

Source: Drush: deploy

The order is deliberate. Database updates run first because configuration to be imported may need columns and entity type definitions that only an update creates; configuration is imported second; the cache is rebuilt so that the new configuration is visible; and deploy hooks run last, when both schema and configuration are current. Drush documents three kinds of update function and their position in the sequence:

  • hook_update_N() is a Drupal update function for low-level changes, with the caution that the full API is not available.

  • hook_post_update_NAME() is a Drupal post-update function and runs before the configuration is imported, as part of the updatedb step.

  • hook_deploy_NAME() is a Drush function that runs after the configuration is imported, in the deploy:hook step.

A deploy hook is the right place for work that needs the new configuration to exist, such as filling a field that was added by the same release. It lives in meridian_collection.deploy.php:

<?php

/**
 * @file
 * Deploy hooks for the Meridian collection module.
 */

use Drupal\node\Entity\Node;

/**
 * Sets the new field_gallery_label on existing exhibitions.
 *
 * Runs after the configuration import that created the field.
 */
function meridian_collection_deploy_set_gallery_label(array &$sandbox): string {
  $nids = \Drupal::entityQuery('node')
    ->accessCheck(FALSE)
    ->condition('type', 'exhibition')
    ->execute();
  foreach (Node::loadMultiple($nids) as $node) {
    $node->set('field_gallery_label', 'Main hall');
    $node->save();
  }
  return 'Updated ' . count($nids) . ' exhibitions.';
}

The accessCheck() call is mandatory in entity queries. Deploy hooks that touch many entities should use the batch pattern with $sandbox and return a message, as described in Update API and Post Updates.

Run drush deploy after every code deployment, even when nothing seems to have changed in configuration. A deployment that only runs drush config:import skips the database updates, and one that only runs drush updatedb skips the import; either leaves the site half-updated. Pipelines, artefact builds and zero-downtime concerns are in Deployment and CI/CD, and the DDEV and Docker setups in Installation with Composer and DDEV and Docker and Containers.

A continuous-integration job can fail the build when someone forgot to export, by checking that the active configuration equals the committed one:

# .github/workflows/config-check.yml (excerpt)
jobs:
  config-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist
      - name: Install site from committed configuration
        run: |
          vendor/bin/drush site:install minimal --existing-config --yes \
            --db-url="sqlite://localhost/sites/default/files/.ht.sqlite"
      - name: Fail if the export differs from the committed files
        run: |
          vendor/bin/drush config:export --yes
          git diff --exit-code config/sync

This job installs from the sync directory and exports again; any difference means that the committed files are not self-consistent, for instance a missing dependency or a value changed by an update hook that was never exported.

Typical Meridian release

  1. On the laptop, build the "Gallery" taxonomy and the Gallery label field in the interface, then run ddev drush config:export -y.

  2. Review git diff config/sync: a new vocabulary file, a field storage, a field, form and view displays and an edited core.extension are expected. Unexpected changes in system.site, system.performance or views.settings usually mean an override leaked into the export (see below).

  3. Commit code and configuration together and open a pull request.

  4. After merge the pipeline builds an artefact and runs drush deploy on staging; editors check the result.

  5. The same artefact is promoted to production and drush deploy runs again.

Environment differences

Some configuration must not be the same everywhere: a development module such as Devel or Webprofiler, a different search server, a different caching back end, an analytics key or a different "site name" banner. Drupal has four levels of tools for that, from the least to the most invasive.

Overrides in settings.php

The simplest tool is the $config array in settings.php, which overrides configuration values at read time without changing the stored configuration.

<?php

// settings.php (staging)
$config['system.site']['name'] = 'Meridian Museum (staging)';
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.logging']['error_level'] = 'verbose';

// Secrets come from the environment and never from exported YAML.
$config['meridian_collection.settings']['api_key'] = getenv('MERIDIAN_COLLECTIONS_API_KEY');

The behaviour has several consequences:

  • The override is applied when configuration is read through the config factory with get(), and the original value is available with getOriginal('name', FALSE). A configuration object loaded with getEditable() is always free of overrides, so saving it never writes an override into the database.

  • Overrides do not appear in the administration interface, which shows the stored value, so an administrator who changes the site name on staging sees no effect. Check them with drush config:get system.performance --include-overridden, or use a contributed module such as Config Override Warn to flag them.

  • Overrides are not exported. The exported value is the stored one, which lets staging use a value that the repository does not hold.

  • Overrides apply to values that already exist. They are not a way to enable a module or to create an entity.

The override system has three layers, in order of priority: settings.php wins over overrides provided by modules, which win over language overrides. A module provides its own overrides by tagging a service with config.factory.override and implementing ConfigFactoryOverrideInterface::loadOverrides(), which returns an array keyed by configuration name. Core configuration forms do not display module overrides either.

Environment-specific values are better read from environment variables than from a per-environment copy of settings.php that is kept in Git. The full treatment of settings.php, settings.local.php and environment detection is in settings.php and Environments.

Config Split

The Config Split contributed module (project config_split) splits chosen configuration into separate directories and merges it back during import. At the time of writing its latest stable release is 2.0.2 (12 February 2025), which supports Drupal 8.8 and later up to 11, and the older 1.x line is for Drupal 9 and 10. In 2.x a split can also hold only the changed keys of an object as a config patch, which is on by default. It is a contributed module and is not part of Drupal core.

Config Split defines splits as configuration entities named config_split.config_split.NAME. Each split has a folder, a weight, a status and a list of what it holds:

  • A complete split removes the listed configuration from the main sync directory on export, so it only exists in the split folder. A development-only module such as devel is the typical case.

  • A conditional split keeps the configuration in the main sync directory and, when the split is active, writes the environment’s different version to the split folder; on import the split’s version replaces the main one.

# config/sync/config_split.config_split.local.yml (shape; created in the UI at
# /admin/config/development/configuration/config-split)
id: local
label: 'Local development'
folder: ../config/local
module:
  devel: 0
  webprofiler: 0
theme: {}
complete_list: {}
partial_list: {}
weight: 0
status: false

The split is switched on per environment by overriding its status in settings.php, since the split settings are "normal configuration" that "can be overwritten in settings.php", according to the module documentation. That is how a single exported configuration enables the local split on laptops and not on production:

<?php

// settings.local.php, loaded only on developer machines
$config['config_split.config_split.local']['status'] = TRUE;

The documentation advises not to override the settings that decide what is written to the split folder (the complete and partial split lists), only the status, weight and folder. The 1.x series called the partial list the "graylist", which explains that term in older documentation.

The usual commands keep working: with a recent Drush the module documentation says the standard drush cex && drush cim workflow honours the splits, and the dedicated commands drush config-split:export NAME and drush config-split:import NAME act on a single split without touching the main sync directory. The Drupal native config commands of other consoles are not recommended with splits. Because a split changes what core.extension contains per environment, import order is: the main directory, then the active splits by weight.

Config Ignore

Config Ignore (project config_ignore) takes the opposite approach: instead of storing the different version somewhere, it makes the importer skip chosen configuration, so that production keeps the values its administrators changed. Its latest release is 8.x-3.4 (25 April 2026) for Drupal 8.8 and later up to 11, and its settings page is at /admin/config/development/configuration/ignore.

  • Enter configuration object names, for example system.site or meridian_collection.settings, one per line. The module changes the configuration being imported so that it matches the active one, so the import finds no difference for those items.

  • Simple mode ignores on import and export; intermediate mode has separate lists for import and export; advanced mode has six text areas.

  • To update an ignored item deliberately, use the single-item import.

  • $settings['config_ignore_deactivate'] = TRUE; in settings.php switches the module off, useful for a one-off full import.

  • Never ignore core.extension: doing so stops module installations from reaching the site. Environment-specific modules belong in Config Split.

  • The project is marked minimally maintained, and the 3.x series uses the core API instead of Config Filter.

The two modules suit different cases. Config Split is explicit and reproducible: the environment’s variant is in Git and reviewable. Config Ignore is permissive: the value is whatever the production administrators last saved, which is convenient for a block of editorial text and dangerous for anything the code depends on, because the repository no longer describes the site. For small differences prefer a settings.php override; for different module sets prefer Config Split; use Config Ignore sparingly for settings that editors really own.

Locking production

Some teams make the production configuration read-only, so that nobody can change it from the interface and drift is impossible. The contributed Config Read-only module does this with a settings flag; it needs a plan for the occasional urgent change, and it is not part of Drupal core. Another common measure is not to install the Configuration Manager UI on production at all, uninstalling it after the first deployment and using Drush only.

Configuration versus content

Configuration management moves structure. It does not move content, and mixing the two is the second most common source of bad deployments. The User Guide’s deployment sections are explicit about it: moving a view moves a view and does not move the Vendor nodes that it lists.

Configuration Content

Examples

Content types, fields, views, roles, text formats, image styles, enabled modules, block placements, menus' structure

Nodes, taxonomy terms, media items, files, users, comments, menu links created by editors, block-content items

Created by

Developers and site builders

Editors and visitors

Identity

Machine name, plus UUID for entities

Numeric ID plus UUID, different per site

Moves between environments by

config:export, Git, config:import

Database copy (down from production), migrations, recipes with content, or a default-content module

Direction

Laptop to production

Production to laptop and staging

The correct flow is therefore two-way: configuration flows up (laptop, staging, production) through Git, and content flows down (production to staging to laptop) by copying and sanitising the database. A developer who needs the museum’s exhibitions on a laptop copies the production database, runs drush sql:sanitize, then runs drush deploy to bring it up to the branch’s configuration.

Content that must ship with the code, such as the "Visit", "Accessibility" and "About" pages, a starter set of taxonomy terms or a default menu, is default content. Drupal has two routes: Recipes can carry content in their content directory and import it when the recipe is applied (see Recipes), and the contributed Default Content module exports and imports entities with their UUIDs. The meridian_events recipe applies the museum’s event type, fields, view and permissions to a new site. Because recipes have no update path, a recipe is applied once; it creates configuration that afterwards belongs to the site and is then exported to the sync directory like all other configuration. The relationship of a recipe’s config actions to schema is in the recipes page.

Two traps follow from the content/configuration boundary. A configuration entity may depend on content (for example a block that embeds a block-content item, or a view with a hard-coded node ID), in which case the importer on the target site fails or produces an empty block because the content does not exist there. And content that is stored as configuration, such as a text format’s help text or a menu link kept in a configuration entity, reaches production only through an export, which is a reason to keep editorial text out of configuration. Workspaces are a separate concept that stage content changes, not configuration; see Workspaces.

Common errors and pitfalls

Symptom Cause and remedy

The import stops with a message that the site UUID in the source storage does not match the target storage.

The two sites were installed independently. Create the target from a database copy, install it with --existing-config, or set system.site uuid to the value in the sync files on an empty site.

config:import deletes a view or block that was created on production.

Import mirrors the sync directory. Create configuration only on the laptop and export it, or use Config Ignore for the few items that editors own.

config:export shows a long diff of unrelated files.

Different module versions, or an environment that was not imported before exporting. Run config:import first, then export, and always drush deploy after pulling.

A change in settings.php has no effect, or an edit in the UI "does not save".

A $config override hides the stored value. Run drush config:get NAME --include-overridden and remove the override.

Edited YAML in config/install has no effect on an installed module.

Active configuration is in the database. Write an update function or import the changed file.

Translators cannot see a setting.

The schema type is string; use label or text for translatable values.

The import reports a dependency or missing-module error.

core.extension lists a module that is not in the deployed code. Run composer install for the same commit before the import, and keep composer.lock and config/sync in step.

A module that was removed from the code is still listed in core.extension.

Uninstall the module (and export) before removing it with Composer. Removing a module’s code first leaves the site unable to boot until the module is restored.

An API key shows up in a Git diff.

A secret was saved into a configuration object and exported. Move it to an environment variable and a settings.php override or to the Key module, rotate the key and clean the history. AI provider keys follow the same rule.

Cache problems after a deployment.

drush deploy rebuilds caches; a script that imports configuration without it can leave stale render and config caches.

Security

  • Secrets stay out of the sync directory. The exported files are committed, and they are read by everybody with access to the repository. Database passwords and the hash_salt belong in settings.php read from the environment. API keys that a module stores in configuration should come from the Key module, an environment variable read in a settings.php override or an equivalent mechanism. Scan the sync directory in CI for credentials.

  • The sync directory is outside the web root. The files list modules, field names, permissions per role and internal URLs. Even if they are harmless individually, together they help an attacker.

  • Permissions in configuration are security-relevant. user.role.*.yml files list each role’s permissions. Review role changes in a pull request as carefully as code, and keep the Administer configuration and Synchronize configuration permissions with administrators only; a person who can import configuration can grant themselves any permission. See Users, Roles and Permissions.

  • Uninstall the Configuration Manager UI on production if only Drush is used, which removes the browser-based path to full import and single-item import.

  • Treat imported YAML as code. A configuration import can enable modules, change text formats (an input format that allows unsafe HTML is a stored cross-site scripting risk), change trusted settings and replace views with PHP-evaluated fields. Only deploy configuration from reviewed commits.

  • Super-user and registration defaults are configuration too. Registration is set to administrators only on new installs since Drupal 11.1, and the security.enable_super_user container parameter can disable user 1’s bypass; verify that the exported user.settings does not loosen them. See Security Hardening.

Version notes

  • The $settings['config_sync_directory'] setting has existed since Drupal 8.8; the earlier $config_directories array is gone.

  • The Standard profile does not create the Article and Page content types in 11.4, so exports from new sites no longer contain them.

  • drush deploy includes cache:warm when Drupal is 11.2 or later; the other four steps are unchanged.

  • Drush 13 serves Drupal 10.2 and 11, and Drush 14 serves Drupal 11.3 and 12. Install Drush per project with composer require drush/drush.

  • Config Split 2.0.x and Config Ignore 3.x support Drupal 11, and Config Split 1.x is limited to Drupal 9 and 10. Check each project page before upgrading to Drupal 12.

  • Drupal 10 ends on 2026-12-09 and 10.6 is its last minor; sites upgrading from 10.6 go to 11.3 or later. Drupal 12 refuses to upgrade from 11.2 or earlier, so a team that keeps its configuration current on 11.4 or 11.5 has the least work. In 12, query objects are no longer serialisable, which concerns any custom code that stores an entity query.

  • Several modules that were core in Drupal 10 left it in Drupal 11 (Statistics, Book, Forum, Tour and others) and more leave in 12. Install the contributed version of any removed module in use before upgrading and do not uninstall it, or its configuration is deleted with it and the next config:import removes it for good. See Removed Core Modules and Replacements and Preparing for Drupal 12.

  • Recipes (in core since 10.3) apply configuration with config actions and have no update path: after being applied the site is ordinary Drupal.

Corrections to older material

Older books and tutorials describe the workflow correctly in outline but differ in details. Drupal 10 Development Cookbook (Glaman and Quillen, 2023) teaches the Composer, DDEV and drush deploy workflow for Drupal 10.0 and Drush 12; since then drush deploy has gained the cache:warm step (Drupal 11.2 and later), Drush 13 removed drush.services.yml in favour of attributes and autowiring, and the Standard profile has stopped creating Article and Page (11.4). Drupal 10 Masterclass (Bergstein, 2023) covers Git, Drush and DevOps for Drupal 10.0 and 10.1 but does not cover Config Split, Config Ignore, Workspaces or Drupal CMS, which this page and Workspaces cover. Tutorials from the Drupal 8 era use $config_directories, the Features module for deployment and drush config-export with a hyphen, and none of those is the current practice. The consolidated list is in Where Older Books and Tutorials Are Outdated.

Meridian walkthrough: first deployment

The commands below take a new project from nothing to a repeatable pipeline. They assume the DDEV project from Installation with Composer and DDEV.

# 1. Install once, on one machine only (config_sync_directory is already set in settings.php).
ddev drush site:install minimal --account-name=admin --site-name='Meridian Museum' -y
ddev drush config:get system.site uuid   # this UUID travels in config/sync/system.site.yml

# 2. Build the site in the UI or by recipe, then export and commit.
ddev drush config:export -y
git add config/sync && git commit -m "Initial Meridian configuration"

# 3. A second developer clones and installs from the exported configuration.
git clone https://example.test/meridian/site.git && cd site
ddev start && ddev composer install
ddev drush site:install minimal --existing-config --account-name=admin -y

# 4. From now on every environment is updated with one command.
ddev drush deploy -y

The second command in step 1 only displays the site UUID; the same number must be present in the sync files and on every environment. Install once on one machine, and create the others from its database or its exported configuration.