Skip to main content

Installing GeoIP

Two images: the API, and an optional console. The API answers on 5411, the console on 5413.

Deploying it​

You need Docker with the Compose plugin, an API key of your own choosing (any long random string, for example from openssl rand -base64 32), and optionally a free MaxMind GeoLite2 account.

Save this as docker-compose.yml:

services:
geoip:
image: ghcr.io/antelao-io/geoip:1.5.1
restart: unless-stopped
ports:
- "5411:8080"
environment:
Api__Keys__ui: ${GEOIP_API_KEY:?set GEOIP_API_KEY}
GEOIP_SEED_ADMIN_PASS: ${GEOIP_SEED_ADMIN_PASS:-}
GEOIP_SEED_ADMIN_TOTP: ${GEOIP_SEED_ADMIN_TOTP:-}
GeoIp__AccountId: ${MAXMIND_ACCOUNT_ID:-}
GeoIp__LicenseKey: ${MAXMIND_LICENSE_KEY:-}
volumes:
- geoip-data:/data
- /etc/machine-id:/etc/machine-id:ro

geoip-ui:
image: ghcr.io/antelao-io/geoip-ui:1.5.1
restart: unless-stopped
profiles: [console]
ports:
- "127.0.0.1:5413:8080"
environment:
GeoIpApi__BaseUrl: http://geoip:8080
GeoIpApi__ApiKey: ${GEOIP_API_KEY:?set GEOIP_API_KEY}
DataProtection__KeysPath: /keys
volumes:
- geoip-ui-keys:/keys
depends_on:
geoip:
condition: service_healthy

volumes:
geoip-data:
geoip-ui-keys:

Put the values in a .env file beside it:

GEOIP_API_KEY=<your API key>
MAXMIND_ACCOUNT_ID=<optional>
MAXMIND_LICENSE_KEY=<optional>
GEOIP_SEED_ADMIN_PASS=<the console administrator's password>
GEOIP_SEED_ADMIN_TOTP=<optional: the administrator's authenticator secret>

Then start the API alone, or the API and the console:

sudo docker compose up -d
sudo docker compose --profile console up -d
  • The version is pinned. The example names release 1.5.1, and both images carry the same number. Keep a release number there rather than latest, which moves the moment a new release is published, so that you can always say what is deployed. The current releases, and what each one changed, are on Release notes. The images live in the registry under those numbers as their tags, which a registry tool lists, for example skopeo list-tags docker://ghcr.io/antelao-io/geoip; the console's Release notes page says what changed in the build you run. To upgrade, change the number on both images and run sudo docker compose pull and the up -d command again. Check the image's signature too: see Verify a download.
  • The API is published on every interface in this example, over plain HTTP. Decide who may reach it before you start it: see Keeping the API key off the network.
  • The console is published on loopback only. It is a sign-in page for people, not an API for machines. Reach it over an SSH tunnel, or put a reverse proxy with HTTPS in front of it before publishing it anywhere else - passkeys work only over HTTPS, and the console keeps a sign-in only over HTTPS or on localhost. Sign in as admin with GEOIP_SEED_ADMIN_PASS, and a code from the authenticator app when GEOIP_SEED_ADMIN_TOTP is set, then add accounts for everyone else on the Users page; see Accounts.
  • Behind a reverse proxy, name it with Ui__TrustedProxies__0 (its address, or its network). Otherwise the console sees every request as coming from the proxy over plain HTTP: every sign-in is throttled and audited under the proxy's address. With none named, a forwarded address is believed from nobody, so a caller cannot name its own. A proxy in front of the API itself is named the same way with Api__TrustedProxies__0. See A proxy that is not trusted for what the console says when one is missing.
  • The key the console uses is named ui in the example. If yours has another name, set Api__ConsoleKeyName to it, or the audit log records the console's address for everyone who signs in through it.
  • Keep the geoip-data volume across an upgrade. It holds the databases, the license, and the service's database with every account and every setting saved through the API. Losing it re-downloads the GeoLite2 databases and drops the installed license and the accounts.
  • /etc/machine-id is mounted because the license is bound to the host, not the container. Without it every recreated container looks like a different machine.
At least one API key is required

Api__Keys__<name> in the API container's environment. Everything except /health needs the X-Api-Key header, and a service with no configured key cannot be reached at all. See API keys.

Keeping the API key off the network​

The API answers over plain HTTP, and every call carries the key

The API has no TLS of its own. Every call sends the API key in the X-Api-Key header, so on a network you do not control anyone who can see the traffic can read the key and use it.

Publish the API no wider than its callers need. The console reaches it over the compose network (http://geoip:8080), so none of these changes affects the console.

Who calls the APIWhat to do
Only programs on this hostPublish it on loopback: "127.0.0.1:5411:8080"
Machines on a private networkPublish it on this host's private address only, for example "10.0.0.5:5411:8080", and admit only the callers in the host firewall. Docker routes published ports past the host's INPUT rules, so filter them in the DOCKER-USER chain
Machines across the internetPut a reverse proxy with HTTPS in front of it, publish the API on loopback for the proxy to reach, and name the proxy as trusted

With a reverse proxy on the same host, the API side of the compose file becomes:

geoip:
ports:
- "127.0.0.1:5411:8080"
environment:
Api__TrustedProxies__0: 172.18.0.1

The proxy forwards to http://127.0.0.1:5411 and sends X-Forwarded-For and X-Forwarded-Proto. The address to trust is the one the API sees the proxy's requests come from: for a proxy on the same host that is the gateway of the compose network (docker network inspect <network>). A wrong or missing entry shows itself, as the next section describes.

A proxy that is not trusted​

When a proxy on the host's own networks (loopback, or a private address) forwards requests to the console or the API without being named as a trusted proxy, every visitor behind it is judged as the proxy: they share one sign-in limit, and the audit log and the session list show the proxy's address instead of theirs. The console then shows Administrators a notice on every page:

  • which address forwards, whether the console or the API saw it, how many requests, and when it was first and last seen;
  • what to set: Ui__TrustedProxies__0 on the console, or Api__TrustedProxies__0 on the API, with the address it names. Restart that container afterwards;
  • red when the address is loopback (127.0.0.1 or ::1): that is a sidecar or a proxy in the same network namespace, for example peer encryption on Azure Container Apps. Amber for any other private address, which may also be your own forward proxy; trusting it silences the notice.

The notice has no close button. It goes away 24 hours after the proxy was last seen, so once the proxy is trusted it lapses on its own.

The first time a loopback proxy is seen, the service also sends one mail to the recipients on the SMTP page. While the mail cannot be handed to the mail server (no SMTP configured, or the mail server refuses it), it is tried again every 15 minutes, and the notice says so. Once it is handed over it is never sent again, also after a restart.

A request through an SSH tunnel carries no forwarded address, so it never raises the notice.

Without MaxMind credentials​

The service still starts: it reports itself unavailable on /status and answers every lookup unknown rather than refusing callers. Nothing that depends on it goes down because a credential is missing.

Add the credential afterwards on the console's Databases page, or with PUT /credentials. See Databases and credentials.

Keeping a local copy instead​

If your lookups sit on a request path, an HTTP round trip per request buys nothing and adds an outage mode. Mirror the database file instead:

  1. Read /status and compare each edition's sha256 with the copy you hold.
  2. GET /databases/{edition} with If-None-Match: <etag>. Almost every poll answers 304.
  3. Write to a temporary file, verify the SHA-256, then rename it over the old one. The rename is atomic, and MaxMind's own reader notices the replacement and re-opens.

Daily is generous: GeoLite2 is published twice a week. Downloading the file needs a license; see Licensing.

This is not a public mirror

The files are downloaded under your own MaxMind account and served to your own machines, behind the same API key as everything else here.

Cloudflare Turnstile​

Cloudflare Turnstile confirms that a sign-in comes from a person's browser - it weighs the browser and how it behaves, usually without a puzzle - before the password is even looked at. The console's sign-in page can carry it, so a password or passkey sign-in is accepted only from a browser that passed it. It is off until both of its keys are set, and a console with no internet access leaves it off.

  1. In the Cloudflare dashboard, add a Turnstile widget for the hostname people open the console on, and copy its site key and secret key.
  2. Add them to the console's environment in the compose file, and put the values in .env as GEOIP_TURNSTILE_SITE_KEY, GEOIP_TURNSTILE_SECRET_KEY and, optionally, GEOIP_TURNSTILE_HOSTNAME:
geoip-ui:
environment:
Turnstile__SiteKey: ${GEOIP_TURNSTILE_SITE_KEY:-}
Turnstile__SecretKey: ${GEOIP_TURNSTILE_SECRET_KEY:-}
Turnstile__Hostnames__0: ${GEOIP_TURNSTILE_HOSTNAME:-}
Turnstile__Bypass: ${GEOIP_TURNSTILE_BYPASS:-false}

Turnstile__Hostnames__0 is optional: when it is set (for example to geoip.example.com), an answer solved on any other hostname is refused.

The console verifies every answer with Cloudflare, so it must reach challenges.cloudflare.com on port 443. The check fails closed: when Cloudflare cannot be reached, or a key is wrong, nobody can sign in. A refused check counts against the console's sign-in throttle like any other attempt.

Locked out by the check

Start the console with Turnstile__Bypass=true (with the lines above, GEOIP_TURNSTILE_BYPASS=true in .env), or with the start argument --Turnstile:Bypass=true (in compose, command: ["--Turnstile:Bypass=true"] on the console). The check is then off although the keys are set: the console warns about it at every start, and the audit log notes it on every sign-in. Remove it once the cause is fixed.

Settings​

Every setting is listed in the reference. The ones worth knowing on day one:

VariableDefaultWhat it is
Api__Keys__<name>-An accepted API key. At least one is required, and it cannot be revoked through the API
GeoIp__AccountId / GeoIp__LicenseKey-The MaxMind account. Overridden by anything set through the API
GeoIp__EditionIdsGeoLite2-Country GeoLite2-ASN GeoLite2-CityWhich databases to keep
GeoIp__UpdateIntervalHours24How often they are refreshed
RateLimit__EnabledfalseWhether callers are rate limited at all
GEOIP_SEED_ADMIN_PASSgeneratedThe built-in administrator's password. Unset means one is generated on the first start and written to /data/first-admin-password, which only the API's user can read
GEOIP_SEED_ADMIN_TOTP-Optional: the built-in administrator's authenticator secret in base32, at least 26 characters, which makes two-factor required for it; see A required authenticator app