Self-Hosted Photo Server
Personal photo management with ML face recognition — photo.tyfsadik.org
Overview
This project is a self-hosted Immich instance deployed at photo.tyfsadik.org that provides Google Photos-equivalent functionality with full data ownership. Immich handles automatic mobile photo backup, timeline browsing, album organization, and search. The ML microservices container runs face recognition, object detection, and CLIP-based semantic search, enabling queries like "beach sunset" without any tagging.
The stack runs on Docker Compose with PostgreSQL as the metadata database (with the pgvecto.rs extension for vector similarity search used by the ML features), Redis for job queuing, and a dedicated microservices container that runs the Python-based ML workloads separately from the main server process. Files are stored on a local volume backed by the Proxmox ZFS dataset. The mobile app enables background backup over Wi-Fi, keeping the photo library current without any manual steps.
Architecture
Tech Stack
- Immich — self-hosted Google Photos alternative with mobile backup
- PostgreSQL + pgvecto.rs — metadata store with vector similarity search for ML
- Redis — job queue for background ML processing tasks
- Immich microservices — Python ML container for face recognition and object detection
- Docker & Docker Compose — service orchestration
- Nginx — SSL reverse proxy for photo.tyfsadik.org
- Let's Encrypt — TLS certificate management
Build Process
Download Immich docker-compose.yml and .env
Immich provides an official Docker Compose file that defines all required services.
The .env file contains the upload location path, database credentials,
and version pin. These are the only two files needed to get a working stack.
mkdir immich && cd immich
# Download official compose file and env template
wget -O docker-compose.yml \
https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget -O .env \
https://github.com/immich-app/immich/releases/latest/download/example.env
# Review what services are defined
cat docker-compose.yml | grep "^ [a-z]" | grep ":"
Configure UPLOAD_LOCATION and Credentials in .env
The .env file is edited to set the upload path to the ZFS-backed volume
mount point, and strong random passwords are generated for the database. The
IMMICH_VERSION is pinned to a specific release for reproducibility.
# .env configuration
UPLOAD_LOCATION=/mnt/photos/immich
DB_DATA_LOCATION=/mnt/photos/postgres
# Generate secure password
DB_PASSWORD=$(openssl rand -hex 32)
echo "DB_PASSWORD=$DB_PASSWORD" >> .env
DB_USERNAME=immich
DB_DATABASE_NAME=immich
# Pin to a specific release (check releases page for latest)
IMMICH_VERSION=v1.106.4
# Redis (default, no password needed for local-only)
REDIS_HOSTNAME=immich_redis
Start the Stack
The full stack is brought up with Docker Compose. First startup takes several minutes as Docker pulls the images (server, microservices, PostgreSQL, Redis). The ML models are downloaded on first use when the microservices container starts processing a photo.
docker compose up -d
# Watch startup logs
docker compose logs -f
# Verify all containers are healthy
docker compose ps
# Check the server is responding
curl http://localhost:3001/api/server-info/ping
# Expected: {"res":"pong"}
Nginx Reverse Proxy Configuration
Nginx is configured to proxy photo.tyfsadik.org to the Immich server container.
The client_max_body_size is set generously to support large video file
uploads. WebSocket connections are proxied for real-time upload progress updates.
# /etc/nginx/sites-available/photo.tyfsadik.org
server {
listen 80;
server_name photo.tyfsadik.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name photo.tyfsadik.org;
ssl_certificate /etc/letsencrypt/live/photo.tyfsadik.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photo.tyfsadik.org/privkey.pem;
client_max_body_size 50G;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
ln -s /etc/nginx/sites-available/photo.tyfsadik.org /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Provision SSL Certificate
Certbot provisions the Let's Encrypt certificate and modifies the Nginx config to redirect HTTP to HTTPS. A systemd timer handles automatic renewal 30 days before expiry.
apt install certbot python3-certbot-nginx -y
certbot --nginx -d photo.tyfsadik.org \
--agree-tos --email [email protected]
# Verify auto-renewal is configured
systemctl status certbot.timer
# Test renewal without actually renewing
certbot renew --dry-run
Install Mobile App and Enable Auto-Backup
The Immich mobile app is installed from the App Store or Play Store. The server URL
is set to https://photo.tyfsadik.org. Auto-backup is configured to run
over Wi-Fi only, with background sync enabled. For existing photo libraries, a bulk
import job is run via the Immich web interface to scan the upload directory.
# For bulk importing an existing photo directory
# Upload files to the UPLOAD_LOCATION directly, then trigger scan:
docker exec -it immich_server \
node /usr/src/app/dist/cli/index.js upload --yes \
/usr/src/app/upload/library
# Or trigger library scan via the web UI:
# Administration > Jobs > Library > Scan All
# Monitor ML processing jobs
docker compose logs -f immich_microservices | grep "Running job"
Request Flow
Challenges & Solutions
-
ML microservices consuming too much RAM: The face recognition and CLIP
models together consumed over 4 GB of RAM on a server with 8 GB total, leaving insufficient
memory for PostgreSQL and the main server. Resolved by adding Docker resource constraints
(
mem_limit: 2g) in the compose file and configuring the ML service to unload models from memory after a period of inactivity using theMODEL_TTLenvironment variable. - Large initial library import taking days: Importing 30,000 existing photos via the mobile app one by one was impractical. Used the Immich CLI bulk import utility to copy files directly to the upload location and trigger a library scan, processing the entire backlog as a batch job overnight.
-
PostgreSQL pgvecto.rs extension not found: The official Immich PostgreSQL
image includes pgvecto.rs, but a custom image was initially used that lacked it. Switched
back to the official
ghcr.io/immich-app/postgresimage which bundles all required extensions. - Video thumbnails not generating: The microservices container failed to generate video thumbnails because FFmpeg was not installed in a custom image variant. The official Immich server image includes FFmpeg, resolving the issue after switching back to the canonical image tag.
What I Learned
- Docker Compose multi-service architectures with health checks and dependency ordering
- PostgreSQL vector extensions (pgvecto.rs) and their use in ML-powered search
- Redis job queue patterns for decoupling ML processing from the request path
- Docker resource constraints (mem_limit, cpus) for shared low-resource servers
- ML model lifecycle management: lazy loading, TTL-based unloading, warm vs cold inference
- Mobile app background sync behavior and Wi-Fi-only backup configuration