Everything an IP address can tell you, over HTTP: country, city, coordinates, timezone and AS,…
1.1K
Read this in French.
Everything an IP address can tell you, over HTTP. GET / returns the caller's own address,
GET /8.8.8.8 returns someone else's: country, city, coordinates, timezone, network and
autonomous system. Your own service, on your own machine — the lookup is a local file
read, so nothing about your visitors is sent to a third party, and there is no rate limit
but yours.
Why it exists and how it is used in production: IP Info : géolocaliser une adresse IP.
.mmdb file, and
a lookup in it takes microseconds. The file is downloaded once and refreshed in the
background — see DATABASES.md.public, private, loopback,
multicast…) and normalised form come from the address itself.curl ip.example.com/me/country prints FR.curl https://ip.example.com/81.2.69.200
{
"ip": "81.2.69.200",
"version": 4,
"type": "public",
"hostname": null,
"continent": "EU",
"continent_name": "Europe",
"country": "GB",
"country_name": "United Kingdom",
"country_is_eu": false,
"region": "England",
"region_code": "ENG",
"city": "London",
"postal": "OX1",
"latitude": 51.5142,
"longitude": -0.0931,
"accuracy_radius": 10,
"timezone": "Europe/London",
"network": "81.2.69.192/26",
"asn": 12345,
"as_name": "Test ISP Ltd",
"as_network": "81.2.69.0/24"
}
Every field is always present, and an unknown value is null rather than a missing key:
a typed client has one shape to know, whether the database is loaded or not.
Two fields deserve a word. accuracy_radius is the radius in kilometres within which MaxMind
places the address — without it, four decimal places of latitude look like a street address,
which they are not. network is the block that actually answered: a /26 is a precise
answer, a /8 is a whole country.
type is what the address says about itself, and it is the field to check before trusting
the rest: public, private, loopback, link-local, shared (CGNAT), multicast,
broadcast, documentation, benchmark, reserved, unspecified. Anything that is not
public is in no geolocation database, and the service says so instead of returning an empty
object you would have to interpret.
| URL | Result |
|---|---|
/ | the caller's address |
/me, /self | the same thing — easier to read in a signed URL |
/8.8.8.8 | a given address |
/2a01:e0c:1::5 | IPv6, same treatment |
/me/country | one field, as text/plain |
/8.8.8.8/timezone | idem, for any field |
/health | probe: uptime and state of each database |
Two query parameters, on every route:
?fields=ip,country,city — restricts the response. The order of the answer never changes,
so two clients asking for the same fields in a different order share a cache entry.?lang=fr — the language of place names: de, en, es, fr, ja, pt-BR, ru,
zh-CN. Missing translations fall back to English.curl 'https://ip.example.com/81.2.69.200?fields=country,city&lang=fr'
# {"country":"GB","city":"Londres"}
curl https://ip.example.com/me/country
# FR
An absent value returns an empty body, not the string null — which is what makes the
plain-text form usable in a shell script.
Nothing to clone, nothing to build: the image is published on Docker Hub. You do need a GeoLite2 license key, which is free — MaxMind asks for an account, and that is the price of the data. Sign up here.
Create an empty folder and put this docker-compose.yml in it:
services:
ip-info:
image: smeagolworms4/ip-info:latest
container_name: ip-info
restart: unless-stopped
stop_grace_period: 30s
user: "${PUID:-1000}:${PGID:-1000}"
environment:
MAXMIND_LICENSE_KEY: "your-license-key"
#TRUST_PROXY: "true" # behind a reverse proxy
#DEFAULT_LANGUAGE: "fr"
ports:
- "${PORT_HOST:-3000}:3000"
volumes:
- ./data:/data
Then, next to it:
mkdir data
docker compose up -d
curl localhost:3000/8.8.8.8
The ./data volume matters: the databases are downloaded into it, and without it every
restart fetches 70 MB again — which MaxMind counts against your key.
It starts without a key too, and says so in the logs. It then answers with the version,
the category and the normalised form of every address, and null everywhere else. That is
enough to tell a private address from a public one, and it makes the container's first run
easy to check.
docker run -d \
--name ip-info \
--restart unless-stopped \
--user 1000:1000 \
-p 3000:3000 \
-e MAXMIND_LICENSE_KEY=your-license-key \
-v "$(pwd)/data:/data" \
smeagolworms4/ip-info:latest
npm install
MAXMIND_LICENSE_KEY=your-license-key DB_DIR=./data npm start
Node 20.6 or later, and no native dependency: no compiler, no system library.
The same service also ships as a zip ready to drop onto a single Lambda function — no second function to keep the database up to date, no scheduled job: the cold start downloads it, and each invocation checks its age in passing. LAMBDA.md covers it.
There is no database server here. GeoLite2 is a file — an .mmdb, a binary search tree the
service walks bit by bit — downloaded once and read in memory. Three ways to get one, and the
same variable covers all three:
MAXMIND_LICENSE_KEY=xxx # official download
DB_CITY_URL=https://my-bucket.s3.amazonaws.com/City.tar.gz # your own mirror
DB_CITY_URL=/srv/geoip/GeoLite2-City.mmdb # a file you already have
GeoLite2-City and GeoLite2-ASN are loaded by default. GeoLite2-Country is available for
those who only need the country and would rather have a file ten times smaller.
DATABASES.md covers the
refresh cycle, mirroring to S3 (scripts/mirror-databases.sh), and what GeoLite2 is worth in
practice.
Everything is set through environment variables, and nothing else. They are all declared in the image with their default value, so the settings can be listed without opening the documentation:
docker run --rm smeagolworms4/ip-info:latest env
An empty value is not a missing setting: it hands the decision back to the code (no database
configured, computed header, rate limiter off). .env.example lists the same variables with
comments, and a test checks that the three lists never drift apart.
| Variable | Default | Purpose |
|---|---|---|
MAXMIND_LICENSE_KEY | empty | A free GeoLite2 key. Enough on its own to configure every download |
DB_EDITIONS | city,asn | Which databases to load: city, country, asn |
DB_CITY_URL | empty | An HTTP(S) address, a local path, or file:///…. Wins over the license key |
DB_ASN_URL | empty | Same, for the autonomous-system database |
DB_COUNTRY_URL | empty | Same, for the country-only database |
DB_DIR | /data in the image | Where the files are kept. Mount it, or every restart downloads again |
DB_REFRESH_INTERVAL | 86400 | Age in seconds past which a database is downloaded again |
DB_DOWNLOAD_TIMEOUT | 120000 | Download timeout, in milliseconds |
DB_REQUIRED | false | Refuse to start without a usable database, rather than answering without geolocation for weeks |
| Variable | Default | Purpose |
|---|---|---|
PORT / HOST | 3000 / 0.0.0.0 | Listening socket |
BASE_PATH | empty | Mount prefix, e.g. /ip |
HEALTH_PATH | /health | Health probe — never logged, never signed, never cached |
TRUST_PROXY | empty | Express trust proxy: true, a hop count, or a list of IPs. Required behind a reverse proxy |
| Variable | Default | Purpose |
|---|---|---|
DEFAULT_LANGUAGE | en | Language of place names. ?lang= overrides it per request |
FIELDS | empty (all) | The fields published at all. ?fields= can only narrow this list, never reopen it |
REVERSE_DNS | false | Fill in hostname with a PTR lookup — the only thing here that talks to the network while answering |
REVERSE_DNS_TIMEOUT | 2000 | Milliseconds before giving up on the PTR lookup |
FAKE_IP | empty | Forces the caller's address, to develop without leaving your machine |
They only apply to explicit addresses: the answer about your address is always
private, no-store, since it depends on who is asking.
| Variable | Default | Purpose |
|---|---|---|
MAX_AGE | 3600 | max-age: browsers |
S_MAX_AGE | 86400 | s-maxage: shared caches |
ERROR_MAX_AGE | 60 | How long error responses are cached |
CACHE_CONTROL | empty | Replaces the header computed from the two above |
CORS_ORIGIN | * | Empty removes the CORS headers entirely |
| Variable | Default | Purpose |
|---|---|---|
SIGNATURE_KEY | empty | The HMAC secret. Empty disables signing, and the service answers everyone |
SIGNATURE_ALGORITHM | sha256 | sha256, sha1 or sha512 |
SIGNATURE_LENGTH | 16 | Hex characters kept, 8 to 128 — within the digest's own length |
SIGNATURE_TTL | 3600 | How long a signed URL stays valid, in seconds |
SIGNATURE_SKEW | 60 | Clock tolerance between the machine that signs and the one that verifies |
| Variable | Default | Purpose |
|---|---|---|
RATE_LIMIT | 0 | Requests per window and per caller. 0 disables it |
RATE_LIMIT_WINDOW | 60 | Window length, in seconds |
SHUTDOWN_TIMEOUT | 10000 | Grace period on SIGTERM, in milliseconds |
LOG_FORMAT | tiny | A morgan format, or off |
LOG_LEVEL | info | debug, info, warn, error, silent |
Off by default: with no key, the service answers everyone, which is exactly what you want on a private network or behind an authenticated proxy.
Set SIGNATURE_KEY and every URL then needs two parameters — a timestamp and a HMAC:
/8.8.8.8?d=1770000000&s=cc5849a839193e59
d is part of what is signed, so it cannot be moved, and it bounds the URL's life:
one hour by default. That is the difference between an address shared by accident and a
stolen key. The rule fits in one line, in any language:
const s = createHmac('sha256', KEY).update(`8.8.8.8?d=${d}`).digest('hex').slice(0, 16);
SIGNATURE.md has the full specification, reference values to check an implementation against, and the same three lines written out in JavaScript, PHP, Python, C# and Java.
Not strictly necessary — a lookup costs microseconds, and a single container handles far more
requests than a website will send it. But Cache-Control is emitted properly, so a CDN in
front works with no configuration: public, max-age=3600, s-maxage=86400 on explicit
addresses, private, no-store on / and /me.
That distinction is the one thing to get right if you put your own cache in front: the answer about the caller's address must never be shared, or the second visitor gets the first one's city. The service already says so in its headers; a proxy configured to ignore them will get it wrong.
When URLs are signed, max-age never promises more than the URL's remaining life — a cached
answer cannot outlive the address that produced it.
npm test
Around 90 tests, and no network access needed. The interesting part is how: the test
suite carries a small MaxMind DB writer (test/helpers.js), a hundred lines that produce
real .mmdb files — search tree, typed data section, metadata block. The service opens them
with its own reader, unaware they were made for the occasion.
That is what makes the database tests real rather than mocked: a local HTTP server serves a
genuine .tar.gz, and the tests check the download, the extraction, the atomic replacement,
the refresh of a two-day-old file, an interrupted download leaving a corrupt file, and an
unreachable mirror — which must degrade, not crash.
The rest covers address parsing (reserved ranges, IPv6 normalisation, IPv4-mapped
addresses), the HTTP surface (routes, status codes, cache headers, CORS, TRUST_PROXY),
signing (expiry, moved timestamps, a signature stolen from another path), and the Lambda
entry point on the three AWS event shapes.
The image itself is tested too:
docker build -t ip-info:test .
test/docker-smoke.sh ip-info:test
It checks that the container starts, runs as a non-root user, reads its databases from the
volume, answers correctly in IPv4 and IPv6, and honours X-Forwarded-For. It runs on every
push through GitHub Actions, on Node 20, 22 and 24 — and on the three published
architectures, the two ARM ones under QEMU.
src/
index.js entry point: configuration, listening socket, clean shutdown
lambda.js AWS Lambda entry point: event → HTTP request, response → JSON
config.js environment → configuration, and the checks done at startup
server.js routing, CORS, cache headers, error handling
databases.js download, extraction, atomic replacement, background refresh
lookup.js building the response from the MaxMind records
ip.js parsing, normalisation and classification of addresses
signature.js optional HMAC signing of URLs
ratelimit.js fixed-window rate limiter
logger.js levelled logs
config.js is the only module that reads process.env: one place to look to know what is
adjustable, and tests can build a configuration without touching the environment.
The Lambda entry point does not reimplement anything: it starts the very Express application
of server.js once per container, on a loopback socket, and relays each invocation to it.
One codebase, two entry points.
https://hub.docker.com/r/smeagolworms4/ip-info
Published for linux/amd64, linux/arm64 and linux/arm/v7 from a single multi-arch
manifest — the same tag works on a PC, a NAS and a Raspberry Pi.
| Tag | Built on |
|---|---|
latest | every push to main, and every git tag — the one to use |
main | every push to the main branch |
<version> (e.g. 1.0.0) | creation of a git tag of that name, to pin a version |
→ How the image and the Lambda archive are built and published
Every address answers null everywhere — no database is loaded. GET /health says which
one and why; the logs say it at startup. Nine times out of ten, MAXMIND_LICENSE_KEY is
missing or expired.
The service returns its own address to everyone — it sits behind a reverse proxy and
TRUST_PROXY is not set, so it sees the proxy's address. Set TRUST_PROXY=true, and make
sure the proxy actually sends X-Forwarded-For. Left unset by default on purpose: with it
on, anyone can choose the address they are given by sending the header themselves.
/ answers 127.0.0.1 or ::1 locally — that is correct, it is your address as the
service sees it. Use FAKE_IP to develop against a public one.
Every restart downloads the databases again — DB_DIR is not on a volume. In the image
it is /data; mount it.
403 Signature invalide on everything — print the string being signed before hashing it.
Nine times out of ten it carries a leading slash, BASE_PATH, or the s parameter.
403 URL expirée — the URL is more than SIGNATURE_TTL old, or the two machines'
clocks disagree by more than SIGNATURE_SKEW.
The container will not start: /data n'est pas accessible en écriture — the container
runs as UID 1000. Set PUID/PGID to match your own, or chown the folder.
The service reads a file and answers. It has no notion of authentication and does not try to: anything private belongs behind the reverse proxy that fronts it, where authentication is that layer's job.
SIGNATURE_KEY binds each URL to the path it asks for and to a moment in time, which is
what separates it from a permanent signature: a leaked URL stops working within the hour.
It is not access control — a valid URL works for whoever holds it.
Nothing about your visitors leaves the machine. That is the point of a local database
rather than a third-party API: the address of the person reading your pages is not sent
anywhere, and there is no provider building a profile out of your traffic. REVERSE_DNS is
the one exception, and it is off by default: it sends the address to a DNS resolver.
Geolocation is an estimate, and GeoLite2 is the free tier of one. A city is often the ISP's,
not the visitor's; a VPN moves the answer to another country entirely. accuracy_radius is
there to keep that in view. Deciding anything that matters — a payment, a right, an
identity — on this basis is a mistake, whatever the database.
Content type
Image
Digest
sha256:36ff0155c…
Size
56.4 MB
Last updated
about 2 months ago
docker pull smeagolworms4/ip-info