Skip to content
Go back

Photo Libraries Without Google Lookups

By SumGuy 10 min read
Photo Libraries Without Google Lookups

The Privacy Leak You Set Up on Purpose

You spent a weekend getting Immich running. Composing the stack, migrating 40,000 photos off Google Photos, breathing the sweet air of self-hosted freedom. Your photos live on your NAS. Your data stays home.

Then you notice that every photo with GPS data shows a little place name — “Yosemite Valley,” “Uncle Dave’s Backyard Cookout,” “That Parking Garage in Denver.” Where did those come from?

They came from a reverse geocoding lookup. Depending on which app you’re running and which version, those lookups may be going to Google Maps, Mapbox, or a bundled local dataset. Some versions of PhotoPrism default to an external API. Older Immich releases did too. Every time a photo gets indexed, its EXIF coordinates either stay local or get shipped off to a third-party server to be turned into a human-readable place name. If it’s the latter: Google now knows you were at that protest in 2019. Mapbox knows your home coordinates. The company that provided your “private” photo library introduced a data broker into the pipeline.

That’s the gap. You moved the photos. You didn’t check where the lookups were going.

Full example: Compose snippets and a backfill script at github.com/KingPin/sumguy-examples/tree/main/self-hosting/reverse-geocoding-photo-libraries

Why They Default to Google

It’s not malice. It’s convenience for the developer.

Google Maps Geocoding is free-ish up to 40,000 lookups a month. Mapbox has a similar free tier. Neither requires you to run any infrastructure. You register, get an API key, paste it in the config, and ship. For a casual home library with a few hundred photos trickling in per month, you’ll never hit the limits. For the app developer, it’s a solved problem.

The results also look excellent. Google’s data is dense, constantly updated, and handles weird edge cases well — remote rural addresses, recent construction, venues with multiple names. Nominatim on OSM is very good and keeps getting better, but Google still has the edge on coverage in underserved areas.

None of that matters if your threat model is “I don’t want my photo metadata leaving my network.” At that point, the quality gap is irrelevant. You just need something that runs locally and knows where your country roads are.

The Lookup Pattern: What Actually Happens

Reverse geocoding is straightforward. You have a lat/lon pair — say 37.7419, -119.5332. You send that to an API endpoint. The endpoint returns a structured address: Yosemite Valley, Mariposa County, California, United States. Your app stores the human-readable string.

The Nominatim reverse endpoint looks like this:

Terminal window
curl "http://your-nominatim:8080/reverse?lat=37.7419&lon=-119.5332&format=json&zoom=10"

The zoom parameter controls granularity — 18 is building-level, 10 is city/town, 3 is country. For photo libraries, zoom 10–12 is the sweet spot. You want “Yosemite Valley” not “Curry Village Road, Lot 4, Parking Space 17.”

Both Immich and PhotoPrism debounce and cache these lookups internally. They don’t fire one lookup per photo on import — they batch, deduplicate coordinates, and store results so identical or nearby coordinates don’t trigger multiple resolutions. That’s important whether you’re hitting a local Nominatim (PhotoPrism’s path) or a bundled local dataset (Immich’s path). Either way, 20,000 photos from the same trip don’t need 20,000 individual lookups.

If you haven’t set up Nominatim yet, the Nominatim self-hosted geocoding server post covers the full install. Come back here when it’s running.

Immich: Already Local (Mostly)

Immich’s geocoding is handled by its immich-server service. Current releases ship with a bundled offline dataset (Natural Earth data) for reverse geocoding — it runs entirely locally and doesn’t phone home by default. No env var swap required for the privacy goal: if you’re running a recent Immich release, the geocoding is already local.

What you can tune is the precision of those lookups, and you can trigger a re-geocode job via the API:

Terminal window
# Trigger reverse geocoding on a specific asset
curl -X POST "http://immich:2283/api/jobs" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "reverseGeocoding"}'

Check the Immich admin panel under Administration → Jobs to monitor geocoding job status and trigger bulk re-geocoding on your existing library.

To verify no external geocoding calls are leaking out, watch the server logs during an import:

Terminal window
docker compose logs -f immich-server | grep -i "geocod\|reverse\|maps.google"

You should see no outbound calls to maps.googleapis.com or api.mapbox.com. If you do, you’re on an older release — upgrade first.

Note: Immich’s geocoding configuration has changed significantly across versions. Always check the Immich environment variables docs for your specific release before adding env vars found in random blog posts (including this one).

PhotoPrism’s Geocoder Config

PhotoPrism handles this through a single environment variable in its service definition. It replaces the default Maps API endpoint with your own:

docker-compose.yml
services:
photoprism:
image: photoprism/photoprism:latest
environment:
PHOTOPRISM_GEOCODING_API: nominatim
PHOTOPRISM_GEOCODING_SERVER: "http://nominatim:8080"
# Optional: set preferred language for place name results
PHOTOPRISM_GEOCODING_LANGUAGE: en
networks:
- photoprism-net
- geocoding-net

PhotoPrism’s geocoder implementation has been Nominatim-aware for a while — the nominatim API type is a first-class option, not a workaround. Once you set it, PhotoPrism will use your server for all new imports and when you trigger a re-index.

To trigger geocoding on existing photos without a full re-import:

Terminal window
docker compose exec photoprism photoprism places update

This walks every photo with GPS coordinates and re-resolves the place names. On a 50,000-photo library it’ll run for a while. Let it finish before doing anything else in PhotoPrism — it’s reasonably IO-heavy on the database side.

The Mass Backfill Problem

Here’s the thing: you probably already have thousands of photos in your library with place names from a previous setup — an old Mapbox API key, a prior PhotoPrism config, or just stale data that never got resolved. The changes above handle new imports, but existing place data sits in the database until you explicitly re-geocode it.

PhotoPrism’s places update command above handles this natively. For Immich, you can trigger a bulk re-geocode using the jobs API — useful if you’ve upgraded Immich versions or want to force a refresh across your library.

Here’s a bash script that walks a list of asset IDs and fires a geocoding refresh for each one, batching requests to avoid overwhelming Immich:

backfill-geocoding.sh
#!/usr/bin/env bash
# Backfill reverse geocoding for all assets in Immich
# Requires: curl, jq
# Usage: IMMICH_HOST=http://immich:2283 IMMICH_API_KEY=yourkey ./backfill-geocoding.sh
set -euo pipefail
IMMICH_HOST="${IMMICH_HOST:-http://localhost:2283}"
API_KEY="${IMMICH_API_KEY:?Set IMMICH_API_KEY}"
BATCH_SIZE=50
DELAY_SECONDS=2
echo "Fetching all asset IDs..."
ASSET_IDS=$(curl -sf \
-H "x-api-key: $API_KEY" \
"$IMMICH_HOST/api/assets?take=10000&skip=0" | jq -r '.[].id')
TOTAL=$(echo "$ASSET_IDS" | wc -l)
echo "Found $TOTAL assets. Processing in batches of $BATCH_SIZE..."
BATCH=()
COUNT=0
while IFS= read -r asset_id; do
BATCH+=("\"$asset_id\"")
COUNT=$((COUNT + 1))
if [ "${#BATCH[@]}" -eq "$BATCH_SIZE" ]; then
IDS=$(IFS=,; echo "${BATCH[*]}")
curl -sf -X POST "$IMMICH_HOST/api/asset/jobs" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"assetIds\": [$IDS], \"name\": \"reverseGeocoding\"}" > /dev/null
echo "Queued batch ($COUNT / $TOTAL)..."
BATCH=()
sleep "$DELAY_SECONDS"
fi
done <<< "$ASSET_IDS"
# Flush remaining
if [ "${#BATCH[@]}" -gt 0 ]; then
IDS=$(IFS=,; echo "${BATCH[*]}")
curl -sf -X POST "$IMMICH_HOST/api/asset/jobs" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"assetIds\": [$IDS], \"name\": \"reverseGeocoding\"}" > /dev/null
echo "Queued final batch."
fi
echo "Done. Monitor job progress in the Immich admin panel."

The DELAY_SECONDS=2 between batches keeps Immich’s job queue from getting buried. Batches of 50 every 2 seconds gives the worker time to process without piling up a backlog that clogs other Immich operations.

For a library of 50,000 photos, budget 30–60 minutes for the full backfill. Most of that time is Immich’s job queue working through the list, not the geocoding lookups themselves.

Performance Expectations

These numbers apply to PhotoPrism + Nominatim — PhotoPrism’s geocoder hits your Nominatim endpoint directly, so Nominatim’s throughput is the real ceiling. Immich’s built-in geocoder uses its bundled dataset and doesn’t touch Nominatim.

A Nominatim instance on a modest home server — say a mini PC with 16 GB RAM and an NVMe — running a single-country or single-continent import can typically handle:

For photo library use this is massive overkill. Even during aggressive backfills you’re unlikely to push past 5–10 requests/second. The real constraint is your PostgreSQL memory config, not the hardware.

Two things that matter for performance:

Cache aggressively. Both Immich and PhotoPrism cache place results internally, so duplicate coordinates (every photo from the same vacation) only get resolved once. You don’t need a separate Redis layer for this use case.

Use zoom=10 or lower for place names. Higher zoom values force Nominatim to traverse more of the spatial index. For photos you want neighborhood or city level, not street-level accuracy — so zoom=10 to zoom=12 keeps queries fast and the result useful.

The Apple Photos and Google Photos Comparison

Here’s the actual trade-off if you’re coming from a mainstream photo service:

Google Photos reverse geocodes your GPS to a place name, stores it, and also keeps that coordinate and the lookup in your Google account, connected to your identity, forever. The feature is nice. The data residency is not.

Apple Photos uses Apple’s own Maps API for geocoding. The privacy story is somewhat better — Apple’s policy on location lookups is relatively clean and they process it on-device for some features. But it’s still Apple’s server, it still happens automatically, and you’re still trusting the policy.

Self-hosted Immich or PhotoPrism means the coordinate never leaves your network. For PhotoPrism, place names get resolved against your Nominatim instance using OSM data. For Immich, the bundled Natural Earth dataset handles it entirely on-device. Either way, no third party sees the query. The feature works identically from the user’s perspective: your photos have nice place labels in the timeline and on the map view.

You do lose some coverage quality in rural or international locations where OSM data is thin. That’s a real trade-off. Whether it matters depends entirely on where you take photos.

Honestly, for most home labbers: Immich already handles this locally out of the box — nothing to change. For PhotoPrism, two environment variables and a shared Docker network pointing at your Nominatim instance is an afternoon of work, tops. You already have the Nominatim stack up (or you should — see the setup post if you don’t).

Your photos stay home. The lookups stay home. That’s the point.

Wrapping Up

The gap between “self-hosting your photos” and “actually keeping your photo data private” has always been the geocoding step. It’s easy to miss because both Immich and PhotoPrism do it silently in the background, and the default providers are convenient enough that you never think to question them.

Fixing it is simpler than it looks. Immich already runs geocoding locally — just verify you’re on a current release. PhotoPrism needs two environment variables and a Nominatim instance, then a places update run for existing photos. Either way, a few hours of work puts the whole pipeline on your hardware.

Your 2 AM self will appreciate not wondering who else saw where those photos were taken.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Boundary vs Teleport

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts