Can I self-host Google One?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called Immich. It takes one prompt, a 6144 MB VPS, and about 300 minutes. That is $9.99 a month you stop paying Google One — $119.88 a year on the Google AI Plus (2 TB) plan.
Why people pay for Google One
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Almost nobody sets out to buy Google One. You take a few thousand photos, the free 15 GB fills up, and one morning Gmail stops accepting mail and your phone stops backing up until you pick a plan. What the money buys is real: storage shared across Photos, Drive and Gmail, a camera roll that uploads itself without you thinking about it, search that finds a picture of a dog on a beach in 2014, and a copy of everything you own sitting in somebody else's datacentre with an operator on call. Since 2026 the middle and upper tiers are sold as AI plans, so the same subscription now carries Gemini limits whether or not the storage was the reason you subscribed.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | 15 GB shared across Google Drive, Gmail and Google Photos. This is the tier people are on when the bill starts. |
| Basic (100 GB) | $1.99/mo | Storage only. Shareable with up to 5 other people. The plans page advertises up to 16% off for annual billing. |
| Google AI Plus (400 GB) | $4.99/mo | The lower of two plans sold under the AI Plus name, listed on the Google AI subscriptions page rather than the storage plans page. |
| Google AI Plus (2 TB)the plan this page prices against | $9.99/mo | The 2 TB tier, formerly sold as Premium. This is the one a full phone camera roll usually lands on. |
| Google AI Pro (5 TB) | $19.99/mo | 5 TB, bundled with YouTube Premium Lite and other Google subscriptions. |
| Google AI Ultra (20 TB) | $99.99/mo | Introduced at Google I/O 2026 as a $100 tier between AI Pro and the top plan, with roughly 5x the AI usage limits of AI Pro. |
| Google AI Ultra (highest limits) | $199.99/mo | The top plan, cut from $250 to $200 a month at I/O 2026, with roughly 20x the AI usage limits of AI Pro. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: medium
Replaced by Immich
One project, named before the prompt, so you know what you are about to install.
Your camera roll uploads itself to a server you own, and search still finds the dog on the beach.
This is the half of the bill you are actually paying. Photos are what fills 15 GB, and Immich is the only self-hosted option with a phone app that does the thing Google Photos does without being asked: background upload of the camera roll, including video, with the originals kept. On top of that you get search by what is in the picture, face grouping, albums and a map, all running on your own CPU rather than a service that reads your library. The trade is honest and it is not small. It wants 6 GB of RAM, four containers, and a backup habit for both a database and a growing directory of files, and if you drop the phone app on the local-only path you lose the automatic part that made you pay in the first place.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Immichour pickONE WEEKEND— setup effort 3 of 4
Your camera roll uploads itself to a server you own, and search still finds the dog on the beach.
This is the half of the bill you are actually paying. Photos are what fills 15 GB, and Immich is the only self-hosted option with a phone app that does the thing Google Photos does without being asked: background upload of the camera roll, including video, with the originals kept. On top of that you get search by what is in the picture, face grouping, albums and a map, all running on your own CPU rather than a service that reads your library. The trade is honest and it is not small. It wants 6 GB of RAM, four containers, and a backup habit for both a database and a growing directory of files, and if you drop the phone app on the local-only path you lose the automatic part that made you pay in the first place.
- NextcloudONE WEEKEND— setup effort 3 of 4
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 swap
You'd run
Immich
ONE WEEKEND · ~300 min to running · 6144 MB RAM
Google One Google AI Plus (2 TB) · vendor list price · checked 2026-08-05 · source · confidence: medium
Before you start
- RAM floor
- 6144 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~300 min3–24 hours, through the first backup
The prompt
Two paths to the same Immich: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
346 lines · 14,911 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
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.334 lines · 14,972 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout72 lines
# 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:agent-readable mirror: /self-host/google-one.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned75 lines
# 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:Caddyfilethe hostname and TLS31 lines
# 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.shthe same install, no agent166 lines
#!/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/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Google One.
- Two things to back up, not one. The database knows where every photo is and the directory holds every photo, and restoring one without the other rebuilds nothing. Upstream says this plainly: a database dump contains no pictures. Immich takes a nightly dump for you and puts it on the same disk as the library, which is a convenience, not a backup.
- A real server, not the cheapest one. Upstream's floor is 6 GB of RAM and this install runs four containers, one of which does machine learning on the CPU. Budget 20 GB of disk before your first photo, then the size of your library on top, plus 10 to 20 percent for the thumbnails and transcodes it generates.
- You are the one who reads the release notes. Immich ships often, calls out breaking changes on its own releases, and states that downgrading is not supported even within a minor version. Pinning a digest is the easy half; deciding when to move off it is the part that stays yours.
- The phone app is the product. Background camera upload is what you are actually replacing, and it needs the server reachable from wherever the phone is, which means a domain and a certificate. On the local-only path that part does not work, and the honest version of this page says so rather than pretending a laptop is a photo service.
- No shared 15 GB with Gmail, no family plan management, no Gemini. This gets you the photo library. The rest of what Google One meters is a different problem.
Where this came from
“We do not backport patches to earlier versions. We encourage all users to run the most recent stable release of Immich. Downgrading to an earlier version, even within the same minor version, is not supported.”
- Upstream publishes a hardware floor of 6 GB of RAM with 8 GB recommended, and notes that 4 GB works only with machine learning disabled. source
- The release's own compose file runs four services and uses a PostgreSQL image upstream builds itself, carrying the VectorChord extension, rather than a stock postgres image. source
- Immich listens on port 2283 and does not support being served on a sub-path, so it needs a hostname of its own behind the reverse proxy. source
- A database backup contains metadata only, not photos or video, so the files under UPLOAD_LOCATION have to be copied separately for the backup to mean anything. source
- Setting IMMICH_ALLOW_SETUP to false disables the admin-sign-up endpoint, which is how the first-account window is closed once the administrator exists. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Google One?
Not Google One itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Immich. Your camera roll uploads itself to a server you own, and search still finds the dog on the beach. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 300 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Google One?
Immich. Your camera roll uploads itself to a server you own, and search still finds the dog on the beach. This is the half of the bill you are actually paying. Photos are what fills 15 GB, and Immich is the only self-hosted option with a phone app that does the thing Google Photos does without being asked: background upload of the camera roll, including video, with the originals kept. On top of that you get search by what is in the picture, face grouping, albums and a map, all running on your own CPU rather than a service that reads your library. The trade is honest and it is not small. It wants 6 GB of RAM, four containers, and a backup habit for both a database and a growing directory of files, and if you drop the phone app on the local-only path you lose the automatic part that made you pay in the first place. Also evaluated and ranked below it: Nextcloud. The prompt on this page installs Immich only. Immich is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Google One?
6144 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Immich itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Google One Google AI Plus (2 TB), $9.99/mo — $119.88 a year.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the Immich install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Immich on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Immich on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer Immich answers only at http://localhost:8098, so your phone cannot reach it and the automatic camera backup does not happen: photos arrive when you drag them into the browser on that machine. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.