nginx: doing ip geolocation right in nginx
Tags: nginx, linux, php, webdev
Published: 2024-06-11 14:32:40

knowing the geolocation of your site's users is handy thing. maybe you want to force your canadian users into a degraded, second-rate version of your ecommerce site, or maybe you want to redirect people from brazil to a frontend you ran through google translate, or maybe you just want to block the netherlands because you hate the dutch. there are reasons.

traditionally, this gets done by calling a third-party geolocation api. you gotta fiddle with api keys and manage rate limits and write a bunch of code. or... we could just let nginx do it all for us.

in this post we're going to go over how to do ip geolocation for country and city in nginx and get that data into our web app where we can use it. all of this was written for ubuntu-like systems runing nginx `1.18.0`. 

![geordi approves](https://gbh.fruitbat.io/wp-content/uploads/2024/06/meme_geoip.jpg "the geordi approves meme")<figcaption>doing geolocation in your httpd</figcaption>

## test if have the necessary `nginx` module
geoip lookup in nginx is done by the `http-geoip2` module. on ubuntu-like systems this module usually comes pre-installed, although ymmv.

to test if our nginx has `http-geoip2` installed, we can run:

```bash
nginx -V 2>&1 | sed -n "/http-geoip2/p"
```

this just takes the output of nginx's version data and tests if `http-geoip2` is listed in it. if our nginx has the module, we will see output that looks something like.

```
nginx version: nginx/1.18.0 (Ubuntu)
built with OpenSSL 3.0.2 15 Mar 2022
TLS SNI support enabled
configure arguments: --with-cc-opt='-g -O2 <snip> --with-threads --add-dynamic-module=/build/nginx-d8gVax/nginx-1.18.0/debian/modules/http-geoip2 <snip>
```

if we don't have the module, the output will be blank.

## get the maxmind databases
[maxmind](https://www.maxmind.com/en/home) is technically a purveyor of anti-fraud software, but the thing they're really known for is compiling and distributing ip-to-location databases. these are made available as either plain ol' csvs or in a 'custom but open' binary format that has an associated c library for speedy searches. we're going to use the binary format.

let's install the c library (and associated executable) first. on ubuntu-like systems this is just an `apt` away:

```bash
sudo add-apt-repository ppa:maxmind/ppa
sudo apt update
sudo apt install libmaxminddb0 libmaxminddb-dev mmdb-bin
```

on non-ubuntu-based distros, we'll be stuck with [compiling it from source](https://github.com/maxmind/libmaxminddb/blob/main/README.md#installing-from-a-tarball) like it's the nineties.

getting the databases themselves is actually more work. first, we have to [sign up for an account on maxmind](https://www.maxmind.com/en/geolite2/signup) and jump through the email verification and 2fa login hoops.

once we have our account and have signed in, on our 'account summary' page there will be a text link that reads 'download databases'. clicking that leads to a list of downloadables. the ones we want are:

* GeoLite2 City 
* GeoLite2 Country 

make sure you get the format 'GeoIP2 Binary (.mmdb)'. these are tarballs. download them to the server where you are running nginx.

untarring the archives is straightforward:

```bash
sudo tar -zxvf GeoLite2-City_20240604.tar.gz
sudo tar -zxvf GeoLite2-Country_20240604.tar.gz
```

next, we will make a directory to store the `mmdb` files. i like to put these things in `/var`, which is maybe contentious since the os doesn't write to them, but they are, technically, databases. and databases go in `/var`.

```bash
sudo mkdir /var/maxmind
```

we then put *just* the `mmdb` files in there.

```bash
sudo mv ./GeoLite2-City_20240604/GeoLite2-City.mmdb /var/maxmind
sudo mv ./GeoLite2-Country_20240604/GeoLite2-Country.mmdb /var/maxmind 
```

we now have our geolocation databases and the library to query it.

### fun fact: you can lookup ip locations on the command line
along with the c library for mmdb, which nginx needs, we also installed `mmdblookup`, an executable that can query the geoip database. it takes two arguments: the `--file` where the database lives, and the `--ip` we want to lookup.

```bash
mmdblookup --file /var/maxmind/GeoLite2-City.mmdb --ip <some ip address>
```

that should spit out a small moutain of information about the ip location in a format that looks like json, but is not actually json.

if we want to narrow our output, we can specify a path to the desired data in the command. for instance, looking at the not-quite-json output, we see there is a `city` object which contains a `names` object which has a value keyed at `en` for the english name of the city. we can get just that english city name by adding the path.

```bash
mmdblookup --file /var/maxmind/GeoLite2-City.mmdb --ip <some ip address> city names en
```

## configure `nginx.conf` to do the actual lookup
nginx will do our geolocation lookup automatically, but first we need to configure it so it knows things like where we stored those `mmdb` databases and what data we want to extract from them. we'll do this in the `http` block of our `nginx.conf` file.

```
##
# GeoIp country
##
geoip2 /var/maxmind/GeoLite2-Country.mmdb {
    $geoip2_data_country_name country names en;
    $geoip2_data_country_code country iso_code;
}

##
# GeoIp city
##
geoip2 /var/maxmind/GeoLite2-City.mmdb {
    $geoip2_data_city_name city names en;
}
```

here, we have the `geoip2` directive entered twice for two different databases: the country one and the city one. both of these directives will be run.

the body of each of those blocks, the stuff in the curly braces, is variable assignments. if we recall, when we were using `mmdblookup` in the 'fun fact' section above, we could pass a path to the value we wanted in the record. here we're taking a variable name, ie `$geoip2_data_city_name` and assigning it to the value at the path `city names en` for the record for the current ip address. of course we can use any variable name and any valid path we want here, but city name and, country name and iso code are probably the most useful.

this is all that's required for nginx to extract geolocation data for an ip.

**note:** the complete `nginx.conf` file is at the bottom of this post, because providing snippets without context is Not Cool.

## configure our virtual host to make our geodata available
of course extracting geolocation data is only useful if we can get it into our script.

we're going to do that by taking the variables `geoip2` made in our `nginx.conf` file and setting them as `fastcgi_params` in the `location` block of our virtual host. here's an example

```
location ~ \.php$ {
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;

    ###
    # geoip2 variables
    fastcgi_param COUNTRY_CODE $geoip2_data_country_code;
    fastcgi_param COUNTRY_NAME $geoip2_data_country_name;
    fastcgi_param CITY_NAME $geoip2_data_city_name;

    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_intercept_errors off;
    fastcgi_buffer_size 16k;
    fastcgi_buffers 4 16k;
    fastcgi_connect_timeout 300;
    fastcgi_send_timeout 300;
    fastcgi_read_timeout 300;
}   
```

if you've ever looked at an nginx configuration for a php site before, this `location` block should look somewhat familiar. the important addition here, of course, is where we call `fastcgi_param` and pass it two arguments: first, the name of the `fastcgi` paramater we want to declare and, second, the name of the `geoip2` variable we set in `nginx.conf`.

## get location data in one line of php

here's a fun fact about `fastcgi_param`s: they're available in php's `$_SERVER` array.

with that in mind, let's write a one line php script (not including the opening tag) that will display the country name of the user:

```php
<?php
print $_SERVER['COUNTRY_NAME'];
```

if we serve this script on our geolocation-enabled nginx, behold: we will see the country name associated with the user's ip address. 

## addendum 1: the complete `nginx.conf`

the complete nginx.conf used in the examples above is [available as a gist](https://gist.github.com/gbhorwood/0714ba45d956d1c5d0d46ed0dbfed4eb).



## addendum 2: the complete virutal host configuration
the virtual hosts file used for the examples with the following elements removed

* `server_name`
* `root`
* php version 
* access log name
* error log name

is [available as a gist](https://gist.github.com/gbhorwood/0714ba45d956d1c5d0d46ed0dbfed4eb).

> 🔎  this post was originally written in the [grant horwood technical blog](https://gbh.fruitbat.io/2024/06/10/nginx-doing-ip-geolocation-right-in-nginx/)