Heroku offers a maintenance mode. With it, the dyno isn't reached and an iFrame with a maintenance HTML page is shown. But unfortunately it can't be customized any further than customizing the HTML page which is shown in the iFrame. This is problematic when building an API which is used by a hybrid mobile app through a browser. With it, we need to disable Cors headers of the requests, to not be canceled immediately. But there aren't any settings to configure the headers returned there and we can't do anything in our application as a request never reaches the application (by design).

This means we need to build the maintenance mode ourself.

The Heroku maintenance mode has three advantages:

  • The request doesn't reach the application and therefore doesn't affect any work we might do while in maintenance mode
  • It's easy to configure (through a click in the web interface of the app or with changing one setting through the console)
  • Doesn't need a special handling for it within our application (no code that needs to be maintained)

With those goals in mind, let's see how close we can get.

The easiest approach (which is also the one I found all over the internet) would be to simple add a listener to your application (for example Symfony) and return a 503 Service Unavailable response for every request as soon as an environment variable is set. But this would mean that our application is triggered (which might have side effects) and that we need code within our application to handle the maintenance mode.

The only layer between our application code and the Heroku load balancer is our server configuration. In my case any Apache configuration. Before we can build anything maintenance related, we need to see what we might need to move from our application to a configuration. In my case that was everything related to headers, especially Cors headers. Previously I had the following listener in my application:

final class HeaderResponseListener implements EventSubscriberInterface
{
    public function onKernelResponse(ResponseEvent $event): void
    {
        // Don't do anything if it's not the master request.
        if (!$event->isMasterRequest()) {
            return;
        }

        $response = $event->getResponse();
        if ($response) {
            $response->headers->set('Access-Control-Allow-Origin', '*');
            $response->headers->set('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
            $response->headers->set('Access-Control-Allow-Headers', 'Authorization,Accept-Language,Connection,Content-Type,Content-Length,Host,Origin,User-Agent');
        }
    }
    
    ...
}
App\EventListener\HeaderResponse.php

It was also responsible to add the same headers on any exception. With it, everything returned from Symfony has a header which allows my mobile app to call the API. But the application should not be reached in the maintenance mode, so we need to move this into the Apache configuration:

# Cors
Header set Access-Control-Allow-Origin "*"

# Headers for app
Header merge Access-Control-Allow-Headers "Authorization,Accept-Language,Connection,Content-Type,Content-Length,Host,Origin,User-Agent"
Header merge Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS"
.htaccess

This is a 100% replacement for the listener and reduced the time Symfony needs for a request (although I doubt that it's measurable). Also any other response within our maintenance mode will have the same headers.

Make sure to use set or merge purposefully. Using for example set for the Access-Control-Allow-Headers would mean that it doesn't matter if you want to return additional headers from your application, the Apache configuration would always overwrite whatever is given from the application.

With this out of our way we wan't to enable the maintenance mode as easy as possible. The cleanest and easiest way is the setting of an environment variable. This can be done through the Heroku console and within the web interface. Let's call our variable MAINTENANCE. When it's set to true (the string, not the boolean), we want to use the maintenance mode. We can do this with a redirect condition that validates the environment variable:

RewriteCond %{ENV:MAINTENANCE} ^true

If the variable is not set or set to something other then true, the rewrite condition is not triggered.

My first through was to return an error document through a redirect with the related status. Something like:

ErrorDocument 503 maintenance.html

RewriteCond %{ENV:MAINTENANCE} ^true
RewriteRule ^ - [R=503,L]

Unfortunately this doesn't work, as any header directives aren't used, as soon as you only redirect to some response directly. So we need to redirect to something which will return a response. An HTML page or a PHP script. But we don't want to have our application handling it, so what I came up with is the following:

symfony-app
| - .htaccess
| - bin
| - config
| - ...
| - maintenance
  | - .htaccess
  | - index.php
  | - maintenance.html
| - ...
Directory structure
# Redirect to maintenance directory when in maintenance mode
RewriteCond %{ENV:MAINTENANCE} ^true
RewriteCond %{REQUEST_URI} !^/maintenance/
RewriteRule ^(.*)$ maintenance/$1 [L]
symfony-app/.htaccess
header("HTTP/1.1 503 Service Unavailable");

echo file_get_contents('maintenance.html');
symfony-app/maintenance/index.php
<html>
    <body>
        <h1>We're in maintenance mode</h1>
        ...
    </body>
</html>
symfony-app/maintenance/maintenance.html

When the MAINTENANCE environment variable is set to true, Apache will redirect every request into the maintenance directory. The .htaccess there is simply a copy from the one in the public folder so that the handling there is identical to the behaviour in our application.
Every request will be answered with a 503 Service Unavailable header and the content from the HTML page. This way the mobile app can look for the 503 status code and every user through the website will get a page that explains the maintenance mode without the user hitting the frontend application (which might also have side effects).

Now there is only one part missing: Handling of the preflight with OPTIONS requests. Before any real request is done by the mobile app, it triggers a request to the same url with the method OPTIONS. At the moment this would also be answered with a 503 Service Unavailable which is interpreted as a failed call. Which in turn means the real request won't be done and the mobile app would never see the real 503 status code. So we need to extend our index.php with one special case:

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    header("HTTP/1.1 204 No Content");
} else {
    header("HTTP/1.1 503 Service Unavailable");
    echo file_get_contents('maintenance.html');
}

I first tried to redirect all options calls directly with a response like the following in the Apache configuration:

# Return empty response for options request
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]

But this has the same side effect that the header directive is not triggered and we don't have a Cors header in the response. Which in turn will prevent the options request from succeeding. If anyone knows how to attach the headers within a redirect, let me know.

Result

Let's see how we did:

  • The request doesn't reach the application and therefore doesn't affect any work we might do while in maintenance mode ✓
  • It's easy to configure (through a click in the web interface of the app or with changing one setting through the console) ✓
  • Doesn't need a special handling for it within our application (no code that needs to be maintained) ✓

Only the last one is can be debated as we still have configuration code in our system for the maintenance mode. But on the plus side: We don't need to upload a maintenance HTML page to an external storage. It's part of our git repository with all the other relevant code.


A side note for setup of the environment variable in MAMP Pro.

The environment variable has to be known to Apache, so we need to supply it to MAMP Pro. It can't be done with SetEnv in the MAMP Pro configuration as the SetEnv directive is run after the redirect directive is used. So we need to supply it to MAMP Pro directly. MAMP has a file /Applications/MAMP/Library/bin/envvars which is read on every server start. Simply put in the following into the file (you might need to create it):

MAINTENANCE="true"
export MAINTENANCE
/Applications/MAMP/Library/bin/envvars