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:
using
allow
/deny
to show unapproved ip addresses a 403 with an optional custom error pageusing
if
to return a downtime html string to everyone except an approved ip addressleveraging
map
andif
to allow multiple ip addresses access to the site; downtime for everyone else.
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,
$remot_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 $allowed_ip
. it's essentially a switch/case
.
once map has run, we can use the $allowed_ip
variable in our if
statement to serve our downtime html file to everyone in the world, except us.
🔎 this post originally appeared in the grant horwood technical blog