Drupal: Cron, Queues and Maintenance

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.

Every Drupal site has work that must happen without a visitor asking for it: checking for security updates, pruning expired caches and old log entries, re-indexing content for search, sending queued email, and in the Meridian Museum site, synchronising collection objects from a fictitious collections-management API. In Drupal that scheduled work is called cron, after the Unix scheduler, and Drupal itself never schedules it: something outside the Drupal request cycle has to trigger it. This page explains the two ways to trigger it, what a cron run does internally, how queue workers fit into it, and the related operational screens: maintenance mode, the Status report and the database updates screen.

It was checked on 2026-10-30 against Drupal 11.4.x, Drush 13 and 14 and PHP 8.3 or later. The commands work on Drupal 10.2 and later with Drush 13 (see Versions, Release Cycle and Support); Drupal 10 reaches end of life on 2026-12-09, so new sites should start on Drupal 11. The audience is the site administrator and the developer who deploys the site; the code for writing queue workers and cron hooks is in Queue, Batch and Cron APIs.

What cron is for

Cron is one PHP entry point, \Drupal::service('cron')->run(), that does three things in order:

  1. it invokes hook_cron() (or its object-oriented hook equivalent, see Hooks and Object-Oriented Hook Implementations) on every module that implements it;

  2. it processes every queue that has a worker plugin declaring a cron definition;

  3. it records the time of the run in the state system.

Core modules use it for the update status check, search index maintenance, session and flood-table cleanup, expiring cache items, deleting temporary files and old log entries (Database Logging’s row limit), and, with a mail queue module, sending messages. Contributed modules add their own: Search API indexing, Feeds imports, the Meridian meridian_collection module’s object sync.

A site whose cron does not run keeps working, which is why the failure is easy to miss. Over weeks the symptoms are: the Status report warns that cron has not run, the temporary-files directory and log tables grow, security updates are not detected, the search index goes stale, and queued work never completes.

The official pages are the Drupal User Guide chapter on Configuring Cron Maintenance Tasks and the community guide Cron automated tasks. The latter states that cron runs every three hours by default, that the interval can be set from one hour to one week, and that a run can be started manually from the Status report or from Configuration > System > Cron.

Two ways to trigger cron

flowchart LR subgraph A[Automated Cron module] V[Visitor request] --> K{Interval elapsed?} K -- yes --> R1[Run cron after the response is sent] K -- no --> N[Nothing] end subgraph B[System cron] S[crontab or timer or scheduler] --> D[drush cron] S --> U[HTTP GET /cron/KEY] end R1 --> C[Cron service] D --> C U --> C C --> H[hook_cron implementations] C --> Q[Queue workers with a cron definition] C --> L[state system.cron_last]

Automated Cron

The core Automated Cron module (machine name automated_cron) is installed by the Standard profile. On each page request it checks the time of the last run and, if the configured interval has passed, runs cron at the end of the request, after the response has been sent to the browser. The setting is at Configuration > System > Cron (/admin/config/system/cron), field "Run cron every", and is stored in the automated_cron.settings configuration object under the key interval (in seconds; 0 disables the automatic run).

The User Guide lists the drawbacks that matter in production:

  • It only runs when someone visits a page, so a site without traffic does not run cron.

  • The tasks run inside a web request, so PHP and web server time limits apply and timeouts show up as errors in the log.

  • A web server process stays busy until the tasks finish, which affects scalability.

It is a reasonable default for a local or low-traffic site and the wrong choice for anything serious. The Meridian Museum site disables it in production and turns it off in configuration so the setting is part of the exported configuration (see Configuration Management):

# Disable automatic cron on a running site
ddev drush config:set automated_cron.settings interval 0 -y

# Or uninstall the module entirely and export the change
ddev drush pm:uninstall automated_cron -y
ddev drush config:export -y

System cron: Drush or the cron URL

The alternative is to call cron from the operating system’s scheduler. The User Guide describes it as uninstalling Automated Cron and then requesting the cron URL on a schedule, and mentions the Cron daemon on Linux, macOS, Solaris and BSD, Scheduled Tasks on Windows, a cron-as-a-service provider or a cron manager at the web host. There are two mechanisms.

Drush. drush cron (alias of drush core:cron, "Run all cron hooks in all active modules for specified site") bootstraps Drupal on the command line, so it is not subject to the web server’s request timeout and does not occupy a web worker. The Drush reference suggests guarding the command so cron does not run while the site is in maintenance mode, which could poison the cache:

drush maint:status && drush core:cron

The cron URL. Drupal also exposes cron over HTTP at /cron/ followed by a site-specific key, for example https://meridian.example/cron/CHANGE_ME. The key is stored in the state system under system.cron_key and is shown on the Status report and on the Cron configuration page. Anyone who knows the URL can start a cron run, so treat it like a password: do not publish it in documentation, do not put it in a public repository, and prefer drush cron when the scheduler runs on the same host or inside the same cluster. When HTTP is the only option (a hosted cron service), fetch the URL over HTTPS and rotate the key if it leaks. The URL form is also the only choice when the server cannot run Drush at all, as on some shared hosts.

# Show the key (keep it out of logs and tickets)
ddev drush state:get system.cron_key

# Request cron over HTTP from a scheduler on another machine
curl --silent --show-error --max-time 300 "https://meridian.example/cron/${MERIDIAN_CRON_KEY}"

Do not run both mechanisms at once. Automated Cron plus a system cron job is harmless because both take the same lock, but it wastes requests and makes the Status report’s "last run" harder to read.

Scheduling examples for the Meridian Museum

The museum runs cron every 15 minutes: often enough that the collection sync queue drains promptly, rarely enough that runs never overlap. Pick an interval longer than the typical run time.

Locally with DDEV

DDEV containers do not run a scheduler for you. During development either run cron by hand or leave Automated Cron on:

ddev drush cron
ddev drush queue:list

DDEV can also schedule a command inside the web container with a small add-on or a host crontab entry that calls ddev drush cron, but a manual run is normally enough locally (see Installation with Composer and DDEV).

On a Linux server

# /etc/cron.d/meridian  (runs as the web user; flock prevents overlapping runs)
*/15 * * * * www-data cd /var/www/meridian && /usr/bin/flock -n /tmp/meridian-cron.lock vendor/bin/drush --root=web --uri=https://meridian.example cron >> /var/log/meridian-cron.log 2>&1

Always pass --uri. Without it Drush does not know the site’s base URL and links generated during cron (in emails, sitemaps or queue workers that build absolute URLs) point to http://default. A systemd timer is an equivalent alternative that adds logging through the journal and Persistent=true catch-up after downtime:

# /etc/systemd/system/meridian-cron.service
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/meridian
ExecStart=/var/www/meridian/vendor/bin/drush --root=web --uri=https://meridian.example cron

# /etc/systemd/system/meridian-cron.timer
[Timer]
OnCalendar=*:0/15
Persistent=true

[Install]
WantedBy=timers.target

In containers and Kubernetes

Containers should run cron as a separate scheduled job from the same image as the web pods, so cron never competes with web traffic for PHP workers. This is the pattern used in Docker and Containers and Deployment and CI/CD:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: meridian-cron
spec:
  schedule: "*/15 * * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: drush-cron
              image: registry.example/meridian:2026-10-30
              command: ["vendor/bin/drush", "--uri=https://meridian.example", "cron"]
              envFrom:
                - secretRef:
                    name: meridian-settings

concurrencyPolicy: Forbid is the Kubernetes counterpart of flock. Database credentials and the hash salt come from the referenced secret, never from the manifest.

What a cron run does internally

The Drupal\Core\Cron service (see the API reference) performs these steps:

  1. It calls ignore_user_abort(TRUE) so that closing the browser connection of a cron URL request does not kill the run.

  2. It switches to the anonymous user so permissions are consistent and restores the original user at the end.

  3. It raises the PHP time limit to 240 seconds for the run.

  4. It acquires the cron lock, held for up to 900 seconds. If another run holds it, the log records "Attempting to re-run cron while it is already running." and the request returns without doing the work.

  5. It invokes every module’s cron hook. An exception in one module is logged and does not stop the others.

  6. It processes the queues that declare a cron definition, described below.

  7. It stores the request time in the state key system.cron_last, releases the lock and logs "Cron run completed."

Practical consequences:

  • The 240-second limit is the budget for the whole run, so a cron hook that does heavy work (a full re-index, a large import) must move that work into a queue and process only a slice per run.

  • A crashed run leaves the lock until it expires (900 seconds); the next scheduled run during that window is skipped and logged. Do not delete the lock by hand unless you are sure no run is active.

  • Cron runs as an anonymous user, so code in hook_cron that needs privileges must say so explicitly, for example with the account_switcher service, and entity queries must call accessCheck() deliberately.

  • Lasting data written in cron (state, queue items, logs) is shared across a multi-server deployment only if the database is shared, which it is in any supported setup.

Queues and cron

A queue is a list of work items stored by a queue backend (the database by default; Redis and others via contributed modules). A queue worker is a plugin that processes one item. Cron is the default consumer of queues whose worker declares a cron definition in the plugin attribute.

For the Meridian collection sync, the module defines a worker; the code lives in src/Plugin/QueueWorker/CollectionObjectSyncWorker.php:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\QueueWorker;

use Drupal\Core\Queue\Attribute\QueueWorker;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Syncs one collection object from the collections-management API.
 */
#[QueueWorker(
  id: 'meridian_collection_sync',
  title: new TranslatableMarkup('Collection object sync'),
  cron: ['time' => 60],
)]
final class CollectionObjectSyncWorker extends QueueWorkerBase {

  public function processItem($data): void {
    // $data is the accession number placed on the queue by the importer.
    // Throw \Drupal\Core\Queue\SuspendQueueException if the API is down so the
    // whole queue stops; throw any other exception to leave this item for retry.
  }

}

Source: Queue API

The cron key’s time value is the number of seconds cron may spend on this one queue per run. A queue with a backlog therefore makes progress at a bounded rate on every run, and items not reached are picked up next time. If the cron key is omitted, the queue is never processed by cron and must be run explicitly (see below). The default time applied to workers that declare cron without a value is set in QueueWorkerManager, so check the version in use when depending on it.

The processing rules during a cron run:

  • Queues are processed in the order of their next scheduled processing time, each for at most its time allowance.

  • If processItem() throws SuspendQueueException, the queue stops for this run. If the exception is delayable and the delay is under the maximum wait (30 seconds by default), the queue is retried later in the same run.

  • Another exception is logged and the item is released back to the queue after the lease expires, so it is retried on a later run. A worker that always fails therefore blocks nothing but keeps being retried; remove poison items or handle the failure by catching the exception and recording it.

  • A claimed item stays leased for the worker’s lease time; two runs cannot process the same item simultaneously.

Running queues outside cron

Drush runs and inspects queues directly, which is how long backlogs are drained after an import or an outage:

drush queue:list                                   # queues, worker plugin and number of items waiting
drush queue:run meridian_collection_sync           # until the queue is empty
drush queue:run meridian_collection_sync --time-limit=120 --items-limit=500 --lease-time=600

queue:run takes the queue name and the options --time-limit (maximum seconds), --items-limit (maximum items) and --lease-time (seconds an item stays claimed). Without limits it runs until the queue is empty, so give a limit when calling it from a scheduler. Because it runs on the command line, there is no 240-second budget and no web timeout, so a dedicated high-volume queue is often better processed by a separate scheduled queue:run than by cron. The older drush queue-run alias is kept.

Queue items do not expire on their own, and a very large backlog slows the database queue table. Monitoring drush queue:list in a health check is the simplest way to notice a worker that has stopped making progress.

Maintenance mode

Maintenance mode makes the site show a "site under maintenance" page to everyone who does not hold the permission Use the site in maintenance mode (access site in maintenance mode), while administrators can still use it. It is the safe state for database updates, content migrations and risky deployments.

Enable it at Configuration > Development > Maintenance mode (/admin/config/development/maintenance), where the message shown to visitors can also be edited (stored in system.maintenance as message). From the command line:

drush state:set system.maintenance_mode 1 --input-format=integer
drush state:set system.maintenance_mode 0 --input-format=integer

# Drush 13 reference documents maint:set and maint:status as shortcuts
drush maint:set 1
drush maint:status

Internally the flag is the state value system.maintenance_mode. The MaintenanceMode service reports that the mode applies when the flag is set and the current route is not marked exempt (the login form, update.php and other essential routes are), and a user is exempt when they have the permission above. Because the flag is state, not configuration, it is not exported or imported and is per environment, so a production deploy that turns it on must turn it off again afterwards. A failed deployment script can leave production in maintenance mode, which is why the drush deploy sequence in Drush and the pipeline in Deployment and CI/CD should include an explicit unset step.

Things to know:

  • The maintenance page is served with HTTP 503, which tells search engines to retry later. The Meridian Museum should keep it for minutes, not hours.

  • The page is themed by the maintenance theme (the default front-end theme unless $settings['maintenance_theme'] in settings.php names another one). Make meridian provide a maintenance-page.html.twig with the museum branding.

  • Page caching still applies to anonymous requests in some setups, and a CDN may serve stale pages; purge or bypass the cache when entering and leaving maintenance mode.

  • Cron does not need to be stopped explicitly, but Drush recommends not running it during maintenance (shown above), because pages rendered during the window can be cached incorrectly.

The Status report

The Status report (Reports > Status report, /admin/reports/status) is the dashboard of requirements. It is built from hook_requirements() implementations: every module reports entries with a severity of OK, info, warning or error, in the runtime phase for the page and the install/update phases for the installer and update screen. Entries relevant here:

  • Cron maintenance tasks: when cron last ran, a link "Run cron" and, when system cron is in use, the cron URL with its key. A warning appears when cron has not run recently. Use this entry as the first check after setting up the scheduler.

  • Database updates: whether any update hooks are pending ("Out of date") with a link to update.php.

  • Update notifications: whether the update status check sees available releases. It needs cron.

  • PHP version, Database, Trusted Host Settings, File system, Configuration files, and Access to update.php, which warns when $settings['update_free_access'] is TRUE.

The same data is available without the browser, which is useful for monitoring:

drush core:requirements --severity=1        # warnings and errors only
drush core:status

More on logging, requirement checks and troubleshooting is in Logging, Status Report and Troubleshooting.

The database updates screen

When code changes (a module update, a new release of core, or a change to meridian_collection that adds an update function) the database schema or stored data may need to change. Drupal does this through update hooks and post-update functions (see Update API and Post-Updates). They run from the database updates screen at /update.php, or with Drush.

The User Guide chapter on updating a module describes the sequence: put the site into maintenance mode, replace the code, run the database updates at example.com/update.php, and take the site out of maintenance mode. A backup comes before all of it (see Backups and Disaster Recovery).

drush updatedb:status        # list pending update hooks and post-updates
drush updatedb -y            # apply them (alias: updb)
drush deploy                 # updatedb, config:import, cache:rebuild and deploy hooks

Using the browser form:

  1. Make a backup and enable maintenance mode.

  2. Update the code with Composer (see Updates, Composer and Security Advisories); the only supported way to add or update code is Composer, as module upload and update from the administrative UI were removed in Drupal 11.

  3. Visit /update.php, press Continue, review the pending list and press Apply pending updates.

  4. Review the result log, clear caches if asked, and disable maintenance mode.

update.php normally requires an administrator session or a user with the right permission. Access without logging in is controlled by $settings['update_free_access'] in settings.php (default FALSE). Setting it to TRUE is a security risk: anyone could run the update script. If it is ever turned on to recover from a lockout, turn it off again immediately; the Status report warns while it is on. Prefer drush updatedb, which needs no web access at all.

Pitfalls:

  • Running update.php with old code is meaningless: always deploy the new code first. Rolling back the code after a successful database update without restoring the database leaves the schema ahead of the code.

  • Update functions run as a batch of web requests when started in the browser, so very long updates can time out; run them with Drush.

  • Configuration changes belong to config:import; schema and data changes belong to updates. drush deploy runs them in the right order, so a single command is safer than running the steps by hand.

Version notes and corrections of older material

  • Drupal 10 reaches end of life on 2026-12-09, and Drupal 12.0.0 is planned for the week of 2026-12-07. Cron, queues, maintenance mode and update.php work the same way in 10.2, 11 and the upcoming 12; Drush 13 serves 10.2 and 11, and Drush 14 serves 11.3 and 12.

  • Queue workers are defined with the #[QueueWorker] PHP attribute, which replaces annotation discovery (attribute-based plugins arrived in Drupal 10.2 and core plugin types were converted in later releases). Older books such as Drupal 10 Development Cookbook (2023) show the @QueueWorker annotation and Drush commands written with annotations and registered in drush.services.yml; the annotation is deprecated, and drush.services.yml has not been used since Drush 13, where commands use attributes and autowiring.

  • Cron hooks can be written as object-oriented hook methods since Drupal 11.1, not only as meridian_collection_cron() in the .module file as older books show.

  • Drupal 10 Masterclass (2023) presents Automated Cron as the normal setup, with Drush 12 for command line work. It remains acceptable for development, but production sites should use system cron as described above, and with Drush 13 or 14 on current Drupal.

  • Automatic Updates (see Automatic Updates and Project Browser) applies patch-level core updates only, does not support multisite, and does not run unattended from cron. Cron is still what makes the update status check run, so a site without working cron cannot even tell that an update is waiting.

  • Because the Standard profile no longer creates the Article and Page content types (11.4), tutorials that test cron by publishing an article must create a content type first.

Common errors

  • Cron has not run warning: no scheduler, Automated Cron disabled with no replacement, or low traffic. Run drush cron once, then confirm system.cron_last updates.

  • Attempting to re-run cron while it is already running: overlapping runs or a crashed run holding the lock; lengthen the interval and add flock or concurrencyPolicy: Forbid.

  • Links in cron-generated email point to http://default: Drush ran without --uri.

  • The cron URL returns 403: the key is wrong or has changed. Read it again from the Status report.

  • Cron times out in the browser or at a proxy: use drush cron, not the URL, and move long work into a queue.

  • Queue never drains: the worker has no cron definition, throws on every item, or the lease expired before the item finished. Check drush queue:list, run drush queue:run with a limit, and read the log.

  • Site stuck in maintenance mode after a deployment: drush state:set system.maintenance_mode 0 --input-format=integer.

  • update.php says "Access denied": log in as an administrator, or use drush updatedb.