Skip to content
Go back

Photo Libraries Without Google Lookups

By SumGuy 11 min read
Photo Libraries Without Google Lookups
Contents

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 gives you 10,000 free calls per SKU a month, as of September 2026. Google replaced the old $200 monthly credit with these per-SKU free calls in March 2025, so older “40,000 free lookups” numbers floating around are stale. Mapbox’s free tier is more generous: up to 100,000 geocoding requests a month before billing kicks in. 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 either limit. 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 to 12 works best. 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 PhotoPrism’s hosted places.photoprism.app endpoint or a bundled local dataset (Immich’s path). Either way, 20,000 photos from the same trip don’t need 20,000 individual lookups.

Neither Immich nor PhotoPrism actually requires you to run Nominatim yourself, but if you want a fully local reverse-geocoding pipeline for other tools (Home Assistant, a custom script), the Nominatim self-hosted geocoding server post covers the full install.

Immich: Already Local (Mostly)

Immich’s geocoding is handled by its immich-server service. Current releases ship with a bundled offline dataset (GeoNames 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.

Reverse geocoding isn’t a standalone job in Immich; it happens as part of metadata extraction. So to re-resolve place names you re-run the metadata-extraction queue via the API:

Terminal window
# Re-run the metadata extraction queue (which includes reverse geocoding)
curl -X PUT "http://immich:2283/api/jobs/metadataExtraction" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "start", "force": true}'

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 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 does not point at a server you control. Its reverse geocoding always goes through PhotoPrism’s own hosted service at places.photoprism.app, and there’s no documented environment variable that swaps in your own Nominatim instance. The two settings that do exist:

docker-compose.yml
services:
photoprism:
image: photoprism/photoprism:latest
environment:
PHOTOPRISM_DISABLE_PLACES: "false" # default; set true to turn off reverse geocoding entirely
PHOTOPRISM_PLACES_LOCALE: "en" # place name language, default "local"

The privacy story here is real, just not the one you’d assume. Your GPS coordinates leave your network, but PhotoPrism’s hosted service fuzzes them into S2 cell IDs before doing the lookup and states it keeps no permanent logs of the queries. That’s a different guarantee than “the coordinate never left the building.” If your threat model needs the coordinate to stay fully local, lean on Immich instead, which does that by default, or build your own reverse-geocoding step against a self-hosted Nominatim and skip PhotoPrism’s built-in geocoder.

To re-resolve place names on existing photos:

Terminal window
docker compose exec photoprism photoprism index -f

That triggers a complete rescan, including already-indexed files, which re-runs geocoding along with everything else. 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

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 index -f command above handles this: a complete rescan re-touches already-indexed files, which includes re-resolving place names. 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
# (re-runs metadata extraction, which includes reverse geocoding)
# 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/assets/jobs" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"assetIds\": [$IDS], \"name\": \"refresh-metadata\"}" > /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/assets/jobs" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"assetIds\": [$IDS], \"name\": \"refresh-metadata\"}" > /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 to 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 if you’re calling Nominatim directly yourself, say from a custom script or another service on the same box. They don’t apply to PhotoPrism, which never touches your local Nominatim; it calls its own hosted places.photoprism.app service. Immich’s built-in geocoder uses its bundled dataset and doesn’t touch Nominatim either.

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 to 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 keeps the coordinate on your network: the bundled GeoNames dataset handles reverse geocoding entirely on-device, and no third party sees the query. Self-hosted PhotoPrism is a different deal: it sends fuzzed coordinates to PhotoPrism’s own hosted places.photoprism.app service, so the query does leave your network, just without your exact GPS point and without an account tied to it. If “the coordinate never leaves the building” is your bar, Immich clears it and PhotoPrism doesn’t. Either way, 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, there’s no local swap to make, just a decision: is PhotoPrism’s fuzzed-coordinate, no-permanent-logs privacy model good enough for your threat model, or do you want the coordinate to never leave the LAN, in which case Immich is the one that actually does that. If you’re running Nominatim anyway for Home Assistant or a custom script, the setup post covers that install.

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’s two geocoding env vars won’t get you a fully local pipeline, since there’s no self-hosted swap, but a photoprism index -f run re-resolves place names for existing photos once you’ve decided PhotoPrism’s hosted, privacy-conscious service is an acceptable trade-off. Either way, a few hours of work puts most of the 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
Photon Deep Dive: Search That Forgives

Discussion

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

Related Posts