nginx: putting your site in ‘downtime’ for everyone except you

we’ve all been in that less-than-ideal situation of something going horribly awry in production and having to put the site into downtime while we fix it. that “scheduled maintenance”[sic.] page is important because it keeps users from seeing our glaring error, but it makes investigating or fixing production more difficult because, well, the site is in downtime.

in this post, we’re going to go over a couple of ways we can use nginx to show different content to different users based on their ip address; configuring our web server so that everyone in the world gets our downtime message, except us. we get to see site as normal, allowing us to engage in the not-quite-best-practice of debugging in production.

two users (left) are served the well-crafted downtime page, while the developer (right) sees the real site.

the flyover

we’re going to go over four nginx configurations. some of them are very similar, and are presented to show how we can combine and build on these strategies to tailor them to our needs. they configurations are:

tl;dr: if you’re looking for the solution you can just copy-paste and modify, then the last one, “leveraging map and if“, is probably what you want.

serving 403s with allow and deny

nginx has a lot of features built in to restrict and permit access. we can use it to throttle bandwidth or limit the number of connections per address to mitigate ddos attacks, but here we’re going to look at the allow and deny directives.

in an nginx configuration we can, in a location block, set an arbitrary number of ip addresses to be either allowed or denied. requests from allowed addresses proceed to be handled as normal. denied addresses are served an HTTP 403. let’s look at a complete, if somewhat terse, config:

server {

    server_name gbhorwood.test;
    root "/var/www/html/gbhorwood/test";

    index index.html index.htm index.php;

    charset utf-8;

    location / {
       # allow local ip
       allow 127.0.0.1;

       # allow some other ip
       allow 151.101.2.217;

       # 403 is default behaviour
       deny all;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  off;
    sendfile off;

    client_max_body_size 100m;

    listen 80;
}

here we see that in our location / block we allow two addresses. all other addresses are handled by the deny all directive.

the important thing to note here is that order is important. nginx tests the ip address starting at the top and handles the first directive that matches. for instance, if we did this:

allow 127.0.0.1;
deny 127.0.0.1;

a visitor from localhost would be allowed because nginx stops testing the ip at the first entry for 127.0.0.1.

likewise, if we configured our location block with the reverse:

deny 127.0.0.1;
allow 127.0.0.1;

localhost would be blocked.

the allow and deny directives are not limited to single ip addresses; we can also use cidr blocks. if we wanted to allow only the ips on our local network, for instance, we could do so like this:

allow 192.168.1.1/24;
deny all;

serving a custom downtime file instead of just 403

of course, throwing an HTTP 403 to all our users probably isn’t what we want. the point of a downtime page, after all, is to prevent people from seeing our terrible errors, not to serve them different ones.

we can fix this by setting a custom html error page for 403 and use that for our downtime message.

we’ll start by creating our custom downtime page. we’ll call it downtime.html and, for this example, we’ll put it in the /tmp directory. you might (probably!) want to choose a different location. the contents of our downtime file are:

<h1>downtime</h1>
this site is currently down for "scheduled" maintenance.

once we have our downtime html file, we can configure nginx so that instead of showing the standard 403 error page, we show our downtime.html file instead. here’s the full config:

server {

    server_name gbhorwood.test;
    root "/var/www/html/gbhorwood/test";

    index index.html index.htm index.php;

    charset utf-8;

    # new stuff
    error_page 403 /downtime.html;

    location = /downtime.html {
        root /tmp;
    }
    # end new stuff

    location / {
       # allow local ip
       allow 127.0.0.1;

       # allow some other ip
       allow 151.101.2.217;

       # 403 is default behaviour
       deny all;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  off;
    sendfile off;

    client_max_body_size 100m;

    listen 80;
}

looking in the ‘new stuff’ block, we can see that we are using two directives to serve our custom downtime html file.

the first directive is a call to error_page. this call takes two arguments: the HTTP code we want to assign a custom html file to, and the name of the custom html file. very straightforward.

the next directive is a location block for our downtime html file. we do this so that we can set the root directory that nginx will look in for our downtime.html. this allows us to keep our page out of our main repository and ensures that it will always be served even if we accidentally nuke every file in web page’s root (these things do happen).

of course, hijacking HTTP 403 for our own purposes is a bit of a kludge. there are more elegant ways for us to serve our downtime file. let’s look at those.

standard http error handling looks askance at a developer thinking of hijacking 403 for a downtime page

serving a string of ‘downtime’ html using if()

if we want to show a string of html for our downtime message to every ip address except our own, we can do that using nginx’s if statement.

let’s look at a configuration that does that:

server {

    server_name gbhorwood.test;
    root "/var/www/html/gbhorwood/test";

    index index.html index.htm index.php;

    charset utf-8;

    location / {

        # show downtime html string if not whitelisted ip
        if ($remote_addr != "127.0.0.1") {

            add_header Content-Type text/html;
            return 200 '<html><body>Temporarily offline for scheduled maintenance and upgrades</body></html>';

        }


        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  off;
    sendfile off;

    client_max_body_size 100m;

    listen 80;
}

the interesting stuff here is the if statement inside the location directive. nginx keeps the ip address of the visitor in a variable called $remote_addr. we can test that against our allowed ip using a very familiar-looking if.

if the visitor’s ip address is not allowed, we return a string of html. doing this is a two-step process: sending the Content-Type header with the add_header directive, and returning a string with return.

note that return takes two arguments. the first is the http status code, 200 is probably what we want here. the second argument is our string of html.

serving a file of ‘downtime’ html using if()

maybe returning just one string of html for our downtime page isn’t enough. maybe we want a tonne of css and some animated gifs; y’know, “rich content”.

we can do that by modifying the configuration above so that our if statement serves a file instead of just returning a string. it looks like this:

server {

    server_name gbhorwood.test;
    root "/var/www/html/gbhorwood/test";

    index index.html index.htm index.php;

    charset utf-8;

    location / {

        # show downtime html file if not whitelisted ip
        if ($remote_addr != "127.0.0.1") {
            rewrite ^ /static/down.html break;
        }

    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  off;
    sendfile off;

    client_max_body_size 100m;

    listen 80;
}

again, here, we’re testing the visitor’s ip address in $remote_addr against our allowed ip in an if statement. the difference is that, instead of returning a header and a string, we are using nginx’s rewrite directive to serve a file. since the rewrite directive ends with break, nginx jumps to the end of our location block, and our downtime content is served.

using map to handle multiple ip addresses more sanely

so far we’ve seen how to use if to test for one allowed ip address but, ideally, we would like to have a solution that allowed us to whitelist multiple ips. we could certainly attempt to do this with a regular expression, but regexes, as powerful as they are, are difficult to write and harder to read and if you suddenly have to add a seventh address on short notice it’s probably not going to be a great experience.

we can avoid all that by using nginx’s map function to, well, map ip addresses to boolean values. allowed ips get set to true, all other ip addresses are set to false, and then we use our if to check that boolean. it’s a pretty slick solution.

everybody stand back, i know how to use nginx’s map function
map $remote_addr $allow_ip {
  default       0;
  127.0.0.1     1;
  151.101.2.217 1;
}

server {

    server_name gbhorwood.test;
    root "/var/www/html/gbhorwood/test";

    index index.html index.htm index.php;

    charset utf-8;

    location / {

        #  show downtime html file if not whitelisted ip
        if ($allow_ip = "0") {
            rewrite ^ /static/down.html break;
        }

    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  off;
    sendfile off;

    client_max_body_size 100m;

    listen 80;
}

at the very top of this configuration, outside of the server block, we are calling map.

the map function has three arguments:

  • the value to test: this is $remote_addr, nginx’s internal variable that holds the ip address of the visitor. this is the value that we will be testing in the list of test cases below.
  • the variable to set: the variable that will hold the result of our tests. in this example, a boolean.
  • the list of test cases: a list of tuples, essentially. if the left value matches the value to test, $remote_addr in our example, then the right value is set in the variable $allow_ip

in essence, what this call to map does is look at the user’s ip address held in $remote_addr and then set the value of $allow_ip. it’s essentially a switch/case.

once map has run, we can use the $allow_ip variable in our if statement to serve our downtime html file to everyone in the world, except us.

Posted by: grant horwood

co-founder of fruitbat studios. cli-first linux snob, metric evangelist, unrepentant longhair. all the music i like is objectively horrible. he/him.

Leave a Reply