No description
  • Shell 94%
  • Dockerfile 6%
Find a file
2026-07-26 21:13:48 -05:00
.env.example fix: refresh metrics on their own schedule and correct subcommand exit codes 2026-07-26 15:03:31 -05:00
.gitignore chore: add env example and pass optional tuning through compose 2026-07-26 13:22:05 -05:00
boot fix: refresh metrics on their own schedule and correct subcommand exit codes 2026-07-26 15:03:31 -05:00
crontab fix: refresh metrics on their own schedule and correct subcommand exit codes 2026-07-26 15:03:31 -05:00
docker-compose.yml feat: report consumer adoption and emit Prometheus metrics after every run 2026-07-26 14:23:09 -05:00
Dockerfile feat: shared GeoLite2 updater publishing atomic versioned snapshots for host services 2026-07-26 11:46:54 -05:00
geoip-sync fix: make the ref directory writable by unprivileged consumers 2026-07-26 21:13:48 -05:00
LICENSE docs: add MIT license and make the shared host directory configurable 2026-07-26 12:53:32 -05:00
README.md fix: refresh metrics on their own schedule and correct subcommand exit codes 2026-07-26 15:03:31 -05:00

geoip-updater

One copy of MaxMind's GeoLite2 databases, kept current on a schedule, shared by every service on the host that needs geolocation.

Last updated: 2026-07-26

Why this exists

Each service that wanted geolocation used to carry its own copy, downloaded at image build time. That welds the data's refresh cadence to the application's release cadence: fresh data means rebuilding and redeploying the app, so in practice the data goes stale and nothing surfaces it, because a stale GeoIP database returns a plausible wrong city rather than an error.

MaxMind's own guidance points the same way. Their download documentation recommends downloading "to a local repository and distributing from there" when serving multiple servers.

How it works

/opt/storage/geoip/
├── working/                        geoipupdate downloads here, no consumer reads it
├── versions/20260726T040112Z/      hardlink snapshots
├── current -> versions/2026...     consumers read through this
├── generation                      one line, the live version id
└── refs/                           one file per consumer, naming what it serves

One container, two jobs, run in sequence by one script rather than coordinated between two processes:

  1. geoipupdate checks MaxMind. It sends the hash of the local file and downloads only when the remote build differs. A new database is written to a temporary name, verified against the expected MD5, fsynced, and renamed into place, so a partial download is never visible under the real filename.
  2. If anything changed, geoip-sync snapshots working/ into a dated version directory, flips the current symlink with a single rename(2), and writes the new version id to generation.

Consumers watch generation and reload when it changes. Nothing restarts.

boot runs the sync once at container start and then hands off to cron. That is deliberate: cron alone would silently skip a window that passed while the container was down, so every start, redeploy, and reboot re-checks immediately.

Why daily, when MaxMind publishes twice a week

A check that finds nothing new costs one small metadata request, not a download. Checking daily is therefore cheaper and fresher than trying to land on their Tuesday and Friday publish window, which would risk checking hours before a build lands. GeoLite accounts allow 30 downloads per day and this uses at most three.

Consumer contract

Four lines in the consuming service's compose file. The container path is the same everywhere so this is copy-pasteable across projects:

services:
  myapp:
    volumes:
      - /opt/storage/geoip:/geoip:ro          # databases, read-only
      - /opt/storage/geoip/refs:/geoip/refs   # this consumer's ref, writable
    environment:
      GEOIP_PATH: /geoip/current/GeoLite2-City.mmdb

The env var name is each application's own business. The path is not.

Mount the root, not current. A bind mount resolves symlinks at mount time, so mounting /opt/storage/geoip/current pins the container to whichever version was live when it started and the flip never reaches it.

Two mounts, not one. The databases are read-only, but a consumer has to write its own ref file to be visible in reports. Docker applies bind mounts in order of destination depth, so the nested refs mount shadows the read-only parent and ends up writable while the databases stay protected. A consumer that mounts only the read-only root still works, it just never appears in --status or the metrics.

Reloading without a restart

A consumer that reads the database once at startup will not see a new version until it restarts. To pick up changes in place:

  1. Poll /geoip/generation on an interval. 10 minutes is the recommendation.
  2. When it differs from the value last loaded, open the database again through /geoip/current/ and swap the in-memory reader atomically. Requests already in flight finish against the old reader.
  3. Write the version id to /geoip/refs/<service-name> on every poll, not only on change. The mtime is what marks the consumer as alive.

The interval is not about noticing new data quickly. Databases change twice a week and the updater checks daily, so adopting at 04:05 or 04:15 is indistinguishable. What binds it is the staleness window:

Poll at least four times within GEOIP_REF_STALE_MINUTES.

At a tighter ratio, one delayed poll flaps a healthy consumer into "registered, not running". The shipped defaults are a matched pair, 10-minute polling against a 60-minute window, giving six writes per window and enough slack to ride out a redeploy. Change one and change the other.

That one file is read at two thresholds, which is why there is no separate registration step:

Ref age Consumer is Effect
under GEOIP_REF_STALE_MINUTES running holds the version it names, reported live
under GEOIP_REGISTRY_TTL_DAYS registered, not running still reported, holds nothing
beyond that gone ref reaped, consumer disappears from reports

A consumer down for an hour stays visible as "has not adopted yet" instead of vanishing. One decommissioned for a week ages out on its own, so a retired service never needs removing by hand.

Holding is deliberately limited to running consumers. Deleting a version a stopped consumer is not reading cannot hurt it, since it resolves through current on its next start. Adoption also cannot defer deletion indefinitely: GEOIP_RETENTION_DAYS overrides every ref, because the licence requires it.

Licence compliance

GeoLite2 is free but not unconditional. Three requirements shape this design.

This section summarises the terms as they read on 2026-07-26 and explains how this tool is built around them. It is not legal advice, and MaxMind can change the terms at any time. Read the current GeoLite End User License Agreement rather than relying on a third party's summary of it.

Retention. The GeoLite EULA requires ceasing use of and destroying old versions within 30 days of a new release. geoip-sync prunes on every run, and the 25-day backstop in RETENTION_DAYS applies regardless of refs, so a stuck consumer cannot hold a superseded database past the window. The ref-counting path only ever deletes sooner than the licence requires.

Attribution. The EULA permits building applications for, and displaying data to, users outside your organization, provided the data is attributed to MaxMind. The suggested wording is:

This product includes GeoLite Data created by MaxMind, available from https://www.maxmind.com

Any consumer that shows geolocation to someone outside the organization owes this attribution in a user-visible surface. That obligation sits with the consumer, not with this repo.

Sharing. The EULA prohibits disclosing the data to third parties without consent. Sharing one copy between services you operate on your own infrastructure is internal use, which is what the licence grants and what MaxMind's own multi-server guidance describes. Serving the raw .mmdb to anyone else, or committing it to a repository, would not be.

Also prohibited, and worth knowing before adding a consumer: FCRA uses (credit, insurance, employment decisions) and attempting to identify a specific household, individual, or street address. City-level display is fine.

Deployment

Dokploy Compose service, Git source, autodeploy on.

Credentials go in Dokploy's environment UI, never in this repo:

  • MAXMIND_ACCOUNT_ID
  • MAXMIND_LICENSE_KEY

Redeploy is the manual "update now" button, since a container start always syncs before scheduling.

/opt/storage/geoip does not need pre-creating. Docker creates a missing bind-mount source, and the first run publishes an initial version when no current symlink exists yet.

Verifying

The container staying up proves nothing. Check the data:

readlink /opt/storage/geoip/current      # which version is live
cat /opt/storage/geoip/generation        # what consumers are being told
ls /opt/storage/geoip/refs/              # who has acknowledged it
docker logs <container> | tail           # "published ..." or "no new release"

Known wart

The upstream image declares VOLUME ["/usr/share/GeoIP"] and a child image cannot revoke it, so each container recreation leaves one empty anonymous volume behind. Nothing is written there, since GEOIP_DATA_DIR points elsewhere. docker volume prune clears them.

Reporting

Publishing correctly is not the same as the fleet having adopted. A reload loop that quietly stops running looks exactly like one that is working, so the updater reports on its consumers rather than assuming.

Everything is derived from the ref files consumers already write for pruning. No consumer needs a metrics endpoint, a Prometheus client, or any instrumentation of its own, and a new service becomes monitored by writing a ref rather than by being added to a list here.

Human-readable

$ geoip-sync --status
current:   20260726T192017Z
last run:  2026-07-26 19:20:25Z
versions:  1 retained
consumers:
  downservice          20260726T192017Z     registered, not running
  ssh-whoami           20260726T192017Z     current
  trace                20260719T041102Z     BEHIND

Metrics

Prometheus text format at GEOIP_METRICS_PATH, written atomically after every run. It defaults inside the shared directory so it is always produced and always readable with cat, whether or not anything scrapes it. Point it at node_exporter's textfile collector directory to have it picked up.

geoip_last_run_timestamp_seconds
geoip_publish_timestamp_seconds
geoip_versions_retained
geoip_oldest_version_age_seconds
geoip_consumer_registered{service="..."}
geoip_consumer_live{service="..."}
geoip_consumer_adopted_current{service="..."}

Three alerts worth wiring, the last being a licence alarm rather than an operational one:

  • geoip_last_run_timestamp_seconds older than 48h: the updater is stuck
  • geoip_consumer_adopted_current == 0 for over an hour: a reload stopped working
  • geoip_oldest_version_age_seconds past GEOIP_RETENTION_DAYS: the retention backstop is not firing

The first and third need no consumers and are useful from the first run. The second only reports once consumers write refs.

Scraping is optional and deliberately so. If node_exporter has no --collector.textfile.directory configured, the file is still written and still tells you everything by eye.

Configuration

Variable Default Purpose
GEOIPUPDATE_ACCOUNT_ID required MaxMind account
GEOIPUPDATE_LICENSE_KEY required MaxMind licence key
GEOIPUPDATE_EDITION_IDS required space-separated editions
GEOIP_DATA_DIR /data root of the shared layout
GEOIP_RETENTION_DAYS 25 hard prune backstop, licence-driven
GEOIP_REF_STALE_MINUTES 60 beyond this a consumer is not running
GEOIP_REGISTRY_TTL_DAYS 7 beyond this a consumer is reaped as gone
GEOIP_HEALTH_MAX_AGE_MINUTES 2880 healthcheck tolerance for a failed run
GEOIP_METRICS_PATH $GEOIP_DATA_DIR/metrics.prom Prometheus textfile output
GEOIP_HOST_DIR /opt/storage/geoip host path backing the shared directory

Commands

Command Purpose
(default entrypoint) sync once, then hand off to cron
geoip-sync one sync: check, publish if changed, reap, prune, report
geoip-sync --report recompute metrics from disk, no network
geoip-sync --status human-readable fleet summary
geoip-sync --health container healthcheck, exits non-zero when stale

License

MIT, see LICENSE.

The image builds on maxmind/geoipupdate, which is dual licensed under Apache-2.0 and MIT. No MaxMind data is included in this repository or in the image it builds. Databases are downloaded at runtime using your own account credentials and are subject to the GeoLite EULA.