Manage SSL redirection in Nginx using maps, and save the universe

## The problem We need to be able to enforce SSL connections (HTTPS) on some URLs. We also need to be able to enforce PLAIN connections (HTTP) on others. Further, there are some URLs which we want to just stay with whatever protocol they were requested on so they won't redirect at all. One approach is to use a great big series of rewrite rules, but that would be pretty unwieldy, would involve a bunch of regular expression variable capturing, and likely also mean using more than a couple of if blocks (which I have been told are [evil (Nginx wiki - if is evil)]( and slow, even if few people have the sort of traffic where that really matters). We also may have to maintain them across two virtual hosts, making it hard to check for conflicts between the two lists. In cases where the virtual host pulls double duty serving HTTP on port 80 and HTTPS on port 443 simultaneously, we would have to check the protocol first as well, further confusing the config. There has to be a better way. We already use [Nginx maps (Nginx HttpMapModule)]( to create standard redirect lists on some sites, but mostly these are static lists which are used to redirect old URLs to new ones. We do this so that when we rebuild sites using Ruby on Rails they don't loose the Google juice that is already attached to all those horrible, horrible `default.aspx?foo=bar&578432754230` URLs… Yes, I'm looking at **you** .NET developers. Red Ant office doggo The wiki page on [Nginx maps (Nginx HttpMapModule)]( gives a basic example of using a map to redirect request URLs to new locations. But that is no good for us, because we explicitly want to redirect only if the protocol is not our preferred one. We also only want to redirect to the same URL, albeit over a different protocol. A basic map like that will send our client into a redirect loop. We would need two separate maps and that kind of takes us back to square one. There is also potentially a problem with making matches too broad in separate maps and causing redirect loops again, whereas a single map will match only one value. Using one map we can also take advantage of the map default. It's complicated, so just trust me **separate maps = do not want!** ## The solution Rather than build a map of request URIs to redirect locations, we are instead going to build a map of request URIs and their preferred protocols http, https, or none. Here's what a really simple example of that might look like: ``` map $uri $example_org_preferred_proto { default "http"; ~^/(images|css|javascript)/ "none"; ~^/sign-up/ "https"; ~^/account/ "https"; } ``` The first line defines the `map` block. The first variable `$uri` is what we are checking against in our case the incoming request URL without the protocol, host name or query string. The second variable `$example_org_preferred_proto` is what we will map the result of the match to. The second line is a special default case inside the map. Most of our URLs should always be HTTP, so if none of the patterns in the block match the requested URL, then the result will default to this value of http. You may want to default to one of the other values depending on which will save you more rules and give you the shortest list. The third line is matching our static assets directories which we need to be available over both HTTP and HTTPS. So we assign them a value of none. Since Nginx 0.9.6 you can use regular expressions inside maps. The tilde character `~` at the start of this line indicates that this is a regular expression. These work like other regular expressions in Nginx which I **wont** explain here. The fourth and fifth lines make sure that our sign ups and account management pages are always HTTPS. This could have been done in one line like the previous one, but part of the point of doing this is to create logical and readable groupings of URLs to ease ongoing maintenance. In a much longer list, this will matter more. Inside our virtual host configuration we can apply the map like this: ``` map $uri $example_org_preferred_proto { default "http"; ~^/(images|css|javascript)/ "none"; ~^/sign-up/ "https"; ~^/account/ "https"; } server { listen 80; server_name; root /var/www/; if ($example_org_preferred_proto = "https") { return 301$request_uri; } } server { listen 443 ssl; server_name; ssl_certificate /var/ssl/; ssl_certificate_key /var/ssl/; root /var/www/; if ($example_org_preferred_proto = "http") { return 301$request_uri; } } ``` Note that the map is defined outside of the `server` blocks. That's one of the advantages of this method over a series of rewrites the map is setup when Nginx starts and is available globally throughout the configuration. This is supposed to improve performance but I don't know why exactly as the mapping itself occurs on demand. I'm happy to remain ignorant of the specifics here. It also is a bit easier on the readability of your logs if you are debugging your Nginx configuration as it outputs less debug lines. The real payoff is in those `if` blocks, we simply query the preferred scheme for this request and if it doesn't match, then we redirect to it. Here Ive used permanent redirects by indicating 301, but during testing it is wise to use temporary redirects by indicating 302 instead. ## But I'm behind a load balancer which handles the SSL, so this won't work for me! True, very true. Some people are behind load balancers which do SSL termination for the incoming requests. This means that they are usually utilising a single `server` block for their virtual host configuration and that virtual host is set to only listen on port 80, because everything being passed to it is in PLAIN/HTTP. For these cases we need to make a couple of adjustments to use the X-Forwarded-Proto header that is usually sent by the load balancer. By doing so we can detect the incoming request's original protocol, a task which was done automatically in the previous example due to the separate server blocks listening on different ports. ``` map $uri $example_org_preferred_proto { default "http"; ~^/(images|css|javascript)/ "none"; ~^/sign-up/ "https"; ~^/account/ "https"; } server { listen 80; server_name; root /var/www/; # If there is no preferred protocol, then prefer the current protocol if ($example_org_preferred_proto = "none") { set $example_org_preferred_proto $http_x_forwarded_proto; } # Redirect if the forwarded protocol doesn't match the preferred scheme if ($example_org_preferred_proto != $http_x_forwarded_proto) { return 301 $example_org_preferred_proto://$request_uri; } } ``` We have to add an additional `if` block here so that our URLs that prefer none don't go into redirect loops. It simply re-maps the URLs preferred protocol to whatever the forwarded protocol was. For those URLs, the next `if` block will never be true, so no redirect occurs for none. Great! It might be possible to map the none URLs directly to `$http_x_forwarded_proto` in the map itself, but my guess is that doing so would be slower for large lists due to the variable requiring expansion several times. In the second `if` block we simply redirect to the preferred protocol if it doesn't match the forwarded protocol from the load balancer. That covers both the remaining http and https cases. If you aren't behind a load balancer, but use a single server block listening on port 80 and 443 for HTTP and HTTPS traffic, you can probably use a similar method, substituting `$http_x_forwarded_proto` with `$scheme`. If you have a big map, then you may hit Nginx's memory limit for map hashes. This can be increased using the directives [map_hash_max_size (Nginx HttpMapModule map_hash_max_size)]( and [map_hash_bucket_size (Nginx HttpMapModule map_hash_bucket_size)]( In any case, Nginx will tell you what's up when you try to restart it. If you have any questions specific to your setup, or any suggestions for improving these methods then drop a comment here.