diff --git a/composer.json b/composer.json
index 51f3afb4875633257396dd688307f3533f55b557..9653416213a998e0e343685afa4bace6930a179e 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,8 @@
         "composer-runtime-api": "^2.1"
     },
     "require-dev": {
-        "colinodell/psr-testlogger": "^1.2"
+        "colinodell/psr-testlogger": "^1.2",
+        "drush/drush": "^11"
     },
     "scripts": {
         "phpcbf": "scripts/phpcbf.sh",
@@ -42,5 +43,12 @@
         "psr-4": {
             "Drupal\\automatic_updates\\Development\\": "scripts/src"
         }
+    },
+    "extra": {
+        "drush": {
+            "services": {
+                "drush.services.yml": "^11"
+            }
+        }
     }
-}
+}
\ No newline at end of file
diff --git a/drush.services.yml b/drush.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8119ead091b74c37d021c046ad5d16b7487721d2
--- /dev/null
+++ b/drush.services.yml
@@ -0,0 +1,9 @@
+services:
+  _defaults:
+    autowire: true
+  Drupal\automatic_updates\Commands\AutomaticUpdatesCommands:
+    tags:
+      - { name: drush.command }
+  Drupal\automatic_updates\DrushUpdateStage:
+    calls:
+      - ['setLogger', ['@logger.channel.automatic_updates']]
diff --git a/scripts/install_module.sh b/scripts/install_module.sh
index 886f2189c0022f7eb84479c842a98bc5109ee349..04e7b2aa691ecb04d2dbdcf1e0895f391f95f64c 100755
--- a/scripts/install_module.sh
+++ b/scripts/install_module.sh
@@ -61,6 +61,7 @@ composer require \
 # automatic_updates' development dependencies to the root.
 # @see https://getcomposer.org/doc/04-schema.md#require-dev
 composer require --dev colinodell/psr-testlogger:^1
+composer require --dev drush/drush:^11
 
 # Revert needless changes to Core Composer metapackages.
 git checkout -- "$SITE_DIRECTORY/composer/Metapackage"
diff --git a/src/Commands/AutomaticUpdatesCommands.php b/src/Commands/AutomaticUpdatesCommands.php
new file mode 100644
index 0000000000000000000000000000000000000000..e15219919e43f72a6562a4abeb418798133e0951
--- /dev/null
+++ b/src/Commands/AutomaticUpdatesCommands.php
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\automatic_updates\Commands;
+
+use Drupal\automatic_updates\DrushUpdateStage;
+use Drush\Commands\DrushCommands;
+
+/**
+ * Contains Drush commands for Automatic Updates.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. It should not be called directly, and external
+ *   code should not interact with it.
+ */
+final class AutomaticUpdatesCommands extends DrushCommands {
+
+  /**
+   * Constructs a AutomaticUpdatesCommands object.
+   *
+   * @param \Drupal\automatic_updates\DrushUpdateStage $stage
+   *   The console cron updater service.
+   */
+  public function __construct(private readonly DrushUpdateStage $stage) {
+    parent::__construct();
+  }
+
+  /**
+   * Automatically updates Drupal core.
+   *
+   * @usage auto-update
+   *   Automatically updates Drupal core, if any updates are available.
+   *
+   * @option $post-apply Internal use only.
+   * @option $stage-id Internal use only.
+   * @option $from-version Internal use only.
+   * @option $to-version Internal use only.
+   *
+   * @command auto-update
+   *
+   * @throws \LogicException
+   *   If the --post-apply option is provided without the --stage-id,
+   *   --from-version, and --to-version options.
+   */
+  public function autoUpdate(array $options = ['post-apply' => FALSE, 'stage-id' => NULL, 'from-version' => NULL, 'to-version' => NULL]) {
+    $io = $this->io();
+
+    // The second half of the update process (post-apply etc.) is done by this
+    // exact same command, with some additional flags, in a separate process to
+    // ensure that the system is in a consistent state.
+    // @see \Drupal\automatic_updates\DrushUpdateStage::triggerPostApply()
+    if ($options['post-apply']) {
+      if (empty($options['stage-id']) || empty($options['from-version']) || empty($options['to-version'])) {
+        throw new \LogicException("The post-apply option is for internal use only. It should never be passed directly.");
+      }
+      $message = sprintf('Drupal core was successfully updated to %s!', $options['to-version']);
+      $io->success($message);
+
+      $io->info('Running post-apply tasks and final clean-up...');
+      $this->stage->handlePostApply($options['stage-id'], $options['from-version'], $options['to-version']);
+    }
+    else {
+      if ($this->stage->getMode() === DrushUpdateStage::DISABLED) {
+        $io->error('Automatic updates are disabled.');
+        return;
+      }
+
+      $release = $this->stage->getTargetRelease();
+      if ($release) {
+        $message = sprintf('Updating Drupal core to %s. This may take a while.', $release->getVersion());
+        $io->info($message);
+        $this->stage->performUpdate($release->getVersion(), 300);
+      }
+      else {
+        $io->info("There is no Drupal core update available.");
+      }
+    }
+  }
+
+}
diff --git a/src/CronUpdateStage.php b/src/CronUpdateStage.php
index 0c0b3036ef209a93f23d86a4148587834761afad..2f1c90d0964c0523118d6cbfb5f1684f928f9712 100644
--- a/src/CronUpdateStage.php
+++ b/src/CronUpdateStage.php
@@ -168,7 +168,7 @@ class CronUpdateStage extends UpdateStage {
    *   How long to allow the operation to run before timing out, in seconds, or
    *   NULL to never time out.
    */
-  private function performUpdate(string $target_version, ?int $timeout): void {
+  protected function performUpdate(string $target_version, ?int $timeout): void {
     $project_info = new ProjectInfo('drupal');
 
     if (!$this->isAvailable()) {
diff --git a/src/DrushUpdateStage.php b/src/DrushUpdateStage.php
new file mode 100644
index 0000000000000000000000000000000000000000..caed1d1c16a3198e259a03e9f26704cd4abd7a06
--- /dev/null
+++ b/src/DrushUpdateStage.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\automatic_updates;
+
+use Drush\Drush;
+
+/**
+ * An updater that runs via a Drush command.
+ */
+final class DrushUpdateStage extends CronUpdateStage {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function triggerPostApply(string $stage_id, string $start_version, string $target_version): void {
+    $alias = Drush::aliasManager()->getSelf();
+
+    $output = Drush::processManager()
+      ->drush($alias, 'auto-update', [], [
+        'post-apply' => TRUE,
+        'stage-id' => $stage_id,
+        'from-version' => $start_version,
+        'to-version' => $target_version,
+      ])
+      ->mustRun()
+      ->getOutput();
+    // Ensure the output of the sub-process is visible.
+    Drush::output()->write($output);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function performUpdate(string $target_version, ?int $timeout): void {
+    // Overridden to expose this method to calling code.
+    parent::performUpdate($target_version, $timeout);
+  }
+
+}
diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php
index 87dbc7a134ebd5fb2c01a3b6afcc8462e2f05af4..5629af9e06926afde7e2f2d3d81f2af0e4077adf 100644
--- a/tests/src/Build/CoreUpdateTest.php
+++ b/tests/src/Build/CoreUpdateTest.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\Tests\automatic_updates\Build;
 
 use Behat\Mink\Element\DocumentElement;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\automatic_updates\CronUpdateStage;
 use Drupal\automatic_updates\UpdateStage;
 use Drupal\Composer\Composer;
@@ -17,6 +18,7 @@ use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreDestroyEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\Tests\WebAssert;
+use Symfony\Component\Process\Process;
 
 /**
  * Tests an end-to-end update of Drupal core.
@@ -408,4 +410,40 @@ class CoreUpdateTest extends UpdateTestBase {
     $assert_session->pageTextContains('Ready to update');
   }
 
+  // BEGIN: DELETE FROM CORE MERGE REQUEST
+
+  /**
+   * Tests updating via Drush.
+   */
+  public function testDrushUpdate(): void {
+    $this->createTestProject('RecommendedProject');
+
+    $this->runComposer('composer require drush/drush', 'project');
+
+    $dir = $this->getWorkspaceDirectory() . '/project';
+    $command = [
+      $dir . '/vendor/drush/drush/drush',
+      'auto-update',
+      '--verbose',
+    ];
+    $process = new Process($command, $dir . '/web/sites/default');
+
+    // Give the update process as much time as it needs to run.
+    $process->setTimeout(NULL)->mustRun();
+    $output = $process->getOutput();
+    $this->assertStringContainsString('Updating Drupal core to 9.8.1. This may take a while.', $output);
+    $this->assertStringContainsString('Drupal core was successfully updated to 9.8.1!', $output);
+    $this->assertStringContainsString('Running post-apply tasks and final clean-up...', $output);
+    $this->assertUpdateSuccessful('9.8.1');
+    $this->assertExpectedStageEventsFired(DrushUpdateStage::class);
+
+    // Rerunning the command should exit with a message that no newer version
+    // is available.
+    $process = new Process($command, $process->getWorkingDirectory());
+    $process->mustRun();
+    $this->assertStringContainsString("There is no Drupal core update available.", $process->getOutput());
+  }
+
+  // END: DELETE FROM CORE MERGE REQUEST
+
 }