← Back to blog

WordPress maintenance mode: what core actually does, and why it cannot hold a planned window

The SandyWP team 9 min read

WordPress maintenance mode is a single file in the site root called .maintenance, containing one line of PHP that sets a timestamp.

While that file is present and the timestamp is less than ten minutes old, every request to the site gets an HTTP 503 and core's own message: "Briefly unavailable for scheduled maintenance."

That is the whole mechanism. It is not a setting, there is no screen for it, and it is deliberately short-lived, which is why using it for a planned two-hour maintenance window does not work.

Most guides on this phrase describe the maintenance-mode plugins instead, and treat core's own feature as an implementation detail. The two behave so differently that confusing them is the main reason people get surprised, so this post covers what core does, what it cannot do, and which of the three real options fits which job.

What WordPress maintenance mode actually is

Three pieces, and they are all in core.

The file. .maintenance sits beside wp-config.php in the site root. Its entire contents are written by the upgrader as:

<?php $upgrading = 1759536000; ?>

The number is the Unix timestamp from time() at the moment the file was created. Nothing else is stored in it.

The check. wp_is_maintenance_mode() in wp-includes/load.php looks for the file, requires it to pull $upgrading into scope, and returns false if the timestamp is ten minutes old or more:

// If the $upgrading timestamp is older than 10 minutes, consider maintenance over.
if ( ( time() - $upgrading ) >= 10 * MINUTE_IN_SECONDS ) {
    return false;
}

The response. If the check passes, wp_maintenance() sends a Retry-After: 600 header and calls wp_die() with a 503 status. Both of those are correct behaviour for a temporary outage, and the ten-minute Retry-After matches the ten-minute expiry exactly.

Core creates and deletes this file around core, plugin and theme updates. WP_Upgrader::maintenance_mode() writes it before unpacking and deletes it afterwards, so on a healthy site it exists for a few seconds at a time.

Why core's maintenance mode cannot hold a planned window

Three hard limits, and none of them is configurable.

It expires after ten minutes. Not the file, the effect: the file stays on disk, but after ten minutes wp_is_maintenance_mode() returns false and the site serves normally again.

Refreshing the timestamp every few minutes with a cron job is possible but fragile, and it is not what the mechanism is for.

It locks you out too. wp_maintenance() runs at line 79 of wp-settings.php, before the database is touched, before the theme loads, and before any plugin.

There is no user at that point in the boot, so there is nothing to check a capability against. Visitors, administrators, wp-admin and the REST API all get the same 503.

No plugin can change it. By the time plugins load, wp_die() has already ended the request, so hooks like template_redirect never fire.

This is also why the maintenance-mode plugins do not use it, which is the next section.

If you want a maintenance page that stays up for an hour while an administrator keeps working behind it, core's maintenance mode is the wrong tool. Say what you are doing instead and pick from the three options below.

How to put a WordPress site into maintenance mode: the three real options

Option one: a maintenance-mode plugin. These do not touch .maintenance at all. They hook late in the boot, after users and capabilities exist, check whether the current visitor is logged in, and replace the page for everyone else.

That is how they can let you work in wp-admin while visitors see a holding page, and how they can give you a page builder for it.

The tradeoff is that the holding page is served by a full WordPress request, so the database, the theme and every other plugin have already run. If the reason you are in maintenance is that something in that stack is broken, the plugin's page breaks with it.

Option two: the web server, above WordPress. An .htaccess rule, an Nginx return 503, or your host's own maintenance toggle.

This is the only option that holds indefinitely, survives a broken WordPress, and lets you allow your own IP through. It is also the only one that keeps working while you are restoring a database.

RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.7$
RewriteCond %{REQUEST_URI} !^/maintenance\.html$
RewriteRule ^(.*)$ /maintenance.html [R=503,L]

Set ErrorDocument 503 /maintenance.html alongside it so the status code and the page agree. If you are editing .htaccess for this, our guide to the WordPress .htaccess file covers where core's own block starts and ends, and why putting your rules inside it gets them wiped.

Option three: .maintenance by hand, for a short window. Writing the file yourself is legitimate for the job it is built for: a few minutes of file-level work where you want every request to get a correct 503 rather than a half-updated site.

cd /path/to/wordpress
printf '<?php $upgrading = %s; ?>\n' "$(date +%s)" > .maintenance

Delete the file when you are done. Do not write $upgrading = time(); into it, which several guides suggest.

That re-evaluates on every request, so the timestamp is always the current second, the ten-minute check never trips, and you have built the one failure mode core designed out: a maintenance mode that cannot expire on its own.

Customising the maintenance page with wp-content/maintenance.php

Core's maintenance message is not filterable, but it is replaceable. If wp-content/maintenance.php exists, wp_maintenance() requires it and dies, skipping its own output entirely:

if ( file_exists( WP_CONTENT_DIR . '/maintenance.php' ) ) {
    require_once WP_CONTENT_DIR . '/maintenance.php';
    die();
}

This is an official drop-in, listed in core's own registry as "Custom maintenance message" and shown in the Drop-ins tab of the Plugins screen when it is present.

Two things about it are easy to get wrong, and I have not seen either written down in the guides that rank for this phrase.

Almost no WordPress functions exist yet. At line 79 of wp-settings.php, only version.php, compat.php, load.php, default-constants.php, plugin.php and the recovery-mode classes have loaded.

wp-includes/functions.php loads at line 117, so wp_die(), esc_html(), get_bloginfo() and the template tags are all unavailable.

Core loads functions.php itself for its own branch, but it does that after the drop-in check, so your drop-in does not get it.

You must send your own status code. Core's Retry-After header and 503 are set inside the branch you just skipped.

A drop-in that only echoes HTML returns HTTP 200, which tells a crawler your maintenance page is the real content of every URL on the site. Send the headers first:

<?php
header( 'HTTP/1.1 503 Service Unavailable' );
header( 'Retry-After: 600' );
header( 'Content-Type: text/html; charset=utf-8' );
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Back shortly</title></head>
<body><h1>Back shortly</h1><p>We are updating. Try again in a few minutes.</p></body>
</html>

Keep it to plain PHP and plain HTML with no WordPress calls, and keep it small enough to read in one screen. It runs on the one request path where nothing else is available to help you.

The enable_maintenance_mode filter, and why it exists

There is one filter in the whole mechanism:

apply_filters( 'enable_maintenance_mode', true, $upgrading )

Its core docblock is unusually blunt about the catch: "This filter runs before it can be used by plugins. It is designed for non-web runtimes."

So you cannot hook it from a plugin, and you cannot hook it from a theme. The place it is usable is code that loads before WordPress does, which in practice means wp-config.php or a mu-plugin loaded early enough, and command-line runtimes that want to keep working while the web front end is down.

The same function has a second escape hatch, worth knowing about if you ever see maintenance mode "not working" during an update.

Core's fatal-error scraper passes wp_scrape_key and wp_scrape_nonce, and when those match an md5 of $upgrading the check returns false, so the scrape can see the real error. wp_installing() short-circuits it too.

Does maintenance mode hurt SEO?

Not if the status code is right. A 503 with a Retry-After header is the documented way to tell a crawler that a URL is temporarily unavailable and to come back later, and core gets this right by default.

The failure cases are all about mismatched status codes, which is why the drop-in point above matters:

  • A maintenance page served as 200 tells crawlers the holding page is your content.
  • A maintenance page served as 404 tells them the URL is gone.
  • A 302 to a single holding page collapses your whole site to one URL in their view.

Check it rather than assume it:

curl -sI https://example.com/ | head -3

For a window measured in minutes, none of this matters much. For a redesign that holds for a week, the status code is the only part of your maintenance page that search engines read.

Testing the maintenance page without taking a site down

The awkward part of all of this is that the only way to see your maintenance page is to be in maintenance, and the only site that matters is the live one. That is a bad place to find out your drop-in returns 200, or that your plugin's holding page needs the database that you are about to restore.

A disposable copy solves it. With a sandbox you can write wp-content/maintenance.php, drop in .maintenance, curl the response headers, delete both, and repeat, on a site where a 503 costs nothing.

On SandyWP that loop is SFTP or SSH into the sandbox, where wp-cli is already installed, so writing the two files and checking the headers is a handful of commands. If the page you need to test is your own live site's, the Cloner plugin takes a real copy into a sandbox first, which matters when the holding page depends on your theme.

For agencies running a planned window on a client site, the useful rehearsal is the whole sequence rather than the page: put the copy into maintenance, run the thing you were going to run, confirm it comes back out. Our agency workflow page covers the per-client side of that, and if the window exists because of an update, upgrade testing is the step that usually removes the need for the window at all.

The honest summary

Core's maintenance mode is a correct, deliberately minimal lock for the few seconds an update takes. It sends the right status code, it expires on its own, and it cannot be broken by the plugin stack because it runs before the plugin stack.

It is not a planned-window tool, and no amount of configuration will make it one. For a window longer than ten minutes, do it at the web server, above WordPress, where it keeps working even when WordPress does not.

And if the site is showing the maintenance message and will not come out of it, the file is probably not the cause. We wrote about why a site stuck in maintenance mode is rarely the .maintenance file separately, because after ten minutes core has already stopped reading it.