Skip to content

geoip-database-provider

Modules

A single, credential-free NixOS provider for the MaxMind GeoLite2 databases (City, Country, ASN). One host-wide oneshot fetches the .mmdb files into a shared directory; every consumer (log analyzers like GoAccess, firewall geo-blocking, geoip-aware apps) reads that directory directly and never downloads its own copy.

The problem

  • MaxMind gates the official downloads. Fetching GeoLite2 from MaxMind requires an account and a license key. That means a credential to store, scope, and rotate on every host that wants a geo database — annoying for a fleet and overkill when several services on one host all want the same file.
  • Consumers race the download. A service that needs the database at start-up will find an empty directory if it boots before anything has fetched it.

The approach and its traps

  • P3TERX mirror instead of MaxMind. The default mirrorBaseUrl points at the P3TERX/GeoLite.mmdb GitHub mirror, which republishes the same GeoLite2 files under the same license with no auth wall — so there are no credentials to store or rotate. The trade-off: you trust a third party for freshness and integrity, and there is no checksum verification of the downloaded files. If that trade-off is unacceptable, point mirrorBaseUrl at your own mirror or a MaxMind-authenticated endpoint.

  • RemainAfterExit gates consumers. The updater is a oneshot with RemainAfterExit = true, so after a successful run the unit stays reported as active. A consumer service that declares after = [ "geoip-updater.service" ]; wants = [ "geoip-updater.service" ]; therefore won't start until the databases have been fetched at least once. This is the whole point of the pattern — ordering, not just a cron job.

  • Activation-time initial download. The systemd timer's OnBootSec fires a few minutes after boot, but a consumer deployed alongside this module in the same activation would find an empty directory in the meantime. The system.activationScripts block does a one-time synchronous fetch when the first database file is missing, closing that gap. It ends with || true so a failed download never aborts system activation.

  • Jitter avoids a thundering herd. RandomizedDelaySec (default 1h) spreads the scheduled refresh across a fleet so many hosts don't all hit the mirror in the same minute.

Usage

{
  imports = [ ./modules/geoip-database-provider ];

  modules.services.geoip-databases.enable = true;
}

A consumer service gates on the provider like this:

systemd.services.my-geoip-consumer = {
  after = [ "geoip-updater.service" ];
  wants = [ "geoip-updater.service" ];
  # reads /var/lib/geoip-databases/GeoLite2-City.mmdb etc.
};

Options

Option Default Purpose
enable false Turn the provider on.
dataDir /var/lib/geoip-databases Where the .mmdb files land; consumers read here.
user / group geoip System user/group owning the dir and running the updater.
mirrorBaseUrl P3TERX GitHub mirror Base URL each database filename is appended to.
databases City, Country, ASN .mmdb Filenames to fetch; the first is the presence probe.
updateInterval weekly Refresh cadence (OnCalendar format).
randomizedDelaySec 1h Jitter on the scheduled refresh.

Caveats

  • No integrity/checksum verification of the downloaded databases with the default mirror. Transport is TLS-verified, but the content is trusted on the word of a third-party GitHub account; a fixed hash can't be pinned because the databases are mutable (refreshed weekly). If that mirror or account were compromised, poisoned mappings would land silently — so treat this data as advisory, and if you feed it into firewall geo-blocking or other security-sensitive decisions, host your own mirror or a MaxMind-authenticated endpoint via mirrorBaseUrl instead.
  • The updater hard-fails (curl -fL) on an HTTP error so a bad fetch doesn't silently overwrite a good database with an error page. An already-present old copy is left in place if a later refresh fails.
  • GeoLite2 accuracy and the mirror's update lag are inherited from upstream; this module only handles distribution and ordering.

Source

modules/geoip-database-provider/default.nix
# GeoIP database provider — one shared, credential-free GeoLite2 mirror.
#
# A single oneshot service downloads the GeoLite2 City/Country/ASN databases
# into a shared directory. Its `RemainAfterExit = true` keeps the unit "active"
# after a successful run, so consumer services can order themselves After/Wants
# geoip-updater.service and be guaranteed the .mmdb files exist before they start.
#
# See README.md for the why/traps (P3TERX mirror vs MaxMind account wall,
# RemainAfterExit gating, activation-time initial download, jitter).
{
  config,
  lib,
  pkgs,
  ...
}:
with lib;
let
  cfg = config.modules.services.geoip-databases;

  # Each entry: filename written into dataDir. The mirror serves them all
  # under the same path prefix (cfg.mirrorBaseUrl).
  databases = cfg.databases;

  geoipUpdater = pkgs.writeShellScriptBin "geoip-updater" ''
    set -eu

    GEOIP_DIR="${cfg.dataDir}"
    mkdir -p "$GEOIP_DIR"

    for db in ${escapeShellArgs databases}; do
      echo "Downloading $db ..."
      ${pkgs.curl}/bin/curl -fL -o "$GEOIP_DIR/$db" \
        "${cfg.mirrorBaseUrl}/$db"
    done

    echo "GeoIP databases updated successfully!"

    chmod 644 "$GEOIP_DIR"/*.mmdb
  '';

in
{
  options.modules.services.geoip-databases = {
    enable = mkEnableOption "shared GeoIP (GeoLite2) database provider";

    dataDir = mkOption {
      type = types.str;
      default = "/var/lib/geoip-databases";
      description = ''
        Directory the .mmdb files are written to. Consumers read this path
        directly (they should never download their own copy).
      '';
    };

    user = mkOption {
      type = types.str;
      default = "geoip";
      description = "System user that owns the data directory and runs the updater.";
    };

    group = mkOption {
      type = types.str;
      default = "geoip";
      description = "System group that owns the data directory.";
    };

    mirrorBaseUrl = mkOption {
      type = types.str;
      default = "https://github.com/P3TERX/GeoLite.mmdb/raw/download";
      description = ''
        Base URL each database filename is appended to. The default is a
        no-auth GitHub mirror of MaxMind's GeoLite2 files (sidesteps the
        MaxMind account + license-key wall — see README). Point this at your
        own mirror or a MaxMind-authenticated endpoint if you prefer.
      '';
    };

    databases = mkOption {
      type = types.listOf types.str;
      default = [
        "GeoLite2-City.mmdb"
        "GeoLite2-Country.mmdb"
        "GeoLite2-ASN.mmdb"
      ];
      description = ''
        Database filenames to fetch from mirrorBaseUrl. The first entry is
        also used as the presence probe by the activation script.
      '';
    };

    updateInterval = mkOption {
      type = types.str;
      default = "weekly";
      description = "How often to refresh the databases (systemd OnCalendar format).";
    };

    randomizedDelaySec = mkOption {
      type = types.str;
      default = "1h";
      description = ''
        Jitter added to the scheduled refresh. Spreads the fetch across a
        fleet so many hosts don't hit the mirror in the same minute.
      '';
    };
  };

  config = mkIf cfg.enable {
    users.users.${cfg.user} = {
      isSystemUser = true;
      group = cfg.group;
      description = "GeoIP database updater";
    };

    users.groups.${cfg.group} = { };

    systemd.tmpfiles.rules = [
      "d ${cfg.dataDir} 0755 ${cfg.user} ${cfg.group} - -"
    ];

    systemd.services.geoip-updater = {
      description = "Update GeoIP databases";
      after = [ "network-online.target" ];
      wants = [ "network-online.target" ];
      wantedBy = [ "multi-user.target" ];

      serviceConfig = {
        Type = "oneshot";
        # Stay "active" after a successful run so consumers ordered
        # After=/Wants=geoip-updater.service only start once the DBs exist.
        RemainAfterExit = true;
        ExecStart = "${geoipUpdater}/bin/geoip-updater";
        User = cfg.user;
        Group = cfg.group;
        StandardOutput = "journal";
        StandardError = "journal";

        PrivateTmp = true;
        ProtectHome = true;
        NoNewPrivileges = true;
        ReadWritePaths = [ cfg.dataDir ];
      };
    };

    systemd.timers.geoip-updater = {
      description = "Update GeoIP databases periodically";
      wantedBy = [ "timers.target" ];
      partOf = [ "geoip-updater.service" ];

      timerConfig = {
        OnCalendar = cfg.updateInterval;
        OnBootSec = "5min";
        Persistent = true;
        RandomizedDelaySec = cfg.randomizedDelaySec;
      };
    };

    # First-boot / first-deploy: pull the databases immediately so a consumer
    # deployed alongside this module doesn't find an empty directory before the
    # timer's OnBootSec fires. `|| true` keeps a failed download from aborting
    # activation.
    system.activationScripts.geoip-databases = ''
      if [ ! -f ${cfg.dataDir}/${builtins.head cfg.databases} ]; then
        echo "GeoIP databases not found. Starting initial download..."
        ${pkgs.systemd}/bin/systemctl start geoip-updater.service || true
      fi
    '';
  };
}