# Can I self-host Google One?

**YES, BUT** — it's called Immich. ONE WEEKEND setup · ~5 hours to running · 6 GB RAM minimum · $9.99/mo you stop paying ($119.88/yr on the Google AI Plus (2 TB) plan).

Immich authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/google-one/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Immich 3.1.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and it has to be a hostname of its own: upstream
states Immich cannot be served from a sub-path.

Upstream publishes a floor of 6 GB of RAM and recommends 8 GB, and this install runs the
machine-learning container, so 6 GB is a floor rather than a suggestion. Budget 20 GB of disk
before a single photo: about 5 GB of images, a model cache that fills as searches run, and a
database upstream puts at 1 to 3 GB. Immich runs on amd64 and arm64; since v3 the amd64
machine-learning image needs x86-64-v2, which server CPUs have had since about 2012. Step 4's
health gate needs Docker Engine 25 or newer.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
docker version --format '{{.Server.Version}}'
dig +short <DOMAIN>
```

If available RAM is under 6144 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope, and do not fall back to the machine-learning-disabled variant to fit a
4 GB box: that is a different install. If the Docker version is below 25, or `dig +short` prints
nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/immich /srv/immich/data /srv/immich/backups
sudo install -d -m 700 /srv/immich/postgres
ls -la /srv/immich
```

Assert: `data` and `backups` are owned by the login user, and `postgres` is mode `drwx------`
owned by root. Leave that one alone: the PostgreSQL image chowns its own data directory on first
start and refuses one already chowned to somebody else. `/srv/immich/data` is the photo library.

## 3. Secrets

One secret, the PostgreSQL password. Generate it on the server, do not print it, do not repeat
it in your summary, and keep it out of every log line. Hex rather than base64, because upstream
restricts this password to A-Za-z0-9.

```bash
umask 077
cat > /srv/immich/.env <<EOF
TZ=Etc/UTC
DB_USERNAME=immich
DB_DATABASE_NAME=immich
IMMICH_ALLOW_SETUP=true
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/immich/.env
umask 022
ls -l /srv/immich/.env
```

Assert: the file exists with mode `-rw-------`. `TZ` is the fallback zone for a photo carrying
none of its own, and the user can change it later. `IMMICH_ALLOW_SETUP` is true only until step
7 closes it.

## 4. compose.yml

```bash
cat > /srv/immich/compose.yml <<'EOF'
# Immich · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose install . https://docs.immich.app/install/docker-compose
#   variable reference ..... https://docs.immich.app/install/environment-variables
#   backup and restore ..... https://docs.immich.app/administration/backup-and-restore
#
# Four services, because that is what Immich is: the server, a machine-learning
# worker doing search and faces on the CPU, a Valkey job queue, and PostgreSQL.
# The database is upstream's own build, not stock postgres, because Immich
# keeps one vector per photo in the VectorChord extension. Every tag and digest
# is upstream's pin for v3.1.0, re-read from the registries on 2026-08-05; the
# valkey `9` tag has moved since. All four images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: immich

services:
  database:
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
    container_name: immich_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: "--data-checksums"
    volumes:
      - /srv/immich/postgres:/var/lib/postgresql/data
    shm_size: 128mb
    # The image ships its own tuned postgresql.conf and health script, so
    # nothing here overrides either. No `ports:`: 5432 stays container-only.

  redis:
    image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
    container_name: immich_redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping || exit 1"]
      interval: 10s
      retries: 12
    # No `ports:` either. The queue is spoken between containers only.

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v3.1.0@sha256:5a0839dc5303cd7215bcd2180a26aed3af41675aefb3e75e5157e9f10ad16e6e
    container_name: immich_machine_learning
    restart: unless-stopped
    volumes:
      - model-cache:/cache
    # No env_file: it reads no DB_ or REDIS_ variable, so the password needs no
    # third copy. The service name is load-bearing: the server looks for the
    # models at immich-machine-learning:3003.

  immich-server:
    image: ghcr.io/immich-app/immich-server:v3.1.0@sha256:b434cb9287eea1471c9974845914d4dd328c9c2d652e446ed4930f99944f0ceb
    container_name: immich_server
    restart: unless-stopped
    env_file: /srv/immich/.env
    environment:
      DB_HOSTNAME: database
      REDIS_HOSTNAME: redis
    volumes:
      # Originals, thumbnails, transcodes and the nightly dumps all land here.
      - /srv/immich/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8098.
      - "127.0.0.1:8098:2283"
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  model-cache:
EOF
cd /srv/immich && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Do not swap a stock `postgres` image into the database line:
VectorChord is why upstream builds its own, and a plain postgres fails on the first migration.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-immich
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Immich · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.immich.app/administration/reverse-proxy,
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/encode
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Upstream states Immich cannot be served from a sub-path.

<DOMAIN> {
	# Caddy's encode touches only the text-like content types in its default
	# matcher, so the web app is compressed and photos go through untouched.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8098 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall. Caddy sets three of the four
	# headers upstream asks for, so only X-Real-IP is written. It also applies no
	# request-body limit and no proxy read timeout, which is what an nginx install
	# has to fix before the first 4 GB video upload.
	reverse_proxy 127.0.0.1:8098 {
		header_up X-Real-IP {remote_host}
	}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-immich, reload, and report what it objected to. Caddy issues the
certificate on the first request to that hostname and renews it unattended.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8098 stays closed because it is bound to 127.0.0.1; 5432 and 6379 have no host port to
firewall at all. Assert: `ufw status verbose` prints `Status: active`, shows those three, and no
rule for 8098, 5432 or 6379.

## 7. Start and verify

The pull is roughly 5 GB and the first database start builds its extensions, so this is the slow
step. The loop allows twelve minutes.

```bash
cd /srv/immich
docker compose pull
docker compose up -d
for i in $(seq 1 72); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/server/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/server/ping
curl -sS https://<DOMAIN>/api/server/version
curl -sS https://<DOMAIN>/api/server/config
```

Assert all four and print what you received for each. The loop ends on `200`. The ping response
is exactly `{"res":"pong"}`, the string the container's own health script checks for. The
version response contains `"major":3` and `"minor":1`, confirming the digest you pinned is the
version this prompt claims. The config response contains `"isInitialized":false`: no account
exists yet. If any of the four misses, stop, run
`docker compose logs --tail 40 immich-server` and `docker compose logs --tail 20 database`, and
name the likely earlier step. A database that never reports healthy points at step 2, and a
`502` where JSON was expected means the server is still starting. A running container is not
success.

The first screen at https://<DOMAIN> shows the heading `Welcome to Immich` and a
`Getting Started` button.

STOP: tell the user to open https://<DOMAIN>, click `Getting Started`, and fill in the
`Admin Registration` form, and wait. Do not continue until they confirm. Whoever loads that page
first becomes the administrator here, so it should be them and it should be now.

Once they confirm, close setup permanently:

```bash
cd /srv/immich
sed -i 's/^IMMICH_ALLOW_SETUP=true$/IMMICH_ALLOW_SETUP=false/' /srv/immich/.env
docker compose up -d --force-recreate immich-server
sleep 30
curl -sS https://<DOMAIN>/api/server/config
grep '^IMMICH_ALLOW_SETUP=' /srv/immich/.env
```

Assert: the config response now contains `"isInitialized":true`, and the grep prints
`IMMICH_ALLOW_SETUP=false`. Both must pass before you report success. Every later account is
made by the administrator, so there is no other registration door to close.

## 8. First backup and restore

Two artifacts, not interchangeable. Upstream is explicit that a database backup holds no photos
and no video; the photos are files under /srv/immich/data.

```bash
cd /srv/immich
docker compose exec -T database pg_dump --clean --if-exists --dbname=immich --username=immich | gzip > /srv/immich/backups/immich-db-$(date +%F).sql.gz
sudo tar -czf /srv/immich/backups/immich-config-$(date +%F).tar.gz -C /srv/immich compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/immich/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Immich also dumps itself into
/srv/immich/data/backups nightly at 2am. Two copies on one disk is one disk, so run both of
these from the user's machine, not the server:

```bash
mkdir -p ~/backups/immich
scp vps:/srv/immich/backups/* ~/backups/immich/
rsync -a --exclude 'thumbs/' --exclude 'encoded-video/' vps:/srv/immich/data/ ~/backups/immich/data/
```

Thumbnails and transcodes are excluded because Immich regenerates them; everything else under
data is irreplaceable.

To restore: `docker compose down`, `sudo rm -rf /srv/immich/postgres`, recreate it as in step 2,
put compose.yml and .env back from the config archive, `docker compose up -d database`, wait a
minute for healthy, then load the dump with the search_path rewrite upstream documents:

```bash
gunzip --stdout /srv/immich/backups/immich-db-$(date +%F).sql.gz | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | docker compose exec -T database psql --dbname=immich --username=immich --single-transaction --set ON_ERROR_STOP=on
```

Then rsync the data directory back and run `docker compose up -d`. Tell the user the stakes:
the dump knows where every photo is, the data directory is every photo, and either one alone
rebuilds nothing.

## 9. Updating later

Releases are at https://github.com/immich-app/immich/releases, and the ones that break something
carry a changelog:breaking-change label, filtered at
https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created.
Read the release notes before pulling, every time. Upstream does not backport patches and states
that downgrading, even within the same minor version, is not supported, so an upgrade you cannot
reverse is the normal case. Take both backups, then edit the four image lines in
/srv/immich/compose.yml to their new tags and digests:

```bash
cd /srv/immich
docker compose pull
docker compose up -d
docker compose logs --tail 30 immich-server
```

Immich migrates its own database on the way up, so watch that log until it settles, then re-run
step 7's health check. Across a major version, update mobile clients before the server, as
upstream asks.

## 10. What will probably go wrong

The machine-learning container will look wedged, and it is not. The first time the user searches
for a word instead of a date, that container downloads a CLIP model into its cache volume and
then works through the library one asset at a time. On a two-core box I watched load average sit
above four for twenty minutes with nothing changing on screen, decided the install was broken,
and restarted things, which made it start over. `docker compose logs --tail 20
immich-machine-learning` shows the download and then the inference lines; leave it alone until
those stop.

## 11. Out of scope

- Do not enable hardware transcoding or machine-learning acceleration. Upstream's
  hwaccel.transcoding.yml and hwaccel.ml.yml need a matched host driver, and this install runs
  both workloads on the CPU on purpose.
- Do not configure OAuth. Immich has local accounts and the administrator creates the rest.
- Do not configure SMTP. Immich runs without it; only invitation mail needs it.
- Do not mount an external library. Pointing Immich at photos it does not own changes what a
  backup means, and that is the user's call.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Immich 3.1.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.

Read this before step 1. Immich cannot be served from a sub-path, which upstream states plainly,
so `<DOMAIN>` has to be a hostname of its own rather than a `/photos` prefix on a site you
already run. And this is a four-container install with a 6 GB memory floor, so it does not fit
on the cheapest VPS tier. Both facts are cheaper to learn now than in step 7.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
docker version --format '{{.Server.Version}}'
dig +short <DOMAIN>
```

You should see: at least `6144` MB available, at least `20` G free, `amd64` or `arm64`, a Docker
server version of `25` or higher, and your server's IP on the last line.

If you do not: the RAM line is the one that stops people. Upstream publishes a floor of 6 GB and
recommends 8 GB, and this install runs the machine-learning container, so a 4 GB box will build
thumbnails until the kernel kills something. Resize the server rather than pushing on. The 20 GB
of disk is before a single photo: about 5 GB of images, a model cache that grows as searches
run, and a database upstream puts at 1 to 3 GB. An empty last line means the A record does not
exist yet: add it, wait a minute, and run `dig +short <DOMAIN>` again, because Caddy cannot get
a certificate for a name that does not resolve and failed attempts count against a rate limit
you cannot see. A Docker version below 25 means the database's health check will not gate
start-up properly; upgrade Docker first.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/immich /srv/immich/data /srv/immich/backups
sudo install -d -m 700 /srv/immich/postgres
ls -la /srv/immich
```

You should see: `data` and `backups` owned by you, and `postgres` at mode `drwx------` owned by
root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. `/srv/immich/data` is the photo library, and it is the directory that will
grow: originals, thumbnails, transcodes and Immich's own nightly database dumps all live under
it.

## 3. Secrets

One secret, the PostgreSQL password. It is generated here, on the server, and goes straight into
a file only you can read. Hex rather than base64 because upstream restricts this password to the
characters A-Za-z0-9.

```bash
umask 077
cat > /srv/immich/.env <<EOF
TZ=Etc/UTC
DB_USERNAME=immich
DB_DATABASE_NAME=immich
IMMICH_ALLOW_SETUP=true
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/immich/.env
umask 022
ls -l /srv/immich/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/immich/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten the
password, which is fine before the database exists and a problem afterwards: PostgreSQL keeps
the password it was created with, so a changed `DB_PASSWORD` against an existing data directory
shows up as an authentication failure in the Immich log rather than as anything about passwords.

Do not paste that file, the password, or any command output containing it into this chat window.
Nothing in this install needs you to read the value at all: Immich talks to its own database and
you never type it.

`TZ` is the zone Immich falls back to for a photo that carries none of its own, and it is also
the clock the nightly dump job runs on. Change `Etc/UTC` to your own zone later if you like.
`IMMICH_ALLOW_SETUP` stays true only until step 7 closes it.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/immich/compose.yml <<'EOF'
# Immich · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose install . https://docs.immich.app/install/docker-compose
#   variable reference ..... https://docs.immich.app/install/environment-variables
#   backup and restore ..... https://docs.immich.app/administration/backup-and-restore
#
# Four services, because that is what Immich is: the server, a machine-learning
# worker doing search and faces on the CPU, a Valkey job queue, and PostgreSQL.
# The database is upstream's own build, not stock postgres, because Immich
# keeps one vector per photo in the VectorChord extension. Every tag and digest
# is upstream's pin for v3.1.0, re-read from the registries on 2026-08-05; the
# valkey `9` tag has moved since. All four images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: immich

services:
  database:
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
    container_name: immich_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: "--data-checksums"
    volumes:
      - /srv/immich/postgres:/var/lib/postgresql/data
    shm_size: 128mb
    # The image ships its own tuned postgresql.conf and health script, so
    # nothing here overrides either. No `ports:`: 5432 stays container-only.

  redis:
    image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
    container_name: immich_redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping || exit 1"]
      interval: 10s
      retries: 12
    # No `ports:` either. The queue is spoken between containers only.

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v3.1.0@sha256:5a0839dc5303cd7215bcd2180a26aed3af41675aefb3e75e5157e9f10ad16e6e
    container_name: immich_machine_learning
    restart: unless-stopped
    volumes:
      - model-cache:/cache
    # No env_file: it reads no DB_ or REDIS_ variable, so the password needs no
    # third copy. The service name is load-bearing: the server looks for the
    # models at immich-machine-learning:3003.

  immich-server:
    image: ghcr.io/immich-app/immich-server:v3.1.0@sha256:b434cb9287eea1471c9974845914d4dd328c9c2d652e446ed4930f99944f0ceb
    container_name: immich_server
    restart: unless-stopped
    env_file: /srv/immich/.env
    environment:
      DB_HOSTNAME: database
      REDIS_HOSTNAME: redis
    volumes:
      # Originals, thumbnails, transcodes and the nightly dumps all land here.
      - /srv/immich/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8098.
      - "127.0.0.1:8098:2283"
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  model-cache:
EOF
cd /srv/immich && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/immich/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal;
run `rm /srv/immich/compose.yml` and paste again in one go. Do not substitute a stock `postgres`
image for the database line, however tempting the familiar name looks. Immich stores one vector
per photo in the VectorChord extension, that extension is why upstream builds and publishes its
own PostgreSQL image, and a plain postgres passes `docker compose config` and then fails on the
first migration.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-immich
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Immich · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.immich.app/administration/reverse-proxy,
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/encode
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Upstream states Immich cannot be served from a sub-path.

<DOMAIN> {
	# Caddy's encode touches only the text-like content types in its default
	# matcher, so the web app is compressed and photos go through untouched.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8098 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall. Caddy sets three of the four
	# headers upstream asks for, so only X-Real-IP is written. It also applies no
	# request-body limit and no proxy read timeout, which is what an nginx install
	# has to fix before the first 4 GB video upload.
	reverse_proxy 127.0.0.1:8098 {
		header_up X-Real-IP {remote_host}
	}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-immich /etc/caddy/Caddyfile`, reload, and
paste again. The most common cause is a `<DOMAIN>` you forgot to replace, which Caddy reads as a
site named `<DOMAIN>` and refuses. Caddy issues the certificate on the first request to that
hostname and renews it on its own, so there is nothing to schedule and no path to hardcode.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8098`, `5432` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8098`. 8098 is bound
to 127.0.0.1 by the compose file, and the database and the queue publish no host port at all, so
there is nothing a firewall rule could even apply to. 80/tcp is there to answer the ACME
challenge and redirect to HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy
offers by default. `Status: inactive` is a different problem: Prompt Zero left this firewall
enabled, so something has turned it off since, and `sudo ufw enable` puts it back before you go
any further.

## 7. Start and verify

The pull is roughly 5 GB across four images and the first database start builds its extensions,
so this is the slow step. The loop below allows twelve minutes and prints a status code every
ten seconds, so you can watch it work.

```bash
cd /srv/immich
docker compose pull
docker compose up -d
for i in $(seq 1 72); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/server/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/server/ping
curl -sS https://<DOMAIN>/api/server/version
curl -sS https://<DOMAIN>/api/server/config
```

You should see, in order: the loop climbing through `502` and reaching `200`, then exactly
`{"res":"pong"}`, then a version object containing `"major":3` and `"minor":1`, then a longer
config object containing `"isInitialized":false`.

If you do not: `{"res":"pong"}` is the same string the server container's own health script
checks for, so getting it means the whole chain is working. `"major":3` and `"minor":1` prove
the digest you pinned really is the version this page claims. `"isInitialized":false` means no
account exists yet, which is what you want one step before you make one. If the loop never
leaves `502`, run `docker compose logs --tail 20 database` first, because a database that never
reports healthy holds everything else back, and `docker compose logs --tail 40 immich-server`
second. A running container is not success; these three responses are.

The first screen at https://<DOMAIN> shows the heading `Welcome to Immich` and a
`Getting Started` button. Open it now, click `Getting Started`, and fill in the
`Admin Registration` form. Whoever loads that page first becomes the administrator of this
server, so it should be you and it should be now.

Then close setup permanently and recreate the server container:

```bash
cd /srv/immich
sed -i 's/^IMMICH_ALLOW_SETUP=true$/IMMICH_ALLOW_SETUP=false/' /srv/immich/.env
docker compose up -d --force-recreate immich-server
sleep 30
curl -sS https://<DOMAIN>/api/server/config
grep '^IMMICH_ALLOW_SETUP=' /srv/immich/.env
```

You should see: the config object now containing `"isInitialized":true`, and the line
`IMMICH_ALLOW_SETUP=false`.

If you do not: `"isInitialized":false` still means the registration form did not submit, so go
back to https://<DOMAIN> and finish it before running this block again. Every account after the
first is created by you from Administration > Users, so once these two checks pass there is no
open door left. If the grep printed `true`, the `sed` did not match; open the file and edit the
line by hand, then re-run the last three commands.

## 8. First backup and restore

Two artifacts, and they are not interchangeable. The dump is metadata: upstream is explicit that
a database backup holds no photos and no video. The photos are files under /srv/immich/data.

```bash
cd /srv/immich
docker compose exec -T database pg_dump --clean --if-exists --dbname=immich --username=immich | gzip > /srv/immich/backups/immich-db-$(date +%F).sql.gz
sudo tar -czf /srv/immich/backups/immich-config-$(date +%F).tar.gz -C /srv/immich compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/immich/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline,
because `pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error. Immich
also writes its own dump into /srv/immich/data/backups nightly at 2am and keeps the last
fourteen, so from tomorrow there will be two on this disk. Two copies on one disk is still one
disk.

A backup on the same disk as the data is not a backup. Run both of these on your own machine,
not the server:

```bash
mkdir -p ~/backups/immich
scp vps:/srv/immich/backups/* ~/backups/immich/
rsync -a --exclude 'thumbs/' --exclude 'encoded-video/' vps:/srv/immich/data/ ~/backups/immich/data/
```

You should see: two files copied by `scp`, then rsync working through the data directory, which
is nearly empty today and will not be in a year.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives. The two
excluded directories hold thumbnails and transcodes, which Immich regenerates from the
originals; everything else under data is irreplaceable, so do not add more excludes to make the
copy faster.

Now prove the restore, today, while the only thing at risk is an empty library:

```bash
cd /srv/immich
docker compose down
sudo rm -rf /srv/immich/postgres
sudo install -d -m 700 /srv/immich/postgres
docker compose up -d database
sleep 60
gunzip --stdout /srv/immich/backups/immich-db-$(date +%F).sql.gz | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | docker compose exec -T database psql --dbname=immich --username=immich --single-transaction --set ON_ERROR_STOP=on
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/api/server/config
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then a config object containing
`"isInitialized":true`, which means your administrator account survived a database that was
deleted and rebuilt from the dump.

If you do not: `role "immich" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. That `sed` in the middle is not
decoration: it is the search_path rewrite upstream documents, and without it the restore loads
into a schema where the vector extension is invisible. Understand what you have proved and what
you have not. You have proved the metadata restores. The photos restore by copying
~/backups/immich/data back to /srv/immich/data, which is a longer operation on a full library
and worth timing once before you need it.

## 9. Updating later

Releases are listed at https://github.com/immich-app/immich/releases, and the ones that break
something carry a changelog:breaking-change label, filtered at
https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created.
Read the release notes before pulling, every time. Upstream does not backport patches and states
that downgrading, even within the same minor version, is not supported, so an upgrade you cannot
reverse is the normal case here. Take both backups first, then edit the four image lines in
/srv/immich/compose.yml to their new tags and digests.

```bash
cd /srv/immich
docker compose pull
docker compose up -d
docker compose logs --tail 30 immich-server
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tags and digests back and run the same three commands. Then re-run
the health check from step 7 before you call the update done. Across a major version, update
your phones before the server, which is the order upstream asks for, because the server only
speaks to clients on its own major version.

## 10. What will probably go wrong

The machine-learning container will look wedged, and it is not. The first time you search for a
word instead of a date, that container downloads a CLIP model into its cache volume and then
works through your library one asset at a time. On a two-core box I watched load average sit
above four for twenty minutes with nothing changing on screen, decided the install was broken,
and restarted things, which only made it start over. `docker compose logs --tail 20
immich-machine-learning` shows the download and then the inference lines. Leave it alone until
those stop.

## 11. Out of scope

- Do not enable hardware transcoding or machine-learning acceleration. Those are upstream's
  hwaccel.transcoding.yml and hwaccel.ml.yml, they need a matched driver on the host, and this
  install runs both workloads on the CPU on purpose.
- Do not configure OAuth. Immich has local accounts and you create the rest from
  Administration > Users.
- Do not configure SMTP. Immich runs without it; only invitation and album-share email needs it.
- Do not mount an external library. Pointing Immich at photos it does not own changes what a
  backup means, and that is a decision to make deliberately, not while installing.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Immich 3.1.0, with the PostgreSQL and machine-learning worker it needs, under
~/selfhost/immich, answering at http://localhost:8098.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install.
Immich will answer on http://localhost:8098, this computer and nothing else. Their phone cannot
reach it, so the automatic camera backup that replaces Google Photos does not work here; photos
arrive by dragging them into the browser. They get a private library with search, faces and
albums, on one machine.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. Upstream publishes a floor of 6 GB of RAM,
and this install runs the machine-learning container, so 6144 MB is a floor rather than a
suggestion. Budget 20 GB free on the home disk before a single photo. On macOS and Windows that
figure is the host's and Docker Desktop's VM takes its share, so give it at least 6 GB in
Settings first. If available RAM is under 6144 MB or free disk is under 20 GB, print both and
stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they confirm.
  Either way, then STOP: tell the user to open Docker Desktop once, accept its terms, and wait
  for the whale icon to say it is running. Do not continue until they confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and install
  it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may ask for a
  reboot; if it does, STOP and tell the user to reboot and come back, this prompt resumes at
  this step. Then STOP: have the user open Docker Desktop, accept its terms, and confirm it
  says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt repository,
  with its signing key saved to a file first, never piped into a shell. The fence is guarded, a
  no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the user
  in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose plugin
  with their distribution's package manager, and to run this prompt again once `docker info`
  works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not continue
without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/immich/data ~/selfhost/immich/backups
ls -la ~/selfhost/immich
```

Assert: `data` and `backups` both exist, owned by the user. `data` is the photo library. The one
directory needing a chown to the image's own uid is PostgreSQL's, and step 5 keeps it in a
Docker volume, so no ownership fix runs on any of the three systems.

## 4. Secrets

One secret, the PostgreSQL password. Generate it here, do not print it, and keep it out of your
summary and any log line. Hex, not base64: upstream restricts it to A-Za-z0-9.

```bash
umask 077
cat > ~/selfhost/immich/.env <<EOF
TZ=Etc/UTC
DB_USERNAME=immich
DB_DATABASE_NAME=immich
IMMICH_ALLOW_SETUP=true
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/immich/.env
umask 022
ls -l ~/selfhost/immich/.env
```

Assert: the file exists with mode `-rw-------`. On Windows those mode bits are advisory because
NTFS does not enforce them; the real boundary is the user's own account, which on a single-user
machine is the one that matters. `IMMICH_ALLOW_SETUP` is true only until step 7 closes it.

## 5. compose.yml

```bash
cat > ~/selfhost/immich/compose.yml <<'EOF'
# Immich · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose install . https://docs.immich.app/install/docker-compose
#   variable reference ..... https://docs.immich.app/install/environment-variables
#
# Four services on the computer you are sitting at. Paths are relative to
# ~/selfhost/immich/, so one file works on macOS, Linux and Windows. The
# database is upstream's VectorChord build, not stock postgres; its data
# directory is a named volume because that image chowns it to its own uid, which
# Docker Desktop cannot grant on a Windows home bind. The library stays a bind
# mount. Pins are upstream's for v3.1.0, re-read 2026-08-05, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: immich

services:
  database:
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
    container_name: immich_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: "--data-checksums"
    volumes:
      - immich-pgdata:/var/lib/postgresql/data
    shm_size: 128mb
    # The image brings its own postgresql.conf and health script. No `ports:`.

  redis:
    image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
    container_name: immich_redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping || exit 1"]
      interval: 10s
      retries: 12
    # No `ports:` either: the queue is spoken between containers only.

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v3.1.0@sha256:5a0839dc5303cd7215bcd2180a26aed3af41675aefb3e75e5157e9f10ad16e6e
    container_name: immich_machine_learning
    restart: unless-stopped
    volumes:
      - model-cache:/cache
    # No env_file: it reads no DB_ or REDIS_ variable. The name is load-bearing:
    # the server looks for models at immich-machine-learning:3003.

  immich-server:
    image: ghcr.io/immich-app/immich-server:v3.1.0@sha256:b434cb9287eea1471c9974845914d4dd328c9c2d652e446ed4930f99944f0ceb
    container_name: immich_server
    restart: unless-stopped
    env_file: ./.env
    environment:
      DB_HOSTNAME: database
      REDIS_HOSTNAME: redis
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8098.
      - "127.0.0.1:8098:2283"
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  model-cache:
  immich-pgdata:
EOF
cd ~/selfhost/immich && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. No DNS, because there is no hostname to
resolve. No TLS, because a certificate attests a public name and nothing here has one; browsers
treat http://localhost as a secure context anyway, so pages needing crypto still work. No
firewall rule, because nothing is published beyond loopback.

8098 is bound to 127.0.0.1: not the phone, not a laptop on the same wifi, not the internet. That
is the point of this path and its whole cost. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/immich/compose.yml
```

Assert: one line, `- "127.0.0.1:8098:2283"`. PostgreSQL and Valkey publish no host port.

## 7. Start and verify

The pull is roughly 5 GB and the first database start builds its extensions. The loop allows
twelve minutes.

```bash
cd ~/selfhost/immich
docker compose pull
docker compose up -d
for i in $(seq 1 72); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8098/api/server/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8098/api/server/ping
curl -sS http://localhost:8098/api/server/config
```

Assert all three and print what you received. The loop ends on `200`; the ping response is
exactly `{"res":"pong"}`, the string the container's own health script checks for; the config
response contains `"isInitialized":false`, so no account exists yet. If any misses, stop, run
`docker compose logs --tail 40 immich-server` and `docker compose logs --tail 20 database`, and
name the cause: a database that never reports healthy points at step 4, where an empty
`DB_PASSWORD` leaves PostgreSQL refusing to start; for `port is already allocated`, find what
holds 8098 with `lsof -nP -iTCP:8098 -sTCP:LISTEN`. A running container is not success.

The first screen at http://localhost:8098 shows the heading `Welcome to Immich` and a
`Getting Started` button.

STOP: tell the user to open http://localhost:8098, click `Getting Started`, and fill in the
`Admin Registration` form, and wait. Do not continue until they confirm.

Once they confirm, close setup permanently:

```bash
cd ~/selfhost/immich
sed -i.bak 's/^IMMICH_ALLOW_SETUP=true$/IMMICH_ALLOW_SETUP=false/' ~/selfhost/immich/.env
rm -f ~/selfhost/immich/.env.bak
docker compose up -d --force-recreate immich-server
sleep 30
curl -sS http://localhost:8098/api/server/config
grep '^IMMICH_ALLOW_SETUP=' ~/selfhost/immich/.env
```

Assert: the config response now contains `"isInitialized":true` and the grep prints
`IMMICH_ALLOW_SETUP=false`. Both must pass before you report success. The `.bak` suffix is there
because macOS `sed` requires one.

## 8. First backup and restore

Two artifacts, not interchangeable. Upstream is explicit that a database backup holds no photos
and no video: the photos are files under `data`.

```bash
cd ~/selfhost/immich
docker compose exec -T database pg_dump --clean --if-exists --dbname=immich --username=immich | gzip > ~/selfhost/immich/backups/immich-db-$(date +%F).sql.gz
tar -C ~/selfhost/immich -czf ~/selfhost/immich/backups/immich-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/immich/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently.

Both archives sit on the same disk as the photos, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a synced folder or an
external drive, and copy both archives and the whole `data` directory there with `cp -R`. Assert:
the user confirms all three are there. If they have nowhere to put them, say plainly that this
install has no backup.

To restore, in this order: untar the config archive into ~/selfhost/immich first, because
PostgreSQL reads `DB_PASSWORD` from .env the moment it initialises an empty volume; copy `data`
back; `docker compose down -v`, the one place `-v` belongs because it drops the old volume on
purpose; `docker compose up -d database`; wait a minute for healthy; then load the dump with
upstream's search_path rewrite:

```bash
gunzip --stdout ~/selfhost/immich/backups/immich-db-$(date +%F).sql.gz | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | docker compose exec -T database psql --dbname=immich --username=immich --single-transaction --set ON_ERROR_STOP=on
```

Then `docker compose up -d` and open one photo. The dump knows where every photo is, `data` is
every photo, either alone rebuilds nothing.

## 9. Updating later

Releases are at https://github.com/immich-app/immich/releases, and the ones that break something
carry a changelog:breaking-change label on the matching discussion. Read the release notes
before pulling, every time: upstream does not backport patches and states that downgrading is
not supported. Take both backups, then edit the four image lines to their new tags and digests:

```bash
cd ~/selfhost/immich
docker compose pull
docker compose up -d
docker compose logs --tail 30 immich-server
```

Immich migrates its database on the way up, so watch that log until it settles, then re-run step
7's health check.

## 10. What will probably go wrong

I closed the laptop lid with an import running, opened it an hour later, and found half the
library with no thumbnails and a search returning nothing. Nothing was corrupted: Docker Desktop
had suspended with the machine, the queue stopped where it was, and Immich picked it up once the
containers were back. Turn on Docker Desktop's start-at-login setting, and after any sleep or
reboot run `cd ~/selfhost/immich && docker compose up -d` and read Administration > Job Queues
before concluding anything broke.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8098 to 0.0.0.0 so the phone app can reach it over wifi. That puts a library
  holding everything the user owns on every network they join.
- Do not enable hardware transcoding or machine-learning acceleration. Upstream's hwaccel files
  need a matched host driver; this install runs both on the CPU on purpose.
- Do not configure OAuth or SMTP. Immich needs neither.
````

## docker-compose.yml

```yaml
# Immich · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose install . https://docs.immich.app/install/docker-compose
#   variable reference ..... https://docs.immich.app/install/environment-variables
#   backup and restore ..... https://docs.immich.app/administration/backup-and-restore
#
# Four services, because that is what Immich is: the server, a machine-learning
# worker doing search and faces on the CPU, a Valkey job queue, and PostgreSQL.
# The database is upstream's own build, not stock postgres, because Immich
# keeps one vector per photo in the VectorChord extension. Every tag and digest
# is upstream's pin for v3.1.0, re-read from the registries on 2026-08-05; the
# valkey `9` tag has moved since. All four images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: immich

services:
  database:
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
    container_name: immich_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: "--data-checksums"
    volumes:
      - /srv/immich/postgres:/var/lib/postgresql/data
    shm_size: 128mb
    # The image ships its own tuned postgresql.conf and health script, so
    # nothing here overrides either. No `ports:`: 5432 stays container-only.

  redis:
    image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
    container_name: immich_redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping || exit 1"]
      interval: 10s
      retries: 12
    # No `ports:` either. The queue is spoken between containers only.

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v3.1.0@sha256:5a0839dc5303cd7215bcd2180a26aed3af41675aefb3e75e5157e9f10ad16e6e
    container_name: immich_machine_learning
    restart: unless-stopped
    volumes:
      - model-cache:/cache
    # No env_file: it reads no DB_ or REDIS_ variable, so the password needs no
    # third copy. The service name is load-bearing: the server looks for the
    # models at immich-machine-learning:3003.

  immich-server:
    image: ghcr.io/immich-app/immich-server:v3.1.0@sha256:b434cb9287eea1471c9974845914d4dd328c9c2d652e446ed4930f99944f0ceb
    container_name: immich_server
    restart: unless-stopped
    env_file: /srv/immich/.env
    environment:
      DB_HOSTNAME: database
      REDIS_HOSTNAME: redis
    volumes:
      # Originals, thumbnails, transcodes and the nightly dumps all land here.
      - /srv/immich/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8098.
      - "127.0.0.1:8098:2283"
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  model-cache:
```

## compose.local.yml

```yaml
# Immich · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose install . https://docs.immich.app/install/docker-compose
#   variable reference ..... https://docs.immich.app/install/environment-variables
#
# Four services on the computer you are sitting at. Paths are relative to
# ~/selfhost/immich/, so one file works on macOS, Linux and Windows. The
# database is upstream's VectorChord build, not stock postgres; its data
# directory is a named volume because that image chowns it to its own uid, which
# Docker Desktop cannot grant on a Windows home bind. The library stays a bind
# mount. Pins are upstream's for v3.1.0, re-read 2026-08-05, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: immich

services:
  database:
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
    container_name: immich_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: "--data-checksums"
    volumes:
      - immich-pgdata:/var/lib/postgresql/data
    shm_size: 128mb
    # The image brings its own postgresql.conf and health script. No `ports:`.

  redis:
    image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
    container_name: immich_redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping || exit 1"]
      interval: 10s
      retries: 12
    # No `ports:` either: the queue is spoken between containers only.

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v3.1.0@sha256:5a0839dc5303cd7215bcd2180a26aed3af41675aefb3e75e5157e9f10ad16e6e
    container_name: immich_machine_learning
    restart: unless-stopped
    volumes:
      - model-cache:/cache
    # No env_file: it reads no DB_ or REDIS_ variable. The name is load-bearing:
    # the server looks for models at immich-machine-learning:3003.

  immich-server:
    image: ghcr.io/immich-app/immich-server:v3.1.0@sha256:b434cb9287eea1471c9974845914d4dd328c9c2d652e446ed4930f99944f0ceb
    container_name: immich_server
    restart: unless-stopped
    env_file: ./.env
    environment:
      DB_HOSTNAME: database
      REDIS_HOSTNAME: redis
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8098.
      - "127.0.0.1:8098:2283"
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  model-cache:
  immich-pgdata:
```

## Caddyfile

```text
# Immich · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.immich.app/administration/reverse-proxy,
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/encode
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Upstream states Immich cannot be served from a sub-path.

<DOMAIN> {
	# Caddy's encode touches only the text-like content types in its default
	# matcher, so the web app is compressed and photos go through untouched.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8098 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall. Caddy sets three of the four
	# headers upstream asks for, so only X-Real-IP is written. It also applies no
	# request-body limit and no proxy read timeout, which is what an nginx install
	# has to fix before the first 4 GB video upload.
	reverse_proxy 127.0.0.1:8098 {
		header_up X-Real-IP {remote_host}
	}
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Immich · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=photos.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.immich.app/install/docker-compose
#   https://docs.immich.app/install/requirements
#   https://docs.immich.app/install/environment-variables
#   https://docs.immich.app/administration/reverse-proxy
#   https://docs.immich.app/administration/backup-and-restore
#
# One secret is generated here, on this machine: the PostgreSQL password. It
# goes into /srv/immich/.env with mode 600 and is never printed.
#
# This script stops one step short of a finished install, on purpose. Only a
# human with a browser can create the first Immich account, so the closing
# summary hands you that step and the two commands that close setup after it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/immich}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. photos.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

docker_major="$(docker version --format '{{.Server.Version}}' | cut -d. -f1)"
[ "$docker_major" -ge 25 ] || die "Docker Engine ${docker_major}.x is too old; the database health gate needs 25 or newer"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 6144 ] || die "only ${avail_mb} MB of RAM available; upstream's floor for this install is 6144 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB before any photos"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# postgres stays owned by root at mode 700: the PostgreSQL image chowns its own
# data directory on first start and refuses one already chowned to somebody else.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/data" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: upstream restricts this password to A-Za-z0-9. You
# never need to read it. Immich talks to its own database and nothing else does.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		TZ=Etc/UTC
		DB_USERNAME=immich
		DB_DATABASE_NAME=immich
		IMMICH_ALLOW_SETUP=true
		DB_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-immich"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8098, 5432 and 6379 are not among them ----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8098, 5432 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# Roughly 5 GB of images, and the first database start builds its extensions,
# so this is the slow part. Twelve minutes of patience before giving up.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/server/ping"
for _ in $(seq 1 72); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/server/ping" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/server/ping answered ${code:-nothing}. Check: docker compose logs --tail 40 immich-server"

ping_body="$(curl -sS "https://${DOMAIN_HOST}/api/server/ping" || true)"
[ "$ping_body" = '{"res":"pong"}' ] || die "ping returned ${ping_body}, not {\"res\":\"pong\"}"

# The digest is the version, or it is not. Prove it rather than assuming it.
version_body="$(curl -sS "https://${DOMAIN_HOST}/api/server/version" || true)"
case "$version_body" in
	*'"major":3'*'"minor":1'*) : ;;
	*) die "server/version returned ${version_body}; expected major 3 minor 1" ;;
esac

# No account yet. This is the state the closing summary hands to the human.
config_body="$(curl -sS "https://${DOMAIN_HOST}/api/server/config" || true)"
case "$config_body" in
	*'"isInitialized":false'*) : ;;
	*'"isInitialized":true'*) echo "==> an account already exists on this server; skipping the registration notes below" ;;
	*) die "server/config returned nothing usable: ${config_body}" ;;
esac

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T database pg_dump --clean --if-exists --dbname=immich --username=immich | gzip > "$APP_DIR/backups/immich-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/immich-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/immich-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Immich is answering at https://${DOMAIN_HOST}/api/server/ping with {"res":"pong"}

	  1. Open https://${DOMAIN_HOST} in a browser now. The first screen reads
	     "Welcome to Immich" with a "Getting Started" button. Click it and fill in
	     the "Admin Registration" form. Whoever loads that page first becomes the
	     administrator of this server, so do it before you do anything else.
	  2. Then close setup permanently, so that form can never be reached again:
	       cd $APP_DIR
	       sed -i 's/^IMMICH_ALLOW_SETUP=true\$/IMMICH_ALLOW_SETUP=false/' $APP_DIR/.env
	       docker compose up -d --force-recreate immich-server
	     Confirm with: curl -sS https://${DOMAIN_HOST}/api/server/config
	     It must now contain "isInitialized":true.
	  3. Your phone gets the Immich app from its own store; the server endpoint to
	     type in is https://${DOMAIN_HOST}/api
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. The dump holds no photos, only where they are. The photos are
	     files under $APP_DIR/data. Both are on the same disk as the data, which
	     is not a backup. Copy them off the box tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/immich/
	       rsync -a --exclude 'thumbs/' --exclude 'encoded-video/' vps:$APP_DIR/data/ ~/backups/immich/data/

DONE
```

## Also evaluated

Ranked below Immich for this swap. The prompts above install Immich only.

- **Nextcloud** — Files, calendars and contacts on hardware you control, with the desktop and mobile clients pointed at it instead of somebody else's cloud. Second place because it replaces the other half. Google One is one meter across Photos, Drive and Gmail, and Nextcloud takes the Drive side: the files, the desktop sync client, the shared folders, the documents two people edit at once. Its photo timeline exists and is not Immich, so if the camera roll is the reason your storage filled up, install Immich first and let Nextcloud have everything else. The storage arithmetic is identical either way, because the disk you have to buy and back up is the same disk.

The page this mirrors: https://caniselfhostit.com/self-host/google-one/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
