You Have 80,000 Photos and No Exit Plan
Google Photos killed the free tier, Dropbox costs more per year than a Raspberry Pi, and iCloud is just Apple holding your memories hostage. You’ve been putting off the self-hosted photo solution for two years because you kept hoping this would resolve itself. It won’t.
The good news: the self-hosted photo management space has genuinely matured. The bad news: three of the best options — Immich, PhotoPrism, and Ente — make wildly different bets about what “solved” looks like, and picking the wrong one will cost you a weekend of regret.
Let’s cut through the marketing copy.
The Contenders
Immich — the new hotness. Active development, polished mobile apps, face recognition that actually works, timeline view that feels like Google Photos. Also: breaking changes every few releases and a Docker stack that requires four containers minimum.
PhotoPrism — the quiet professional. Battle-tested, more archival in feel, solid for curating a well-organized collection. Less aggressive ML, calmer release cadence. Mobile backup is not its strong suit.
Ente — the privacy absolutist. End-to-end encrypted, open source, also offers a commercial SaaS tier if you want to support the devs. Self-hosting the full stack means building Flutter apps from source. It’s real work.
Immich: Google Photos Didn’t Retire, It Just Moved Servers
If you want the Google Photos experience on hardware you control, Immich is the closest thing that exists right now. The mobile apps are polished, photo backup runs in the background, and the ML pipeline does faces, objects, and CLIP-based semantic search entirely on-prem.
The trade-off is complexity. The Compose stack has four services plus an optional external ML worker:
services: immich-server: image: ghcr.io/immich-app/immich-server:v1.106.4 container_name: immich_server volumes: - /mnt/photos:/usr/src/app/upload - /etc/localtime:/etc/localtime:ro env_file: - .env ports: - "2283:2283" depends_on: - redis - database restart: always
immich-machine-learning: image: ghcr.io/immich-app/immich-machine-learning:v1.106.4 container_name: immich_machine_learning volumes: - model-cache:/cache env_file: - .env restart: always
redis: image: redis:6.2-alpine container_name: immich_redis restart: always
database: image: tensorchord/pgvecto-rs:pg14-v0.2.0 container_name: immich_postgres env_file: - .env environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} POSTGRES_DB: ${DB_DATABASE_NAME} volumes: - pgdata:/var/lib/postgresql/data restart: always
volumes: model-cache: pgdata:DB_HOSTNAME=databaseDB_USERNAME=immichDB_PASSWORD=changeme_pleaseDB_DATABASE_NAME=immichREDIS_HOSTNAME=redisUPLOAD_LOCATION=/mnt/photosA few things that will bite you if you skip the docs:
Pin the version. Immich tags latest and moves fast. Major releases routinely include breaking database migrations. Run v1.106.4 (or whatever version you’re starting with) explicitly, not latest. When you upgrade, do it one minor version at a time and back up Postgres first.
Do not point Immich at your existing photo folder and expect it to leave files alone. In default mode, Immich manages its own upload directory. There’s an “external library” mode for watching your existing NAS share, but it’s read-only — Immich won’t move or rename your originals. Use it. If you let Immich import into its managed storage, your files end up in a date-bucketed folder structure you’ll hate when you need to dig them out manually.
ML RAM costs are real. The machine-learning container pulls CLIP for semantic search and InsightFace for face recognition. On a fresh start with nothing cached, expect 2–4 GB of RAM consumed just by the ML service while it indexes. On a machine with 8 GB total, this hurts. You can tune the model cache and run ML on a schedule rather than continuously, but plan for it.
Google Photos Takeout migration: Immich has an official CLI tool for importing Takeout archives. It handles the .json sidecar files Google attaches to preserve metadata — dates, GPS, album membership. Run it with:
docker run --rm -it \ -v /path/to/takeout:/import \ -e API_KEY=your_immich_api_key \ ghcr.io/immich-app/immich-cli:latest \ upload --recursive /importIt’s not magic — some photos lose their original dates if Google’s JSON sidecars are malformed — but it’s the best Takeout importer in the space.
Storage backend: Immich speaks S3. Set IMMICH_MEDIA_LOCATION to an S3-compatible endpoint and it’ll store originals in your bucket. MinIO on the same host, Backblaze B2, Wasabi — all work. For a homelab with a big NAS, local FS is usually fine and simpler to manage.
PhotoPrism: The Archivist’s Choice
PhotoPrism has been around longer, moves more deliberately, and has a fundamentally different philosophy: it’s a viewer and organizer for photos you already have, not a primary backup target.
The Compose setup is blessedly simpler:
services: photoprism: image: photoprism/photoprism:231128-ce container_name: photoprism restart: unless-stopped security_opt: - seccomp:unconfined - apparmor:unconfined ports: - "2342:2342" environment: PHOTOPRISM_AUTH_MODE: "password" PHOTOPRISM_SITE_URL: "http://photoprism.local:2342/" PHOTOPRISM_ORIGINALS_LIMIT: 5000 PHOTOPRISM_HTTP_COMPRESSION: "gzip" PHOTOPRISM_LOG_LEVEL: "info" PHOTOPRISM_READONLY: "false" PHOTOPRISM_EXPERIMENTAL: "false" PHOTOPRISM_DISABLE_CHOWN: "false" PHOTOPRISM_DISABLE_FACES: "false" PHOTOPRISM_DISABLE_CLASSIFICATION: "false" PHOTOPRISM_DISABLE_VECTORS: "false" PHOTOPRISM_DISABLE_RAW: "false" PHOTOPRISM_ADMIN_USER: "admin" PHOTOPRISM_ADMIN_PASSWORD: "changeme_please" PHOTOPRISM_DATABASE_DRIVER: "mysql" PHOTOPRISM_DATABASE_SERVER: "mariadb:3306" PHOTOPRISM_DATABASE_NAME: "photoprism" PHOTOPRISM_DATABASE_USER: "photoprism" PHOTOPRISM_DATABASE_PASSWORD: "changeme_please" volumes: - /mnt/photos/originals:/photoprism/originals - /mnt/photos/storage:/photoprism/storage depends_on: - mariadb
mariadb: image: mariadb:11 container_name: photoprism_db restart: unless-stopped security_opt: - seccomp:unconfined - apparmor:unconfined environment: MARIADB_AUTO_UPGRADE: "1" MARIADB_INITDB_SKIP_TZINFO: "1" MARIADB_DATABASE: "photoprism" MARIADB_USER: "photoprism" MARIADB_PASSWORD: "changeme_please" MARIADB_ROOT_PASSWORD: "changeme_please" volumes: - dbdata:/var/lib/mysql
volumes: dbdata:PhotoPrism indexes your existing directory structure and leaves files where they are. That’s the whole point. If you’ve spent years organizing /photos/2023/vacation/ the way you like it, PhotoPrism respects that. It won’t restructure anything.
What it does well: RAW format support is excellent. The indexing pipeline handles EXIF, XMP sidecars, videos, and Live Photos. The “moments” view groups by location and time in a way that feels curated rather than algorithmic.
What it doesn’t do well: Mobile backup. There’s no first-party PhotoPrism app that does background upload the way Google Photos does. You’re expected to sync your phone to a folder (via Syncthing, rclone, or your NAS’s mobile app) and let PhotoPrism index from there. For a lot of people, this is fine — it’s the same workflow they’d use for any other server. For others, it’s the dealbreaker.
Initial indexing: On a large library (50k+ photos), expect the first index to run for hours. The face recognition pass is separate and even slower. Let it run overnight and go touch grass.
S3 backend: PhotoPrism supports S3-compatible storage via the PHOTOPRISM_STORAGE_PATH setting. Originals can live on S3 if you configure it at startup — harder to change after the fact.
Ente: When the Government Is Your Threat Model
Ente is built on a different premise than the other two: your photos are encrypted on your device before upload, and the server never sees the plaintext. Zero-knowledge. This means even if someone compromises your self-hosted Ente server, they get encrypted blobs — not your photos.
The trade-off: the self-hosting story is genuinely rough.
The server side is straightforward enough:
services: museum: image: ghcr.io/ente-io/server:latest container_name: ente_server restart: unless-stopped ports: - "8080:8080" environment: ENTE_DB_HOST: postgres ENTE_DB_PORT: 5432 ENTE_DB_NAME: ente_db ENTE_DB_USER: pguser ENTE_DB_PASSWORD: pgpassword ENTE_S3_ARE_LOCAL_BUCKETS: "true" ENTE_S3_B2_EU_ENDPOINT: "http://minio:9000" ENTE_S3_B2_EU_KEY: minioadmin ENTE_S3_B2_EU_SECRET: minioadmin ENTE_S3_B2_EU_BUCKET: ente-objects ENTE_S3_B2_EU_REGION: eu-central-2 depends_on: - postgres - minio
postgres: image: postgres:15 container_name: ente_postgres restart: unless-stopped environment: POSTGRES_USER: pguser POSTGRES_PASSWORD: pgpassword POSTGRES_DB: ente_db volumes: - pgdata:/var/lib/postgresql/data
minio: image: minio/minio container_name: ente_minio restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin volumes: - minio-data:/data ports: - "9000:9000" - "9001:9001"
volumes: pgdata: minio-data:Here’s where it gets real: the mobile apps you download from the App Store / Play Store are hardcoded to hit Ente’s commercial servers. To use them against your self-hosted instance, you have to build the Flutter apps yourself from the open-source repo, pointing them at your server endpoint. That’s not impossible — Flutter toolchain, dart pub get, flutter build — but it’s not a Sunday afternoon project if you haven’t done Flutter before.
Ente does have a web client (photos.ente.io) that can be pointed at a custom server endpoint in settings. For desktop use, that’s workable. For mobile, you’re either building the app yourself or waiting for Ente to ship a configurable-server build to the app stores.
What Ente excels at: If you’re self-hosting for privacy reasons — not just cost — this is your answer. E2E encryption is baked into the architecture, not bolted on. The albums, sharing, and family plan features work exactly as you’d expect once you’re up.
What it doesn’t do: On-prem ML. Because the server never sees decrypted photos, face recognition and semantic search run on-device. The app is smarter than you’d expect, but it’s not doing the heavy server-side CLIP indexing that Immich does.
Backup Discipline: The Part Everyone Skips
All three tools can eat your photos if you’re careless. Ground rules regardless of which you pick:
Back up the database separately from photos. If you restore from a backup that’s six weeks old but the photos folder is current, you have orphaned files and missing index entries. Align your backup schedule.
For Immich (Postgres + pgvector):
docker exec immich_postgres \ pg_dumpall -U immich | gzip > /backup/immich_$(date +%Y%m%d).sql.gzFor PhotoPrism (MariaDB):
docker exec photoprism_db \ mariadb-dump -u photoprism -pchangeme_please photoprism | \ gzip > /backup/photoprism_$(date +%Y%m%d).sql.gzTest your restores. Seriously. Every quarter, spin up a fresh container with the backup dump and verify you can browse photos. “I have backups” and “my backups work” are different facts.
For Immich: back up before every upgrade. No exceptions. The DB migration is automatic and non-reversible. If something goes sideways mid-migration, you need that pre-upgrade dump.
The Verdict on Mobile Backup
| Feature | Immich | PhotoPrism | Ente |
|---|---|---|---|
| Native iOS app | Yes | No | Yes (self-built or SaaS) |
| Native Android app | Yes | No | Yes (self-built or SaaS) |
| Background auto-upload | Yes | Manual/third-party | Yes |
| Works offline | Partial | Partial | Yes (E2E) |
If phone backup is your primary use case, this table basically makes the decision for you. Immich and Ente both have real mobile apps. PhotoPrism expects you to handle the sync yourself.
Migration from Google Photos Takeout
All three can ingest Takeout archives. The process:
- Go to takeout.google.com, select only Google Photos, export all albums
- Download the
.zipchunks (can be 10–50 GB) - Extract everything into a staging folder
Immich: Use the immich-cli Docker image. It reads the JSON sidecars and preserves dates and album structure.
PhotoPrism: Point PHOTOPRISM_ORIGINALS_PATH at your extracted folder and run photoprism index. It reads EXIF but struggles with Google’s JSON sidecars. You may need exiftool to bake dates back into the files first:
# Bake Google Takeout JSON metadata back into EXIF before indexingexiftool -r -d "%Y:%m:%d %H:%M:%S" \ "-DateTimeOriginal<${SubSecDateTimeOriginal}" \ "-DateTimeOriginal<DateTimeOriginal" \ "-DateTimeOriginal<CreateDate" \ -overwrite_original \ /path/to/takeout/Ente: Import via the desktop app or CLI. The encryption means the upload is slower — every file is encrypted client-side before transit.
The Bottom Line
If you want the Google Photos replacement with real mobile backup, active development, and ML that actually finds your dog’s face: Immich. Pin your versions, respect the external library mode, back up Postgres before upgrades. This is the one most people should run.
If you have a large existing photo archive you’ve spent years organizing and you want a viewer that respects your folder structure without trying to take over: PhotoPrism. Set expectations correctly about mobile backup — you’ll need a separate sync solution for your phone.
If end-to-end encryption is non-negotiable — you’re protecting source material, sensitive personal photos, or you just don’t trust any server including your own: Ente. Budget time to build the Flutter apps or use the web UI. The privacy model is genuinely solid.
The real talk: most homelabbers are going to pick Immich and be happy with it. The active development and polished apps make it feel like a product, not a project. Just don’t run latest in production, back up your database, and resist the urge to let it manage your originals folder unless you’re okay with its naming scheme.
Your photos deserve better than a shared folder called MISC BACKUP 2019. Pick one and run it.