Dragan Ljubojevic

Leaving Google Photos: Self-Hosted Immich on a Home NAS, Public Through a VPS Tunnel

My wife and I had two Google Photos accounts, a NAS full of older photos synced from our phones with Syncthing, and a growing Google One bill. This post is about getting everything into one self-hosted Immich instance: the setup, the import, and the many small traps along the way.

The short version: the hard part is not installing Immich, it is importing years of overlapping photos without creating duplicates.

The Setup

phone (Immich app) ──HTTPS──> VPS: Traefik ──> socat relay
                                                  │
                                       WireGuard 10.13.13.0/24
                                                  │
                         home NAS: WireGuard client + socat relay ──> Immich :2283
                                                  │
                                 /data/PHOTO/SORTED/YYYY/MM  (read-only external library)
                                 /data/PHOTO/IMMICH          (uploads from phones)

Why a tunnel instead of port forwarding

The NAS dials out to the VPS and keeps the tunnel open (PersistentKeepalive). Nothing at home listens on the internet, the router has no forwarded ports, and it works behind CGNAT.

Both ends use linuxserver/wireguard, and each published service is just a tiny socat relay that shares the WireGuard container's network namespace. On the VPS:

wireguard:
  image: lscr.io/linuxserver/wireguard
  environment:
    - PEERS=nas
    - SERVERURL=vps.example.com
    - ALLOWEDIPS=10.13.13.0/24     # only the tunnel subnet, never 0.0.0.0/0
    - PERSISTENTKEEPALIVE_PEERS=all
  sysctls:
    - net.ipv4.ip_forward=0          # relays end each connection; no routing into either network
  ports:
    - '51820:51820/udp'
  labels:
    - "traefik.http.routers.photo_https.rule=Host(`photos.example.com`)"
    - "traefik.http.services.photo.loadbalancer.server.port=2283"

photo:
  image: alpine/socat
  network_mode: service:wireguard
  command: ["TCP-LISTEN:2283,fork,reuseaddr", "TCP:10.13.13.2:2283"]

At home, the mirror image: a WireGuard client with the peer config generated on the VPS, and a relay from the tunnel to the port Immich publishes on the host:

photo:
  image: alpine/socat
  network_mode: service:wireguard
  command: ["TCP-LISTEN:2283,fork,reuseaddr", "TCP:host.docker.internal:2283"]

The relays only move bytes; Immich does its own authentication. On the VPS, Traefik adds rate limits: 10 requests per minute per IP on /api/auth/login (Immich has no login lockout of its own), and a loose per-IP limit on the whole site.

Two lessons from running this:

Accounts: Decide Before You Import

Immich's duplicate detection works per user. The same photo in two accounts is stored twice and nobody warns you. This drives everything else:

Two things I learned the hard way:

  1. An external library's owner cannot be changed later. I had attached the library to my first (admin) account. Instead of recreating the library, which would have meant new asset IDs, lost albums, lost face names and days of reprocessing, I simply renamed that account to "Shared" and created a new personal admin account.
  2. Logging the phone app into a different account re-uploads the whole camera roll. After switching my phone to the personal account, I removed the old copies from the Shared account by comparing Immich's SHA-1 checksums between the two accounts (more on that below).

Getting the Data Out: Google Takeout

Takeout gives you a pile of independent zip files (not a split archive), 2 to 10 GB each:

for f in ~/Downloads/takeout*.zip; do
  echo "$f"
  unzip -o -q "$f" -d . || echo "FAILED: $f" >> unpack_failures.log
done

Check the numbering first. One of our exports was missing two parts out of 96 and nobody noticed for a while.

What is inside:

Folder names follow the account language. My wife's account is in Serbian, so it was Google слике/Слике из 2019./ with Отпад instead of Trash and -измењено instead of -edited. Any script that hard-codes the English names silently does the wrong thing.

Why I Did Not Just Run immich-go

immich-go is the standard tool for importing Takeout into Immich, and it is good. I ran it with --dry-run on one small album first, and that saved me:

Always dry-run on a small sample that you know overlaps with what is already on the server. A sample of only new photos (my first test) looks perfect and proves nothing.

Matching Locally: Metadata First, Then Image Content

So I wrote a small matcher that runs locally. No photos leave the network. The slow part is reading metadata over NFS (about an hour for 52,000 files on the NAS, ten minutes for a local Takeout); the matching itself takes minutes.

  1. Scan both sides once into JSON lines: EXIF DateTimeOriginal, camera model, dimensions, size; for videos ffprobe creation time and duration; for Takeout files the time from Google's JSON sidecar.
  2. Same capture time (to the second) + same file name (case-insensitive, ignoring -edited and (1) suffixes): already there.
  3. Same capture time but different name, or no EXIF at all: compare the actual pixels with a perceptual hash.
  4. Videos: recording time ± 2 s and duration ± 1 s.
  5. Everything else is new.

A perceptual hash is a 64-bit fingerprint of what an image looks like. It survives recompression and resizing, which is exactly what Google's Storage saver does:

from PIL import Image, ImageOps
import imagehash, pillow_heif

pillow_heif.register_heif_opener()

def phash(path):
    with Image.open(path) as im:
        im.draft("RGB", (256, 256))          # JPEG: decode at 1/8 size, much faster over NFS
        im = ImageOps.exif_transpose(im).convert("RGB")
        return imagehash.phash(im)

same = phash(google_copy) - phash(nas_original) <= 10   # Hamming distance of 64 bits

Only the uncertain cases get hashed, and only against candidates with a nearby capture time. Of about 5,900 content matches in my wife's library, 5,641 had distance 0, and only 16 were near the threshold.

Sanity checks that caught real problems:

The Results

My wife's TakeoutMy Takeout
Unique assets (album copies collapsed, trash excluded)25,81345,061
Already on the NAS~14,900~42,400
Really new~10,500~2,500

Before copying, I also filtered out things that do not belong in a family archive: FB_IMG_* (images saved from Facebook), screenshots, WhatsApp/Viber/Messenger images, and Google's auto-generated animations and collages. I missed some of these in the first pass and had to delete 228 files afterwards, so put the filter in from the start.

Copying Into the Archive, and an NFS Surprise

New files go into SORTED/YYYY/MM/ like the rest of the archive, named after the original, with the file time set to the capture time (Immich falls back to it for files without EXIF).

Then the timeline showed hundreds of photos dated today. Every copied file had the copy time, even though the script had called os.utime() and a check right afterwards showed the correct date. The NFS client had cached the attributes; when the dirty data was flushed to the server later, the server stamped its own modification time over mine.

The fix: flush before setting the time, re-apply at the end, and verify through a second mount of the same export so the client cache cannot lie to you:

shutil.copyfile(src, tmp)
fd = os.open(tmp, os.O_RDONLY); os.fsync(fd); os.close(fd)   # push the data to the server first
os.utime(tmp, (capture_time, capture_time))
os.rename(tmp, dst)
# ... after all files: wait a minute, re-check via the other mount point, fix what is still wrong

Even with the fsync, a later batch still had 332 of 351 copies overwritten, so the final verify-and-reapply pass is not optional. Moves within the same export (rename) are not affected: they never touch the file's data.

Recreating Albums

Copying files into an external library loses Google's albums. I recreated the meaningful ones (people, trips, events) through the Immich API and skipped the "memories" noise. For each album, every member is either a newly copied file or the NAS original it was matched to, so I can look up its Immich asset ID by path:

api("POST", "/albums", {"albumName": name, "assetIds": ids})
api("PUT", "/assets", {"ids": favourite_ids, "isFavorite": True})   # Google's favourites

Personal Photos Out of the Shared Library

My Takeout had albums of scanned documents, contracts and electronics parts. They had also reached the NAS through Syncthing years ago, so the whole family could see them. I:

  1. uploaded them to my personal account through the API (POST /api/assets), using the NAS originals where they existed, because they are better than Google's copies,
  2. checked that every one of them arrived (status created, then SHA-1 against Immich's stored checksums),
  3. only then moved them out of SORTED into a holding folder, and deleted that folder after another verification.

Switching the Phone to the Personal Account

Immich stores a SHA-1 checksum of every asset. The phone uploads byte-identical files, so deduplicating across accounts is exact, not a guess:

shared   = [a for a in all_assets(SHARED_KEY) if not a.get("libraryId")]   # uploads only, not the NAS library
personal = {a["checksum"] for a in all_assets(PERSONAL_KEY)}
dups = [a["id"] for a in shared if a["checksum"] in personal]
api(SHARED_KEY, "DELETE", "/assets", {"ids": dups, "force": False})        # to trash, restorable

1,406 of 1,453 old uploads matched and went to the trash. The other 47 stayed.

Cleaning Up Old Folders on the NAS

The NAS also had a separate VIDEO/ tree and an UPLOAD/ folder with Syncthing targets from several old phones. The same matcher, pointed at those folders, found 1,822 files missing from the archive and about 3,000 duplicates (including 1,118 copies of each other). Two warnings:

Deleting From Google Photos

When you select photos on photos.google.com and press delete, Google asks:

Remove from your Google Account, devices with backup turned on, and places shared within Google Photos?

That middle part is the trap: every phone with Google Photos backup turned on deletes those photos from its gallery too. Turn off backup in the Google Photos app (or uninstall it) on every device first.

There is no "delete everything" button. Select the first photo, scroll far down, Shift+click the last one, delete, repeat. Then empty the trash, or Google keeps everything for 60 more days. Also check Archive and Locked Folder, which are not in the main view.

Keep the unpacked Takeout (or the zips) on a backup disk. Everything you filtered out (screenshots, edits, chat images) exists only there.

Lessons Learned

The result: one family archive on the NAS, a personal library per person, phones backing up to Immich, everything reachable from anywhere with no open port at home, and no more Google One subscription.