diff --git a/core/includes/errors.inc b/core/includes/errors.inc
index d82ed9bdd1655c4f463c5d3da12bfd72f442bdef..ea27cbef93a5ac87005c102404a355ec5bdb74ff 100644
--- a/core/includes/errors.inc
+++ b/core/includes/errors.inc
@@ -262,7 +262,7 @@ function _drupal_log_error($error, $fatal = FALSE): void {
       // We fallback to a maintenance page at this point, because the page
       // generation itself can generate errors.
       // Should not translate the string to avoid errors producing more errors.
-      $message = 'The website encountered an unexpected error. Try again later.' . '<br />' . $message;
+      $message = 'The website encountered an unexpected error. Try again later.' . '<br /><br />' . $message;
 
       if ($is_installer) {
         // install_display_output() prints the output and ends script execution.
@@ -279,6 +279,16 @@ function _drupal_log_error($error, $fatal = FALSE): void {
         }
       }
 
+      $html = Error::renderFatalError([
+        'title' => 'Service unavailable',
+        'content' => $message,
+        'displayable' => error_displayable($error),
+      ]);
+      if (!empty($html)) {
+        $message = $html;
+        $response->headers->set('Content-Type', 'text/html');
+      }
+
       $response->setContent($message);
       $response->setStatusCode(500, '500 Service unavailable (with message)');
 
diff --git a/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php
index 3473a1b5775dc0eedadf6aef349cc0eb49cfbc6d..a75bdac1393b632c02f8a3c489fca5a0120c1454 100644
--- a/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php
@@ -30,9 +30,10 @@
  * handled by a single exception subscriber:: serialization.exception.default.
  *
  * This exception subscriber runs after all the above (it has a lower priority),
- * which makes it the last-chance exception handler. It always sends a plain
- * text response. If it's a displayable error and the error level is configured
- * to be verbose, then a helpful backtrace is also printed.
+ * which makes it the last-chance exception handler. It sends a html response
+ * if a request format is html and a plain text otherwise. If it's a displayable
+ * error and the error level is configured to be verbose, then a helpful
+ * backtrace is also printed.
  */
 class FinalExceptionSubscriber implements EventSubscriberInterface {
   use StringTranslationTrait;
@@ -129,6 +130,18 @@ public function onException(ExceptionEvent $event) {
     $content_type = $event->getRequest()->getRequestFormat() == 'html' ? 'text/html' : 'text/plain';
     $content = $this->t('The website encountered an unexpected error. Try again later.');
     $content .= $message ? '<br><br>' . $message : '';
+
+    if ($content_type == 'text/html') {
+      $html = Error::renderFatalError([
+        'title' => $this->t('Service unavailable'),
+        'content' => $content,
+        'displayable' => $this->isErrorDisplayable($error),
+      ]);
+      if (!empty($html)) {
+        $content = $html;
+      }
+    }
+
     $response = new Response($content, 500, ['Content-Type' => $content_type]);
 
     if ($exception instanceof HttpExceptionInterface) {
diff --git a/core/lib/Drupal/Core/Utility/Error.php b/core/lib/Drupal/Core/Utility/Error.php
index 459af44d8c517733e57c75b43b365f9534d09bb4..6e7d29ef0f7672ee37856c7dfeb9cfe27e24b3e4 100644
--- a/core/lib/Drupal/Core/Utility/Error.php
+++ b/core/lib/Drupal/Core/Utility/Error.php
@@ -2,13 +2,18 @@
 
 namespace Drupal\Core\Utility;
 
-use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
+use Drupal\Core\Extension\ExtensionDiscovery;
+use Drupal\Core\Site\Settings;
 use Drupal\Component\Utility\Xss;
+use Drupal\Component\Render\FormattableMarkup;
 use Drupal\Core\Database\Connection;
 use Drupal\Core\Database\Database;
 use Drupal\Core\Database\DatabaseExceptionWrapper;
 use Psr\Log\LoggerInterface;
 use Psr\Log\LogLevel;
+use Twig\Environment;
+use Twig\Loader\ArrayLoader;
 
 /**
  * Drupal error utility class.
@@ -120,6 +125,69 @@ public static function renderExceptionSafe($exception) {
     return new FormattableMarkup(Error::DEFAULT_ERROR_MESSAGE . ' <pre class="backtrace">@backtrace</pre>', $decode);
   }
 
+  /**
+   * Renders fatal error page from twig template using Symfony twig engine.
+   *
+   * @param array $context
+   *   Variables to be passed to twig template.
+   *
+   * @return string
+   *   An html code with rendered fatal error page.
+   */
+  public static function renderFatalError(array $context) {
+    $template_path = '/templates/maintenance-page--offline.html.twig';
+    $system_path = 'core/modules/system';
+    $theme = '';
+
+    // Get offline theme from settings.php and check if the template exists.
+    try {
+      $theme = Settings::get('maintenance_theme', '');
+      if (!$theme) {
+        $theme_path = $system_path;
+      }
+      else {
+        $theme_path = \Drupal::service('extension.list.theme')->getPath($theme);
+      }
+    }
+    catch (ContainerNotInitializedException $e) {
+      // The maintenance theme is set but the container doesn't exist
+      // since the database is inactive. Hence there are no services available
+      // to retrieve a maintenance theme path. The path can be obtained by using
+      // ExtensionDiscovery::scan() but the app root should be guessed first
+      // in the same way as DrupalKernel::guessApplicationRoot() does.
+      $app_root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
+      $listing = new ExtensionDiscovery($app_root, FALSE, NULL, NULL);
+      // An empty profile directory prevents ExtensionDiscovery::scan()
+      // from calling \Drupal::installProfile() that needs working container.
+      $listing->setProfileDirectories([]);
+      $themes = $listing->scan('theme');
+      $theme_path = isset($themes[$theme]) ? $themes[$theme]->getPath() : $system_path;
+      if ($context['displayable']) {
+        $context['content'] = $context['content'] . "<pre>" . $e . "</pre>";
+      }
+    }
+    catch (\Throwable $error) {
+      // Handle any other cases.
+      $theme_path = $system_path;
+      if ($context['displayable']) {
+        $context['content'] = $context['content'] . "<pre>" . $error . "</pre>";
+      }
+    }
+
+    $path = $theme_path . $template_path;
+    if (!file_exists($path)) {
+      $path = $system_path . $template_path;
+    }
+
+    // Directly use Symfony twig engine without Drupal wrapper to minimize
+    // possibility of nested exception.
+    $template = file_get_contents($path);
+    $loader = new ArrayLoader(['maintenance_page_offline' => $template]);
+    $environment = new Environment($loader);
+
+    return $environment->render('maintenance_page_offline', $context);
+  }
+
   /**
    * Gets the last caller from a backtrace.
    *
diff --git a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php
index 26e92fc14982467c2cf6c2e851653de9007e0758..e7aa0d4fa730089f5d5918f2002fcfa3dabea3aa 100644
--- a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php
+++ b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php
@@ -212,9 +212,8 @@ public function testBigPipe(): void {
     // The 'edge_case__html_exception' case throws an exception.
     $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later');
     $this->assertSession()->pageTextContains('You are not allowed to say llamas are not cool!');
-    // Check that stop signal and closing body tag are absent.
+    // Check that stop signal is absent.
     $this->assertSession()->responseNotContains(BigPipe::STOP_SIGNAL);
-    $this->assertSession()->responseNotContains('</body>');
     // The exception is expected. Do not interpret it as a test failure.
     unlink($this->root . '/' . $this->siteDirectory . '/error.log');
 
@@ -294,7 +293,6 @@ public function testBigPipeNoJs(): void {
     // The 'edge_case__html_exception' case throws an exception.
     $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later');
     $this->assertSession()->pageTextContains('You are not allowed to say llamas are not cool!');
-    $this->assertSession()->responseNotContains('</body>');
     // The exception is expected. Do not interpret it as a test failure.
     unlink($this->root . '/' . $this->siteDirectory . '/error.log');
   }
diff --git a/core/modules/system/templates/maintenance-page--offline.html.twig b/core/modules/system/templates/maintenance-page--offline.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..f7ba55ddecd418a439392f6013d0165d9906f85d
--- /dev/null
+++ b/core/modules/system/templates/maintenance-page--offline.html.twig
@@ -0,0 +1,41 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a single Drupal page while offline.
+ *
+ * @see template_preprocess_maintenance_page()
+ *
+ * @ingroup themeable
+ */
+#}
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>
+      {% if title %}
+        {{ title }}
+      {% else %}
+        Service Unavailable
+      {% endif %}
+    </title>
+  </head>
+  <body class="maintenance-page layout-no-sidebars path-frontpage">
+    <div id="page-wrapper">
+      <div id="page">
+        <div id="main-wrapper">
+          <div id="main" class="clearfix">
+            <main id="content" class="column" role="main">
+              <section class="section">
+                <a id="main-content"></a>
+                {% if title %}
+                  <h1 class="title" id="page-title">{{ title }}</h1>
+                {% endif %}
+                {{ content|striptags('<p>,<br>,<em>,<pre>')|raw }}
+              </section>
+            </main>
+          </div>
+        </div>
+      </div>
+    </div>
+  </body>
+</html>
\ No newline at end of file
diff --git a/core/modules/system/tests/src/Functional/System/MaintenancePageOfflineTest.php b/core/modules/system/tests/src/Functional/System/MaintenancePageOfflineTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..06e2f5da9a22b6fe9bdfdd2912443e348d408b8a
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/System/MaintenancePageOfflineTest.php
@@ -0,0 +1,212 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\system\Functional\System;
+
+use Drupal\Core\Database\Database;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests if the Maintenance page is served when the site is offline.
+ *
+ * @group system
+ */
+class MaintenancePageOfflineTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'test_theme';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $settings_filename = $this->siteDirectory . '/settings.php';
+    chmod($settings_filename, 0777);
+    $settings_php = file_get_contents($settings_filename);
+    // Ensure we can test errors rather than being caught in
+    // \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware.
+    $settings_php .= "\ndefine('SIMPLETEST_COLLECT_ERRORS', FALSE);\n";
+    file_put_contents($settings_filename, $settings_php);
+  }
+
+  /**
+   * Prepare settings values before a test case.
+   *
+   * @param string $maintenance_theme
+   *   The name of a maintenance theme. Empty if there is no maintenance theme.
+   * @param string $error_level
+   *   The name of an error level.
+   * @param bool $active_database
+   *   TRUE if a database should be active.
+   * @param bool $valid_hash_salt
+   *   TRUE if a hash_salt should be valid.
+   */
+  protected function prepareCaseSettings($maintenance_theme, $error_level, $active_database = TRUE, $valid_hash_salt = TRUE): void {
+    $settings = [];
+    if (!empty($maintenance_theme)) {
+      $settings['settings']['maintenance_theme'] = (object) [
+        'value' => $maintenance_theme,
+        'required' => TRUE,
+      ];
+    }
+    $settings['config']['system.logging']['error_level'] = (object) [
+      'value' => $error_level,
+      'required' => TRUE,
+    ];
+    if (!$active_database) {
+      // Make a database inactive by setting an invalid password.
+      $connection_info = Database::getConnectionInfo();
+      $settings['databases']['default']['default']['password'] = (object) [
+        'value' => $connection_info['default']['password'] . $this->randomMachineName(),
+        'required' => TRUE,
+      ];
+    }
+    if (!$valid_hash_salt) {
+      // Set a hash_salt to invalid value.
+      $settings['settings']['hash_salt'] = (object) [
+        'value' => NULL,
+        'required' => TRUE,
+      ];
+    }
+    $this->writeSettings($settings);
+  }
+
+  /**
+   * Tests cases when settings.php contains invalid database settings.
+   *
+   * Tests if the maintenance offline page is served when settings.php
+   * contains invalid database settings.
+   */
+  public function testInvalidDatabaseSettings(): void {
+    // Open a frontpage without any error.
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertSession()->pageTextContains('Log in');
+
+    // CASE 1 - a maintenance theme's template is picked up
+    // and no errors are displayed.
+    $this->prepareCaseSettings('test_theme', ERROR_REPORTING_HIDE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A maintenance theme's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title test-theme"');
+    // A fatal error message and a backtrace should be hidden.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextNotContains('Access denied for user');
+    $this->assertSession()->responseNotContains('<pre class="backtrace">');
+
+    // CASE 2 - a maintenance theme's template is picked up
+    // and all errors with a backtrace are displayed.
+    $this->prepareCaseSettings('test_theme', ERROR_REPORTING_DISPLAY_VERBOSE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A maintenance theme's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title test-theme"');
+    // A fatal error message and a backtrace should be shown.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextContains('Access denied for user');
+    $this->assertSession()->responseContains('<pre class="backtrace">');
+
+    // CASE 3 - a system's template is picked up
+    // since a maintenance theme doesn't have the template
+    // and no errors are displayed.
+    $this->prepareCaseSettings('test_subtheme', ERROR_REPORTING_HIDE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A system's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title"');
+    // A fatal error message and a backtrace should be hidden.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextNotContains('Access denied for user');
+    $this->assertSession()->responseNotContains('<pre class="backtrace">');
+
+    // CASE 4 - a system's template is picked up
+    // since a maintenance theme is not set
+    // and all errors with a backtrace are displayed.
+    $this->prepareCaseSettings('', ERROR_REPORTING_DISPLAY_VERBOSE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A system's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title"');
+    // A fatal error message and a backtrace should be shown.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextContains('Access denied for user');
+    $this->assertSession()->responseContains('<pre class="backtrace">');
+  }
+
+  /**
+   * Tests cases when settings.php doesn't have a hash_salt defined.
+   *
+   * Tests if the maintenance offline page is served when settings.php
+   * originally did have a hash_salt defined, which is emptied out afterwards.
+   */
+  public function testRemovedHashSalt(): void {
+    // Open a frontpage without any error.
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertSession()->pageTextContains('Log in');
+
+    // CASE 1 - a maintenance theme's template is picked up
+    // and no errors are displayed.
+    $this->prepareCaseSettings('test_theme', ERROR_REPORTING_HIDE, TRUE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A maintenance theme's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title test-theme"');
+    // A fatal error message and a backtrace should be hidden.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextNotContains('Missing $settings[\'hash_salt\'] in settings.php');
+    $this->assertSession()->responseNotContains('<pre class="backtrace">');
+
+    // CASE 2 - a maintenance theme's template is picked up
+    // and all errors with a backtrace are displayed.
+    $this->prepareCaseSettings('test_theme', ERROR_REPORTING_DISPLAY_VERBOSE, TRUE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A maintenance theme's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title test-theme"');
+    // A fatal error message and a backtrace should be shown.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextContains('Missing $settings[\'hash_salt\'] in settings.php');
+    $this->assertSession()->responseContains('<pre class="backtrace">');
+
+    // CASE 3 - a system's template is picked up
+    // since a maintenance theme doesn't have the template
+    // and no errors are displayed.
+    $this->prepareCaseSettings('test_subtheme', ERROR_REPORTING_HIDE, TRUE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A system's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title"');
+    // A fatal error message and a backtrace should be hidden.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextNotContains('Missing $settings[\'hash_salt\'] in settings.php');
+    $this->assertSession()->responseNotContains('<pre class="backtrace">');
+
+    // CASE 4 - a system's template is picked up
+    // since a maintenance theme is not set
+    // and all errors with a backtrace are displayed.
+    $this->prepareCaseSettings('', ERROR_REPORTING_DISPLAY_VERBOSE, TRUE, FALSE);
+    $this->drupalGet('');
+    $this->assertSession()->statusCodeEquals(500);
+    // A system's offline template should be picked up.
+    $this->assertSession()->pageTextContains('Service unavailable');
+    $this->assertSession()->responseContains('<h1 class="title"');
+    // A fatal error message and a backtrace should be shown.
+    $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
+    $this->assertSession()->pageTextContains('Missing $settings[\'hash_salt\'] in settings.php');
+    $this->assertSession()->responseContains('<pre class="backtrace">');
+  }
+
+}
diff --git a/core/modules/system/tests/themes/test_theme/templates/maintenance-page--offline.html.twig b/core/modules/system/tests/themes/test_theme/templates/maintenance-page--offline.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..f73983c4eb2dc043aaae5f28c4b67f296437139f
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme/templates/maintenance-page--offline.html.twig
@@ -0,0 +1,41 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a single Drupal page while offline.
+ *
+ * @see template_preprocess_maintenance_page()
+ *
+ * @ingroup themeable
+ */
+#}
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>
+      {% if title %}
+        {{ title }}
+      {% else %}
+        Service Unavailable
+      {% endif %}
+    </title>
+  </head>
+  <body class="maintenance-page layout-no-sidebars path-frontpage">
+    <div id="page-wrapper">
+      <div id="page">
+        <div id="main-wrapper">
+          <div id="main" class="clearfix">
+            <main id="content" class="column" role="main">
+              <section class="section">
+                <a id="main-content"></a>
+                {% if title %}
+                  <h1 class="title test-theme" id="page-title">{{ title }}</h1>
+                {% endif %}
+                {{ content|striptags('<p>,<br>,<em>,<pre>')|raw }}
+              </section>
+            </main>
+          </div>
+        </div>
+      </div>
+    </div>
+  </body>
+</html>
\ No newline at end of file