[Immich](https://immich.app) is a self-hosted photo and video backup service that does for Google Photos what [Vaultwarden](https://www.deployhq.com/blog/self-host-vaultwarden-vps-docker-deployhq) does for Bitwarden — feature parity (mobile auto-upload, face recognition, smart search, shared albums, geolocation timeline) on infrastructure you control. It is built around PostgreSQL, Redis, and a CLIP-based machine-learning service, all packaged as Docker Compose.

This guide walks through deploying Immich on a VPS with Docker — from spinning up the host, through the four-container compose stack, to mobile app setup, hardware acceleration, and the storage and backup decisions that the official quickstart skips. By the end you have an Immich instance at `photos.yourdomain.com` that backs up your phone's camera roll automatically and survives the next time your Mac dies.

## Why self-host Immich

Google Photos has been the default for a decade because it is free, fast, and works. The reasons people are leaving:

- **Free is over.** The 15 GB shared with Gmail and Drive fills up. The next storage tier is $30/year for 200 GB; $100/year for 2 TB.
- **The library outlives the platform.** Photos from 10+ years of phones live on whatever cloud subscription you currently pay. Cancel and you scramble to export everything.
- **AI training and surveillance concerns.** Your photos are a corpus that gets used. Self-hosted means the corpus stays on your hardware.
- **Family / shared albums on your terms.** Immich shared libraries work without anyone needing a Google account.

Immich is the closest open-source alternative to feature parity. Mobile apps for iOS and Android upload automatically, face recognition runs locally on the ML container, and CLIP-based smart search (photos of red cars at the beach) works against a vector index of your library. PhotoPrism is the other contender; we picked Immich because the mobile experience and active development cadence are both ahead.

## Why Docker

Immich's official install path is Docker Compose. The project ships:

- The main Postgres-backed `immich-server` container.
- A `machine-learning` container for face recognition and CLIP smart search.
- A Redis cache.
- A PostgreSQL container with the `pgvector` extension pre-installed.

There is no install via apt path. The four containers are tuned together — Postgres needs `pgvector`, the ML container ships pre-downloaded models, Redis is wired into the server's job queue. Trying to run any of these natively means rebuilding most of the dependency tree by hand.

If you've followed our [self-host Paperclip with Docker](https://www.deployhq.com/blog/self-host-paperclip-vps-docker-deployhq) or [self-host Nextcloud on a VPS](https://www.deployhq.com/blog/self-host-nextcloud-like-a-pro-deployhq-contabo-vps-tutorial) walkthroughs, the playbook here is similar but heavier — Immich is a real four-container stack, not a single-binary app.

## Prerequisites

Per [Immich's official requirements](https://docs.immich.app/install/requirements):

- A VPS with at least **2 vCPU and 6 GB RAM** (4 vCPU / 8 GB recommended). You can run with 4 GB if you disable the ML container, but you lose face recognition and smart search.
- **Storage matching your library plus 10–20% overhead** for thumbnails and transcoded videos. Plan a year ahead — uploads compound.
- **PostgreSQL data on local SSD** , never on a network share. Immich documents this explicitly: NFS-backed Postgres has caused data corruption in the past.
- A domain or subdomain (`photos.yourdomain.com`) with DNS pointing to the VPS.
- Docker Engine v25+ with the Compose plugin.
- Access to a [reverse proxy](https://www.deployhq.com/blog/what-is-a-reverse-proxy-nginx-apache-and-caddy-explained) for TLS — the mobile app will not connect to a non-HTTPS server.

For libraries over ~500 GB, expect to size up. A 1 TB library on a Hetzner CCX13 (2 vCPU / 8 GB / 80 GB) works for the database and code; the photos themselves live on a separate volume mount or block storage.

## Storage planning before you install

This is the decision that bites people six months in. You have three storage shapes to think about:

1. **The Postgres database.** 1–3 GB for most libraries. Always on local SSD, in a Docker volume.
2. **The library** — original photos and videos. Grows linearly with your archive. Mount this from a dedicated volume or block storage so you can resize without touching the OS disk.
3. **Thumbnails and transcoded video.** 10–20% of library size, regenerable but expensive in CPU time. Lives in the same upload location by default.

A reasonable layout on a Hetzner setup:

```
/opt/immich/ # 50 GB OS disk
  docker-compose.yml
  .env
/mnt/library/ # 1 TB block storage (resizable)
  ├── upload/ # UPLOAD_LOCATION
  ├── library/
  └── thumbs/
```

Block storage is the safe default. Adding a second 1 TB volume later is one command. Re-mounting the OS disk is a weekend.

_Getting storage right up front saves a painful migration later — and the same is true for upgrades. If you'd rather every Immich version bump run through a repeatable [build pipeline](https://www.deployhq.com/features/build-pipelines) instead of a manual SSH session, it's worth wiring the deploy in from the start (full walkthrough at the end)._

## The compose stack

Immich publishes the canonical `docker-compose.yml` in their releases. Pull the official version rather than copy-pasting from a third-party tutorial — it changes between versions.

```
mkdir -p /opt/immich && cd /opt/immich
curl -L https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml \
  -o docker-compose.yml
curl -L https://github.com/immich-app/immich/releases/latest/download/example.env \
  -o .env
```

Edit `.env` — the four variables that matter:

```
UPLOAD_LOCATION=/mnt/library
DB_DATA_LOCATION=/opt/immich/postgres
DB_PASSWORD=<openssl rand -hex 32>
IMMICH_VERSION=v1.140.0 # pin to a specific tag, never :latest for prod
TZ=Europe/Berlin # set to your timezone or face-recognition timestamps drift
```

Two production hardenings to apply on top of the official compose:

**Bind the public port to localhost.** The default `2283:2283` exposes Immich directly to all interfaces. Edit the `immich-server` service:

```
services:
  immich-server:
    ports:
      - "127.0.0.1:2283:2283" # Caddy proxies the public side
```

**Pin the database** to a specific minor version. The official `.env` ships a digest-pinned image; keep the digest. Floating Postgres versions on a database holding your only family-photo archive is exactly the wrong place to discover the upgrade story.

Bring the stack up:

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

Wait for the server to log Immich Server is listening on port 2283. The first start runs migrations and downloads the ML models (~2 GB), so initial boot can take 5–10 minutes.

## TLS via Caddy

`/etc/caddy/Caddyfile`:

```
photos.yourdomain.com {
    reverse_proxy 127.0.0.1:2283
    encode gzip

    # Immich uploads can be large — let the mobile app push 4K video
    request_body {
        max_size 50000MB
    }

    log {
        output file /var/log/caddy/immich.log
    }
}
```

`sudo systemctl reload caddy`. Caddy gets a Let's Encrypt cert on first request.

The `request_body max_size` line matters more than it looks. Without it, large iPhone video uploads fail at the proxy layer with a confusing 413 error. The default Caddy limit (and Nginx's, and Apache's) is too low for modern phone video.

## First-run setup

Visit `https://photos.yourdomain.com`. Immich shows a Getting Started page on first boot:

1. **Create the admin user** — this is the only account that can manage other users.
2. **Set the storage template** — how Immich names files in the library. The default `{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}` is sensible. You can change it later, but renaming a 200 GB library at 3 a.m. when you discover the wrong template is no fun.
3. **Set the timezone** to match your `.env`.

On your phone, install the [Immich app](https://immich.app/docs/overview/quick-start), point it at `https://photos.yourdomain.com`, log in, and enable backup for your camera roll. The first sync will be slow — depending on library size and upload bandwidth, plan for several hours to several days for the initial backup. Subsequent syncs are incremental and quick.

## Hardware acceleration

The default Immich install does video transcoding and ML inference on CPU. For most libraries this is fine — transcoding catches up overnight. If you have a GPU on the host, you can enable hardware acceleration for big speed gains.

| Hardware | Acceleration option | Setup |
| --- | --- | --- |
| Intel CPU with iGPU (Skylake+) | QuickSync | Add `hwaccel.transcoding.yml` from the immich repo, `device: /dev/dri/renderD128` |
| NVIDIA GPU | NVENC + CUDA | NVIDIA Container Toolkit + `hwaccel.transcoding.yml` with `runtime: nvidia` |
| ARM64 (Pi 5, Ampere) | Mali / V4L2 | Add `hwaccel.transcoding.yml` with the `quicksync` profile (works for many ARM iGPUs) |
| AMD GPU | VAAPI | Add the `vaapi` profile and pass `/dev/dri` |

Most public-cloud VPS instances do not give you GPU access at the price points where self-hosting Immich makes sense, so this section is mostly for users running on a dedicated server, a homelab, or a Hetzner CCX-line instance with an iGPU. CPU-only is genuinely fine for households up to ~5 users.

## Migrating from Google Photos

If you're switching, you have an existing library to import. The clean path:

1. Request a [Google Takeout](https://takeout.google.com) of your Google Photos. This gives you a (potentially massive) zip download with original photos and JSON metadata sidecars.
2. Use [`immich-go`](https://github.com/simulot/immich-go) — a community CLI that imports Takeout archives directly, parsing the JSON metadata to preserve dates, geolocation, and album structure. The Immich web UI's Import library feature does not handle Takeout's quirks (split archives, sidecar matching) nearly as well.
3. Run the import on the VPS or push from your laptop:`bash
immich-go upload --server https://photos.yourdomain.com \
 --key <your api key> from-google-takeout --google-takeout takeout-*.zip
`
4. The first ML pass (face recognition, CLIP indexing) will pin one CPU core for hours after a large import. This is normal. Let it finish.

Plan a weekend for a 100 GB library; longer for 1 TB+. Resumable, but uninterrupted is faster.

## Backups

The Postgres database is the metadata index — losing it doesn't lose your photos (those live in the library volume) but does lose face recognition, albums, sharing, and timeline. Backup strategy:

- **Database** : nightly `pg_dump` to off-host storage. Tiny (~1–3 GB).
- **Library** : snapshot the block storage volume. On Hetzner, DigitalOcean, and Vultr, this is one click or a single API call. Daily snapshots, keep last 7 + last 4 weekly.
- **Off-host copies** : Critical. A snapshot on the same provider goes away when the provider does. Sync the library to S3 / B2 / a second VPS via `restic` or `rclone` weekly. The bandwidth cost is real, but recovery from a complete provider outage justifies it.

```
#!/bin/bash
# /usr/local/bin/immich-backup.sh
set -euo pipefail
BACKUP_DIR=/var/backups/immich
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
docker exec immich_postgres pg_dumpall -U postgres | \
  gzip > "$BACKUP_DIR/db-$TIMESTAMP.sql.gz"
find "$BACKUP_DIR" -type f -mtime +30 -delete
```

For the library volume, use `restic` to ship to off-host storage:

```
restic backup /mnt/library --tag immich --exclude '*.tmp' \
  --repo b2:my-bucket:immich
```

Test a database restore once a quarter. Test a library restore once. Knowing the recovery time matters more than knowing the backup ran.

## Operational concerns

- **Watch the ML container's RAM**. CLIP indexing is memory-hungry. The default 2 GB Docker limit can OOM during initial library indexing of a large import. Bump to 4 GB if you see kills in `dmesg`.
- **Set Postgres `shared_buffers`** appropriately for your VPS. The default ships at ~128 MB; on a 6 GB VPS you can comfortably bump to 1.5 GB and it makes face-search noticeably faster.
- **Log rotation** : `docker logs immich-server` grows. Configure log rotation in `/etc/docker/daemon.json` with `max-size: 50m, max-file: 5` or pipe to a host syslog.
- **Don't use `:latest`**. We pinned `IMMICH_VERSION` for a reason. Floating tags on a four-container stack with database migrations is asking for the same `pg_dump` error you spent the weekend recovering from.

## Continuous deployment with DeployHQ

Immich ships releases roughly monthly, with database migrations on most of them. The log in via SSH and run docker compose pull loop gets old after the third upgrade.

A more controllable pattern with [DeployHQ](https://www.deployhq.com):

1. Keep your `docker-compose.yml`, `.env.example`, and any compose overrides (`hwaccel.transcoding.yml`, custom `Caddyfile`) in a private deploy repo.
2. Connect the repo to [DeployHQ](https://www.deployhq.com), add the VPS as an SSH server.
3. The deploy uploads the compose files and runs:`bash
docker compose -f docker-compose.yml --env-file .env pull
docker compose -f docker-compose.yml --env-file .env up -d
`
4. To upgrade Immich: bump `IMMICH_VERSION` in `.env` (managed via [DeployHQ config files](https://www.deployhq.com/support/build-pipelines)), commit, push. The pipeline does the rest.
5. To roll back: revert the commit, push.

This turns the upgrade flow into a git operation with an audit log, instead of an SSH session you forget the details of.

## Wrapping up

You now have Immich running on a VPS with TLS, mobile auto-backup, face recognition, smart search, and a backup strategy that survives a disk failure. Once the initial library upload finishes, the experience is genuinely close to Google Photos — and the storage cost is whatever your VPS provider charges for a block volume, instead of $30+/year per family member.

If you're running Immich plus a few other self-hosted services on the same VPS — Vaultwarden, [a self-hosted GitLab](https://www.deployhq.com/blog/how-to-deploy-gitlab-on-a-vps-a-step-by-step-guide), Paperclip, a [self-hosted Ghost blog](https://www.deployhq.com/blog/deploying-ghost-cms-with-deployhq-automated-setup-guide) — [start a free](https://www.deployhq.com/signup)[DeployHQ](https://www.deployhq.com) trial and turn upgrades into a `git push`. The [agency plan](https://www.deployhq.com/for-agencies) covers managing the same stack across multiple VPSes; pricing is on the [plans page](https://www.deployhq.com/pricing).

Questions about Immich storage planning, ML container tuning, or Takeout migration? Email us at [support@deployhq.com](mailto:support@deployhq.com) or ping [@deployhq](https://x.com/deployhq) on X.

