Geofencing
Your site is available only in certain regions, or offers content that varies between regions. Whether it's at the country level or down to the square kilometer, Fastly's geolocation data offers a way to group and route traffic in a regionally specific way.
Instructions
Whether you want to separate the US, European and Asian editions of your website, block access to content from regions for which you don't have distribution rights, show content that is hyper-local to the user's physical location, or any combination of these, you are constructing a geofence. In this solution pattern, we'll explore a generic solution that accommodates a myriad of use cases.
You'll quickly see that for simple use cases (e.g., 'block all requests from Denmark') much of the solution can be skipped, but the principles remain the same.
Make sure the solution executes in the right place
Fastly services support a number of features that can cause subroutines to be executed more than once, such as shielding and restarts. You will want to ensure that your code only runs once. Start with this code at the end of vcl_recv
:
# Only enable...
if ( fastly.ff.visits_this_service == 0 && # on edge nodes (not shield) req.restarts == 0 # on first VCL pass) { # Remainder of this tutorial's RECV code goes here}
This code checks that the request is not on a shield machine, and that it is on a first pass though the configuration (prior to any restart
). You could also use this opportunity to add an on/off switch linked to a key in a configuration table.
Now that you have sufficient guardrails in place, you can add the logic inside of the if
block that you just created.
Expose Fastly-provided geolocation data to your origin
Within the configuration of a Fastly service, geographic information about the requesting client is available in a number of different variables. These divide up requests into buckets based on well known classifiers of various levels of granularity:
- Continent:
client.geo.continent_code
provides a convenience value based on an arbitrary classification of countries into continents. - Country:
client.geo.country_code
offers ISO 3166-1 alpha-2 country classification. - Point:
client.geo.latitude
andclient.geo.longitude
identify the precise point which represents our best guess of the request's origin. This is not the current device location, although a mobile app could use the device’s location services to supply these.
IMPORTANT: The classification used by client.geo.continent_code
is geographic, not political, therefore 'EU' refers to an arbitrary definition of the continent of Europe and not to the political entity of the European Union.
The simplest thing you can do is therefore to pass the available location data to your origin. Add this to the vcl_recv
subroutine:
set req.http.client-geo-country = client.geo.country_code;set req.http.client-continent = client.geo.continent_code;
Use the location data on your origin
The information about the user's region is presented to your origin server in the HTTP header - client-geo-country
, for example. You can use this information to adjust the response you generate.
It's vital that you tell Fastly whether you used the region information when you generated the response. Requests for things like images or scripts might well deliver the same content to a user regardless of whether they're in Bangalore or Birmingham, so you don't want Fastly to cache those separately. But you do want us to cache separately if the responses are tuned according to the location specified in the request. You can do that using a Vary
header:
Vary: client-geo-country
Now, Fastly needs to store a separate variation of the resource for every possible value of client-geo-region
. In practice, we'll only store the most popular few hundred, in each POP, which is fine for countries or continents.
Group countries into custom regions
Probably the most useful and flexible way to use geo-targeting, this approach takes a list of possible values from the variable of your choice and maps them into a (usually smaller) set of region names, including a default 'catch-all' region. This is usually only useful if you are targeting countries, but is especially useful for countries because they vary wildly in size. You almost certainly don't want users in Vatican City (VA
, population 1,000) to be unable to benefit from content that we've cached as a result of requests from Italy (IT
, population 60 million).
Start by defining the mapping in the INIT space:
table region_defs { "AT": "region-1", "BE": "region-1", "BG": "region-1", "HR": "region-1",
"IT": "region-2", "VA": "region-2",
"NO": "region-3", "CN": "region-3",
"_default": "region-other"}
Now you can create another header to add to your origin request in the vcl_recv
subroutine, after your previous code:
set req.http.client-geo-region = table.lookup( region_defs, req.http.client-geo-country, table.lookup(region_defs,"_default"));
You are laying out this information efficiently in a VCL table. When you ship your solution, you can then manage the data independently of your configuration using an edge dictionary.
Allow regions to be blocked
If you are creating custom regions, then you have the opportunity to set one of those region names to 'blocked' or some other special string that you can use to identify regions from which requests will not be accepted. If you intend to potentially make use of this, you'll need to support blocked regions in your VCL. Add the following to your vcl_recv
code:
if (req.http.geo-region-id == "blocked") { error 618 "geofence:blocked";}
And catch the error in the vcl_error
subroutine, to generate a synthetic response:
if (obj.status == 618 && obj.response == "geofence:blocked") { set obj.status = 403; set obj.response = "Forbidden"; set obj.http.content-type = "text/plain"; synthetic "Sorry, our service is currently not available in your region"; return (deliver);}
This pattern, known as a 'synthetic response', involves triggering an error from somewhere else in your VCL, catching it in the vcl_error
subroutine, and then converting the error obj
into the response that you want to send to the client. To make sure you trap the right error, it's a good idea to use a non-standard HTTP status code in the 6xx
range, and also to set an error 'response text' as part of the error
statement. These two pieces of data then become obj.status
and obj.response
in the vcl_error
subroutine. Checking both of them will ensure you are trapping the right error.
Once you know you are processing the error condition that you triggered from your earlier code, you can modify the obj
to create the response you want. Normally this includes some or all of the following:
- Set
obj.status
to the appropriate HTTP status code - Set
obj.response
to the canonical HTTP response status descriptor that goes with the status code, e.g., "OK" for 200 (this feature is no longer present in HTTP/2, and has no effect in H2 connections) - Add headers using
obj.http
, such asobj.http.content-type
- Create content using
synthetic
. Long strings, e.g. entire HTML pages, can be included here using the{"...."}
syntax, which may include newlines - Exit from the
vcl_error
subroutine by explicitly performing areturn(deliver)
Now change some of your region mappings to be 'blocked' as needed:
..."NO": "blocked",...
Use lat/long coordinates as a grid
If you pick something that comes as a single value, like country or continent code, the solution is pretty simple, as we've seen above. However, if your input is a latitude and longitude, you need to reduce these to a single value that we can use as a cache key. And since there are an infinite number of possible lat/long combinations, some rounding is essential to provide any hope of caching the response from your origin servers.
Start by 'snapping' lat/long coordinates to a grid. This requires choosing a granularity for your grid in degrees. 0.1 decimal degrees of longitude or latitude is about 11km in distance on the surface of the Earth, and encloses an area of about 123 square kilometers. Although there are still 13 million of these covering the whole planet, most of that space is ocean or sparsely inhabited.
HINT: Fastly currently only provides service on one planet, but in future you may need to account for the radius of the planet when calculating the area enclosed by each grid square.
The conversion of the coordinates to a grid requires some temporary variables, so it is cleaner to do this in a custom subroutine. Add this code to the INIT space:
sub set_latlng { declare local var.chunk_size FLOAT; declare local var.lat FLOAT; declare local var.long FLOAT;
set var.chunk_size = 0.1; set var.lat = client.geo.latitude; set var.long = client.geo.longitude;
set var.lat /= var.chunk_size; set var.lat = math.floor(var.lat); set var.lat \*= var.chunk_size;
set var.long /= var.chunk_size; set var.long = math.floor(var.long); set var.long \*= var.chunk_size;
set req.http.client-geo-latlng = var.lat + ", " + var.long;}
The inputs are the latitude and longitude of the client user (in degrees, a number between 0 and 360), and a chunk size (the number of degrees from the north to south and east to west of a grid square). Latitude and longitude are then snapped to the grid by first dividing the input by the chunk size to get the grid index (we use math.floor
because the grid index must be a whole number), and then multiplying by the chunk size to get the coordinate of the start of the grid square. The resulting lat/long reference is a point at the north-west corner of the grid square.
Now call the custom sub at the end of the vcl_recv
subroutine:
call set_latlng;
Fastly can store up to a few hundred variants of the same cache key, per POP. Requests from the same location will tend to cluster in their closest POP, meaning the variants in each POP will likely be different and specific to that general area. At a resolution of 0.1 degrees per grid square, that should provide a reasonable cache hit ratio.
HINT: Instead of using variants of a cache key, it would be possible to actually include the geolocation in the cache key itself. This would be a more scalable solution but makes it harder to purge objects from cache later.
If you need a higher resolution, consider combining manipulation of the cache key with surrogate key tagging.
Allow the location to be overridden by the client
Fastly's location data is good, but ultimately cannot be more than a best guess as to the user's location, and for users on wireless connections could be very inaccurate. You might be better off asking them directly. If your client application is a website, consider using the web platform's Geolocation API and setting the result into a cookie. If your client is a native app, you may be able to use the platform's SDK to access geolocation data and send it in a custom header.
Of course, if the purpose of your geofence is to block certain regions from accessing your site, you should not provide the ability for the client to override the input, but if your intention is to provide region-localized content, especially at a granular level using latitude and longitude, you should definitely consider allowing the client to override the location value. Try this in your set_latlng
function:
if (req.http.cookie:latlng ~ "^([\d\.]+),\s*([\d\.]+)\z") { set var.lat = std.atof(re.group.1); set var.long = std.atof(re.group.2);} else { set var.lat = client.geo.latitude; set var.long = client.geo.longitude;}
Force geo-vary for some content types
It's much better for you to control cache variation by sending the Vary
header from origin. However, if you can't do that, you can also add the vary rule in VCL. But remember if you do this, it will apply to all requests, not just the ones where location is important.
add beresp.http.vary = "client-geo-region";
Next steps
While it does not identify a user's physical location, you could look into using the Autonomous System number, which we expose in VCL as client.as.number
, as a proxy for grouping users by ISP. Other alternative geographic identifiers include the Fastly POP that is handling the request (server.datacenter
) and other variables based on the user's IP address, such as postal code (client.geo.postal_code
), ISO-3166 subdivision (client.geo.region
) or time zone (client.geo.gmt_offset
).
Related content
VCL Reference:
client.geo.continent_code
client.geo.country_code
client.geo.city
client.geo.latitude
client.geo.longitude
math.floor
Blog posts:
Quick install
The embedded fiddle below shows the complete solution. Feel free to run it, and click the INSTALL
tab to customize and upload it to a Fastly service in your account:
Once you have the code in your service, you can further customize it if you need to.
All code on this page is provided under both the BSD and MIT open source licenses.