Deploying Immich as Your Private Photo Cloud: Complete Docker Setup Guide

Deploying Immich as Your Private Photo Cloud: Complete Docker Setup Guide

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

I got tired of paying Google Photos and Apple iCloud for cloud photo storage. For around $40 per year on a VPS like RackNerd, you can run Immich—a full-featured, Google Photos alternative that keeps all your memories under your control. I've been running it in production for eight months now, and it handles my entire photo library with zero hiccups.

Immich isn't just a photo vault either. It's got machine learning-powered object recognition, smart albums, timeline views, and mobile apps that sync just as smoothly as the commercial clouds. The Docker Compose setup is straightforward if you know the gotchas.

In this tutorial, I'll walk you through deploying Immich on a VPS or homelab with PostgreSQL, Redis, and Caddy reverse proxy—everything you need to access your photos securely from anywhere.

What Makes Immich Worth Self-Hosting

Immich launched in 2022 and has matured rapidly. Unlike some self-hosted photo projects that feel clunky, Immich has a polished web UI and native iOS/Android apps that actually work. The key feature that sold me: machine learning recognition runs locally. Your photos aren't shipped to a cloud classifier; instead, Immich uses CLIP embeddings to search by content without ever leaving your server.

The mobile apps support background sync, so photos upload automatically when you're on Wi-Fi. You get library sharing, smart albums (Immich learned my "food" photos automatically), and a timeline that rivals Google Photos. Plus, you own the hardware—no surprise subscription increases or terms-of-service changes.

On a $40/year VPS (Hetzner or RackNerd both work well), I'm spending less per month than a single cloud subscription.

Prerequisites and Infrastructure Decisions

You'll need a Linux VPS or homelab server with:

I prefer Caddy for reverse proxy duties because it handles Let's Encrypt automatically and requires minimal configuration. Nginx or Traefik work too, but Caddy saved me hours of cert management.

RackNerd and Hetzner both offer affordable VPS in the $40–80/year range. For Immich workloads, I recommend their basic shared CPU plans—no need for expensive RAM because PostgreSQL and Redis stay lean.

Docker Compose Setup: The Complete Stack

I keep my Immich deployment organized in a dedicated directory. Here's my production docker-compose.yml:

version: '3.8'

services:
  immich-server:
    image: ghcr.io/immich-app/immich-server:release
    container_name: immich-server
    depends_on:
      - immich-postgres
      - immich-redis
    environment:
      DB_HOSTNAME: immich-postgres
      DB_USERNAME: immich
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: immich
      REDIS_HOSTNAME: immich-redis
      JWT_SECRET: ${JWT_SECRET}
      IMMICH_LOG_LEVEL: log
      IMMICH_MACHINE_LEARNING_ENABLED: "true"
    volumes:
      - /mnt/photos/immich:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3001:3001"
    restart: unless-stopped
    networks:
      - immich-net

  immich-microservices:
    image: ghcr.io/immich-app/immich-server:release
    container_name: immich-microservices
    command: start.sh microservices
    depends_on:
      - immich-postgres
      - immich-redis
    environment:
      DB_HOSTNAME: immich-postgres
      DB_USERNAME: immich
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      DB_NAME: immich
      REDIS_HOSTNAME: immich-redis
      JWT_SECRET: ${JWT_SECRET}
      IMMICH_MACHINE_LEARNING_ENABLED: "true"
    volumes:
      - /mnt/photos/immich:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    restart: unless-stopped
    networks:
      - immich-net

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:release
    container_name: immich-ml
    volumes:
      - immich-ml-cache:/cache
    environment:
      TRANSFORMERS_CACHE: /cache
      IMMICH_LOG_LEVEL: log
    restart: unless-stopped
    networks:
      - immich-net

  immich-postgres:
    image: postgres:16-alpine
    container_name: immich-postgres
    environment:
      POSTGRES_USER: immich
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: immich
    volumes:
      - immich-db:/var/lib/postgresql/data
    restart: unless-stopped
    networks:
      - immich-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U immich"]
      interval: 10s
      timeout: 5s
      retries: 5

  immich-redis:
    image: redis:7-alpine
    container_name: immich-redis
    restart: unless-stopped
    networks:
      - immich-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  immich-db:
  immich-ml-cache:

networks:
  immich-net:
    driver: bridge

Save this as docker-compose.yml in your Immich directory. Notice I've split the server and microservices into separate containers—this lets the microservices (background jobs, ML inference) run independently and restart cleanly without disrupting the API.

Environment Variables and Secrets

Create a .env file in the same directory:

POSTGRES_PASSWORD=your_secure_postgres_password_here
JWT_SECRET=$(openssl rand -base64 32)

Generate the JWT_SECRET with that openssl command. Store these in your .env and never commit it to git.

Tip: Use pwgen -s 32 1 (from the pwgen package) to generate strong passwords quickly. It's faster than openssl and produces readable output.

Before deploying, ensure your upload directory exists and has appropriate permissions:

mkdir -p /mnt/photos/immich
chown 1000:1000 /mnt/photos/immich
chmod 755 /mnt/photos/immich

I use /mnt/photos because it's on a separate mount point from the OS disk—better for performance and backup isolation. Adjust the path to your storage setup.

Launching Immich

Navigate to your Immich directory and spin up the stack:

docker-compose up -d
docker-compose logs -f immich-server

Wait 30–60 seconds for PostgreSQL to initialize and Immich to start. The logs will show something like:

immich-server_1  | [Immich] Server is running at http://0.0.0.0:3001
immich-ml_1      | Preloading models...

On first boot, the ML container downloads CLIP models (~500MB). Be patient—this is one-time.

Check container health:

docker-compose ps

All containers should show "running" with no error restarts.

Reverse Proxy with Caddy

I never expose port 3001 directly. Instead, Caddy handles SSL termination and subdomain routing. Here's my Caddyfile:

photos.example.com {
    encode gzip
    reverse_proxy 127.0.0.1:3001 {
        header_uri -Authorization
        header_up X-Forwarded-For {http.request.remote.host}
        header_up X-Forwarded-Proto {http.request.proto}
    }
}

Replace example.com with your domain. Caddy automatically obtains and renews Let's Encrypt certificates. Run it in Docker too:

docker run -d \
  --name caddy \
  --restart unless-stopped \
  -p 80:80 \
  -p 443:443 \
  -v /etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \
  -v caddy-data:/data \
  -v caddy-config:/config \
  caddy:latest

Or add Caddy to your docker-compose.yml for single-file management. DNS for photos.example.com must point to your VPS IP before Caddy can provision the cert.

Watch out: If port 80 is blocked on your VPS (some providers block it), Let's Encrypt's HTTP challenge won't work. Use DNS validation instead, or ask your provider to unblock HTTP. RackNerd and Hetzner don't block port 80 by default.

Initial Configuration and Mobile Apps

Open https://photos.example.com in your browser. Create an admin account. This becomes your primary library owner. The UI is clean—dashboard shows upload stats, recent photos, and smart search.

Download the mobile apps from the App Store (iOS) or Google Play. Log in with your admin credentials. In settings, enable background sync and set the sync frequency. The app uploads photos automatically on Wi-Fi—perfect for hands-off backup.

On first sync, photos get ingested into the database. The ML container classifies them in the background. Expect 24–48 hours for CLIP embeddings to generate if you have a large library (10k+ photos).

Storage and Backup Strategy

By default, photos live in /mnt/photos/immich on your VPS. If your VPS has limited storage, consider:

For backups, I use rsync to sync the photo directory to a homelab NAS nightly:

0 2 * * * rsync -av --delete /mnt/photos/immich/ [email protected]:/backup/immich/

Also back up the PostgreSQL database. Add this to your crontab:

0 1 * * * docker exec immich-postgres pg_dump -U immich immich | gzip > /backup/immich-db-$(date +\%Y\%m\%d).sql.gz

Performance Tuning and Scaling

On modest hardware (2GB RAM), Immich runs but gets slow with large libraries. A few tweaks:

For VPS hosting, RackNerd's annual plans ($40–80/year) are tight but workable. If you hit resource limits, upgrade to a higher tier or migrate to your homelab (Immich runs identically on bare metal or a Proxmox VM).

Common Issues and Troubleshooting

Photos won't upload: Check that /mnt/photos/immich is writable. Run