- Shell 94%
- Dockerfile 6%
| .env.example | ||
| .gitignore | ||
| boot | ||
| crontab | ||
| docker-compose.yml | ||
| Dockerfile | ||
| geoip-sync | ||
| LICENSE | ||
| README.md | ||
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:
geoipupdatechecks 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.- If anything changed,
geoip-syncsnapshotsworking/into a dated version directory, flips thecurrentsymlink with a singlerename(2), and writes the new version id togeneration.
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:
- Poll
/geoip/generationon an interval. 10 minutes is the recommendation. - 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. - 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_IDMAXMIND_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_secondsolder than 48h: the updater is stuckgeoip_consumer_adopted_current == 0for over an hour: a reload stopped workinggeoip_oldest_version_age_secondspastGEOIP_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.