Can I self-host Adobe Lightroom?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called PhotoPrism. It takes one prompt, a 3072 MB VPS, and about 120 minutes. That is $14.99 a month you stop paying Adobe Lightroom — $179.88 a year on the Lightroom (1TB), annual plan plan.

Why people pay for Adobe Lightroom

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.

Lightroom is two products sold as one, and most subscribers only think about the first. It is a raw developer: a non-destructive edit stack, masking that finds the sky and the subject on its own, presets you built over years, and profiles for the exact camera and lens you shot with. It is also a catalogue: every photograph you own, indexed, keyworded, searchable, and synced so the phone, the laptop and the desktop see the same library. The develop half is genuinely hard to leave, because the edits are stored as instructions in Adobe's format and nothing else reads them the same way. The catalogue half is the part that quietly grew into a storage bill, and it is the part a server you own can take back.

Adobe Lightroom plans and list prices
PlanList priceWhat it buys
Lightroom (1TB), annual planthe plan this page prices against$14.99/moBilled monthly on a one-year commitment, with 1 TB of cloud storage. Adobe's own pricing-change notice puts this at $14.99, up from $11.99, for new subscribers from 20 March 2026 and for existing ones at their next renewal on or after that date. Paying for the year up front is $149.99, up from $119.88.
Lightroom (1TB), month to month$22.49/moNo commitment, cancel any time. Raised from $17.99 in the same March 2026 change. This is the figure a subscriber pays who has not signed up for a year.
Photography plan (1TB)$19.99/moAdds Lightroom Classic and Photoshop to the same 1 TB of cloud storage. This is the plan most photographers who keep a desktop catalogue are actually on, which is why the arithmetic on this page is worth doing twice.
Photography plan (20GB)$9.99/moThe cheap tier that made this bundle famous. Closed to new subscribers, and existing members were moved to a higher monthly rate; it is listed here because a lot of people are still on it and will be surprised by what replaces it.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium

Replaced by PhotoPrism

One project, named before the prompt, so you know what you are about to install.

A photo library that indexes what is in the picture, so twenty years of files become something you can search.

The closest thing to Lightroom's catalogue half that you can run on your own machine. It indexes a folder tree you already have, recognises faces and subjects on your own CPU rather than in somebody's datacentre, reads RAW files, puts everything on a map and a timeline, and answers to a search box that understands what is in the picture. Be clear about what it is not: it will not develop a photograph. There is no exposure slider, no masking, no preset. If the develop module is what you open Lightroom for, this replaces the half of the subscription that turned into a storage bill and leaves the other half exactly where it is.

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.

  1. PhotoPrismour pickONE EVENING— setup effort 2 of 4

    A photo library that indexes what is in the picture, so twenty years of files become something you can search.

    The closest thing to Lightroom's catalogue half that you can run on your own machine. It indexes a folder tree you already have, recognises faces and subjects on your own CPU rather than in somebody's datacentre, reads RAW files, puts everything on a map and a timeline, and answers to a search box that understands what is in the picture. Be clear about what it is not: it will not develop a photograph. There is no exposure slider, no masking, no preset. If the develop module is what you open Lightroom for, this replaces the half of the subscription that turned into a storage bill and leaves the other half exactly where it is.

  2. ImmichONE 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.

    Second here, and first for a different question. If what you actually need back is the phone: the camera roll uploading itself the moment you take a picture, from anywhere, into an archive you own. Immich has the mobile app for that and PhotoPrism does not. It is the heavier install, four containers against two, and it points at the everything-archive rather than the curated library. Choose it if the sync is the thing you are paying for, and PhotoPrism if the organising is.

The swap

You're paying

Adobe Lightroom

$14.99/mo · $179.88/yr

is replaced by

You'd run

PhotoPrism

ONE EVENING · ~120 min to running · 3072 MB RAM

Adobe Lightroom Lightroom (1TB), annual plan · vendor list price · checked 2026-08-07 · source · confidence: medium

Before you start

RAM floor
3072 MBfloor from upstream docs — not measured by us yet
Disk
10 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
~120 min1–3 hours, through the first backup

The prompt

Two paths to the same PhotoPrism: 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.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

327 lines · 14,990 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 PhotoPrism 260728-ce 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 here, and it becomes `PHOTOPRISM_SITE_URL`, the address in every share
link, so moving it later breaks the links already handed out.

Say this to the user first, because it decides whether they want the install at all: PhotoPrism
organises, searches and shows photographs, and it does not develop them. No exposure slider, no
masking, no presets, no history stack. It replaces the catalogue half of Lightroom, not the other
half.

Upstream asks for 2 cores, 3 GB of physical memory and 4 GB of swap, and this wants 10 GB free on
/srv before the first photograph. Both images publish amd64 and arm64. Measure all five:

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

If available RAM is under 3072 MB or free disk is under 10 GB, print both numbers and stop. Do not
install and hope. A box sold as 3 GB shows less than 3072 MB available, so plan on 4 GB. Under
1 GB of total memory PhotoPrism turns TensorFlow and RAW indexing off by itself, which is this
install with the search quietly missing. If swap prints `0`, say so: the indexer spikes on large
files and needs the headroom. If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/photoprism /srv/photoprism/backups /srv/photoprism/originals /srv/photoprism/storage
sudo install -d -m 700 /srv/photoprism/mariadb
ls -la /srv/photoprism
```

Assert: `backups`, `originals` and `storage` owned by the login user, `mariadb` at mode `700`
owned by root. Leave that one alone; the MariaDB image chowns its own data directory and refuses
one somebody claimed first. `originals` is the library; `storage` is cache, sidecar YAML and the
nightly dump PhotoPrism writes itself.

## 3. Secrets

Three secrets: the initial admin password, the `photoprism` database user's password and the
MariaDB root password. Print none of them and keep all three out of your summary and every log
line.

```bash
umask 077
cat > /srv/photoprism/.env <<EOF
PHOTOPRISM_SITE_URL=https://<DOMAIN>/
PHOTOPRISM_ADMIN_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PHOTOPRISM_UID=%s\nPHOTOPRISM_GID=%s\n' "$(id -u)" "$(id -g)" >> /srv/photoprism/.env
chmod 600 /srv/photoprism/.env
umask 022
ls -l /srv/photoprism/.env
id -u
```

Assert: mode `-rw-------`, the login user's name twice, and `id -u` inside the ranges upstream
supports for the id the container drops to after start-up: 0, 33, 50-99, 500-600, 900-1250 and
2000-2100. A first user on a fresh VPS is 1000. Outside those, stop and say so rather than editing
the file. Compose reads this .env for the `${...}` substitutions and never mounts it.
`PHOTOPRISM_ADMIN_PASSWORD` is read once, when the superadmin is created on the first start;
editing the file later changes nothing, and step 7 says where the real change is made.

## 4. compose.yml

```bash
cat > /srv/photoprism/compose.yml <<'EOF'
# PhotoPrism · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose .. https://docs.photoprism.app/getting-started/docker-compose/
#   config options .. https://docs.photoprism.app/getting-started/config-options/
#   behind a proxy .. https://docs.photoprism.app/getting-started/proxies/traefik/
#   open source faq . https://www.photoprism.app/oss/faq
#
# Two services: PhotoPrism and the MariaDB holding the index. The image is the
# "ce" build, which upstream describes as the Community Edition distributed
# under the AGPL; the unsuffixed Docker Hub tags carry their Plus License.
# MariaDB 11.8 is the current long-term release, above the 10.5.12 floor
# upstream states. PHOTOPRISM_INIT is empty and DEFAULT_TLS false, so the
# container installs nothing and generates no certificate at start-up. Every
# ${...} comes from /srv/photoprism/.env, mode 600, which Compose reads and
# never mounts. Digests read 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: photoprism-db
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DATABASE: photoprism
      MARIADB_USER: photoprism
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - /srv/photoprism/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  photoprism:
    image: photoprism/photoprism:260728-ce@sha256:15deeb6cc6c31f043625579a29a0e26f5f7b328441fc3945a7a0b7e4b54c0a18
    container_name: photoprism
    restart: unless-stopped
    # Relaxed as upstream's example does, for the tools the indexer runs.
    security_opt:
      - seccomp:unconfined
      - apparmor:unconfined
    working_dir: /photoprism
    environment:
      PHOTOPRISM_ADMIN_USER: "admin"
      PHOTOPRISM_ADMIN_PASSWORD: "${PHOTOPRISM_ADMIN_PASSWORD}"
      PHOTOPRISM_AUTH_MODE: "password"
      # Caddy reaches this over the Docker bridge, inside the proxy range
      # PhotoPrism trusts by default.
      PHOTOPRISM_SITE_URL: "${PHOTOPRISM_SITE_URL}"
      PHOTOPRISM_SITE_CAPTION: ""
      PHOTOPRISM_DISABLE_TLS: "true"
      PHOTOPRISM_DEFAULT_TLS: "false"
      # Nothing is installed on first start: the container downloads nothing.
      PHOTOPRISM_INIT: ""
      PHOTOPRISM_DISABLE_MCP: "true"
      PHOTOPRISM_BACKUP_DATABASE: "true"
      PHOTOPRISM_DATABASE_DRIVER: "mysql"
      PHOTOPRISM_DATABASE_SERVER: "mariadb:3306"
      PHOTOPRISM_DATABASE_NAME: "photoprism"
      PHOTOPRISM_DATABASE_USER: "photoprism"
      PHOTOPRISM_DATABASE_PASSWORD: "${DB_PASSWORD}"
      # Drops to the login user after start-up, so the photographs belong
      # to a person rather than to root.
      PHOTOPRISM_UID: "${PHOTOPRISM_UID}"
      PHOTOPRISM_GID: "${PHOTOPRISM_GID}"
    volumes:
      # The library: everything indexed lives here.
      - /srv/photoprism/originals:/photoprism/originals
      # Cache, sidecar YAML and the nightly dump.
      - /srv/photoprism/storage:/photoprism/storage
    ports:
      # Loopback only: the host's Caddy alone reaches 8164.
      - "127.0.0.1:8164:2342"
    depends_on:
      mariadb:
        condition: service_healthy
EOF
cd /srv/photoprism && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No database port is published and no credential is written here;
all three arrive from .env.

## 5. Caddy and TLS

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-photoprism
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PhotoPrism · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.photoprism.app/getting-started/proxies/traefik/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# PHOTOPRISM_SITE_URL in .env, and the two have to say the same thing.

<DOMAIN> {
	# No `encode`: PhotoPrism compresses its own API responses, and JPEG,
	# HEIC and video do not compress twice.

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8164 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8164
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-photoprism, reload,
and report the objection. Caddy asks for the certificate on the first request and renews it
itself. `PHOTOPRISM_DISABLE_TLS` is true in compose.yml for that reason.

## 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, 443/tcp is the only way in, 443/udp is HTTP/3. 8164 is bound to
127.0.0.1 and 3306 is never published, so neither has a host port a rule could apply to. Assert:
`Status: active`, rules for 80, 443/tcp and 443/udp, nothing else.

## 7. Start and verify

The first start creates the schema and the superadmin account. The image is about a gigabyte.

```bash
cd /srv/photoprism
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v1/status
curl -sS -o /dev/null -w '%{http_code}\n' 'https://<DOMAIN>/api/v1/photos?count=1'
curl -sS https://<DOMAIN>/ | grep -c '<title>PhotoPrism</title>'
```

Assert all four, printing what you received for each. The loop ends on `200`. The status body is
exactly `{"status":"operational"}`. The unauthenticated search prints `401`, the security assert
here: it proves `PHOTOPRISM_AUTH_MODE` is `password` rather than `public`, and a `200` would mean
every photograph is visible to anyone who finds the address. The grep prints `1`. If any of the
four misses, stop, run `docker compose logs --tail 40 photoprism` and
`docker compose logs --tail 20 mariadb`, and name the likely step: a database that never reports
healthy is step 2, a lasting `502` is step 5. A running container is not success.

The first screen at https://<DOMAIN> is a sign-in card with a `Name` field, a `Password` field and
a `Sign in` button.

STOP: tell the user to open https://<DOMAIN> and sign in as `admin` with the password they read
themselves using `sudo grep PHOTOPRISM_ADMIN_PASSWORD /srv/photoprism/.env`, and wait.
Do not continue until they confirm. Tell them to put it in their password manager, and that it
changes in Settings, then Account: editing .env afterwards does nothing, because the variable is
read only when the account is created.

## 8. First backup and restore

Two artifacts, not interchangeable. The dump is the index: albums, labels, faces, places and where
every file is. The archive is the configuration and the sidecar YAML. Neither holds a photograph,
and the photographs are the third thing.

```bash
cd /srv/photoprism
docker compose exec -T mariadb sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/photoprism/backups/photoprism-db-$(date +%F).sql.gz
sudo tar --exclude='storage/cache' -czf /srv/photoprism/backups/photoprism-config-$(date +%F).tar.gz -C /srv/photoprism compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/photoprism/backups/
```

Assert: both exist, both are non-empty, both sizes printed. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database. `storage/cache` is left out: it is
thumbnails PhotoPrism regenerates, and the largest disposable thing on a real library's disk.

A backup on the same disk is not a backup. Run these from the user's machine, not the server:

```bash
mkdir -p ~/backups/photoprism
scp vps:/srv/photoprism/backups/* ~/backups/photoprism/
rsync -a vps:/srv/photoprism/originals/ ~/backups/photoprism/originals/
```

To restore: `docker compose down`, `sudo rm -rf /srv/photoprism/mariadb`, recreate it as step 2
does, untar the config archive into /srv/photoprism so .env is back before anything starts,
`docker compose up -d mariadb`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
rsync the originals back, then `docker compose up -d`. Tell the user the stakes: MariaDB takes its
password from .env the moment it initialises an empty directory, and the dump alone rebuilds an
index of files that are not there.

## 9. Updating later

Releases are datestamped and listed at https://github.com/photoprism/photoprism/releases, and the
AGPL image for each carries the `-ce` suffix on Docker Hub. Take both backups first, then edit the
photoprism image line in /srv/photoprism/compose.yml to the new tag and digest:

```bash
cd /srv/photoprism
docker compose pull
docker compose up -d
docker compose logs --tail 40 photoprism
```

PhotoPrism migrates its own schema on the way up, so watch that log until it settles, then re-run
step 7's status check. Upstream does not backport fixes to older datestamps, so an install left
alone for a year updates in one jump.

## 10. What will probably go wrong

You will copy a folder of photographs into /srv/photoprism/originals, reload the browser, and see
an empty library. I did, and spent ten minutes checking the mount, which was fine. PhotoPrism does
not watch that directory: the automatic index fires only for files arriving over WebDAV, and
anything put there another way sits unseen until somebody runs
`docker compose exec -T photoprism photoprism index`, which takes a while on a large folder.

## 11. Out of scope

- Do not add the ollama or open-webui services from upstream's example compose file. They are two
  more containers and a multi-gigabyte model download.
- Do not set `PHOTOPRISM_AUTH_MODE` to `public`. It removes the sign-in screen from a service the
  whole internet can reach, and step 7 asserts against that.
- Do not switch to the unsuffixed image tag for membership features. That build ships under
  PhotoPrism's Plus License rather than the AGPL, and the licence is the user's decision.
- Do not configure hardware video transcoding or mount /dev/dri. It needs devices this prompt never
  checked for.
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 PhotoPrism 260728-ce 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, because it decides whether you want the install at all. PhotoPrism
organises, searches and shows photographs. It does not develop them: there is no exposure
slider, no masking, no presets and no history stack. It replaces the catalogue half of
Lightroom, and the other half stays where it is.

## 1. Preflight

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

You should see: at least `3072` MB available, a swap figure that is not `0`, at least `10` G
free, `amd64` or `arm64`, and your server's IP on the last line. Upstream asks for 2 cores,
3 GB of physical memory and 4 GB of swap. A box sold as 3 GB shows less than 3072 MB
available, so plan on 4 GB.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name
nobody resolves and failed attempts count against a rate limit you cannot see. `0 MB swap` is
worth fixing before you index anything: the indexer spikes on large files and a box with no
swap restarts in the middle instead of finishing. And if total memory is under 1 GB,
PhotoPrism switches TensorFlow and RAW indexing off by itself, which looks like a working
install with the search quietly missing.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/photoprism /srv/photoprism/backups /srv/photoprism/originals /srv/photoprism/storage
sudo install -d -m 700 /srv/photoprism/mariadb
ls -la /srv/photoprism
```

You should see: `backups`, `originals` and `storage` owned by you, and `mariadb` at mode
`drwx------` owned by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. `originals` is the library, the only directory that will hold
photographs; `storage` is cache, sidecar YAML and the nightly database dump PhotoPrism writes
for itself.

## 3. Secrets

Three secrets, all generated here on the server, all straight into a file only you can read:
the initial admin password, the `photoprism` database user's password, and the MariaDB root
password.

```bash
umask 077
cat > /srv/photoprism/.env <<EOF
PHOTOPRISM_SITE_URL=https://<DOMAIN>/
PHOTOPRISM_ADMIN_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PHOTOPRISM_UID=%s\nPHOTOPRISM_GID=%s\n' "$(id -u)" "$(id -g)" >> /srv/photoprism/.env
chmod 600 /srv/photoprism/.env
umask 022
ls -l /srv/photoprism/.env
id -u
```

You should see: mode `-rw-------`, your own username twice, and `id -u` printing a number
inside the ranges upstream supports for the id the container drops to after start-up, which
are 0, 33, 50-99, 500-600, 900-1250 and 2000-2100. A first user on a fresh VPS is 1000.
Replace `<DOMAIN>` on the first line with your real hostname before you paste.

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/photoprism/.env` and
carry on. If `id -u` printed something outside those ranges, stop and ask before editing the
file, because the container uses that number with `setpriv`. If the file already existed from
an earlier attempt, this block has now overwritten all three secrets, which is fine before the
database exists and a problem afterwards: MariaDB keeps the password it was created with, so a
changed `DB_PASSWORD` on an existing directory produces an authentication failure in the
PhotoPrism log rather than anything about passwords.

Do not paste that file, any of the three secrets, or any command output containing them into
this chat window. The agent path never sees those values; this one will hand them to a third
party unless you keep them out.

## 4. compose.yml

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

```bash
cat > /srv/photoprism/compose.yml <<'EOF'
# PhotoPrism · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose .. https://docs.photoprism.app/getting-started/docker-compose/
#   config options .. https://docs.photoprism.app/getting-started/config-options/
#   behind a proxy .. https://docs.photoprism.app/getting-started/proxies/traefik/
#   open source faq . https://www.photoprism.app/oss/faq
#
# Two services: PhotoPrism and the MariaDB holding the index. The image is the
# "ce" build, which upstream describes as the Community Edition distributed
# under the AGPL; the unsuffixed Docker Hub tags carry their Plus License.
# MariaDB 11.8 is the current long-term release, above the 10.5.12 floor
# upstream states. PHOTOPRISM_INIT is empty and DEFAULT_TLS false, so the
# container installs nothing and generates no certificate at start-up. Every
# ${...} comes from /srv/photoprism/.env, mode 600, which Compose reads and
# never mounts. Digests read 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: photoprism-db
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DATABASE: photoprism
      MARIADB_USER: photoprism
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - /srv/photoprism/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  photoprism:
    image: photoprism/photoprism:260728-ce@sha256:15deeb6cc6c31f043625579a29a0e26f5f7b328441fc3945a7a0b7e4b54c0a18
    container_name: photoprism
    restart: unless-stopped
    # Relaxed as upstream's example does, for the tools the indexer runs.
    security_opt:
      - seccomp:unconfined
      - apparmor:unconfined
    working_dir: /photoprism
    environment:
      PHOTOPRISM_ADMIN_USER: "admin"
      PHOTOPRISM_ADMIN_PASSWORD: "${PHOTOPRISM_ADMIN_PASSWORD}"
      PHOTOPRISM_AUTH_MODE: "password"
      # Caddy reaches this over the Docker bridge, inside the proxy range
      # PhotoPrism trusts by default.
      PHOTOPRISM_SITE_URL: "${PHOTOPRISM_SITE_URL}"
      PHOTOPRISM_SITE_CAPTION: ""
      PHOTOPRISM_DISABLE_TLS: "true"
      PHOTOPRISM_DEFAULT_TLS: "false"
      # Nothing is installed on first start: the container downloads nothing.
      PHOTOPRISM_INIT: ""
      PHOTOPRISM_DISABLE_MCP: "true"
      PHOTOPRISM_BACKUP_DATABASE: "true"
      PHOTOPRISM_DATABASE_DRIVER: "mysql"
      PHOTOPRISM_DATABASE_SERVER: "mariadb:3306"
      PHOTOPRISM_DATABASE_NAME: "photoprism"
      PHOTOPRISM_DATABASE_USER: "photoprism"
      PHOTOPRISM_DATABASE_PASSWORD: "${DB_PASSWORD}"
      # Drops to the login user after start-up, so the photographs belong
      # to a person rather than to root.
      PHOTOPRISM_UID: "${PHOTOPRISM_UID}"
      PHOTOPRISM_GID: "${PHOTOPRISM_GID}"
    volumes:
      # The library: everything indexed lives here.
      - /srv/photoprism/originals:/photoprism/originals
      # Cache, sidecar YAML and the nightly dump.
      - /srv/photoprism/storage:/photoprism/storage
    ports:
      # Loopback only: the host's Caddy alone reaches 8164.
      - "127.0.0.1:8164:2342"
    depends_on:
      mariadb:
        condition: service_healthy
EOF
cd /srv/photoprism && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/photoprism/.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/photoprism/compose.yml` and paste again in one go. The image is the
`-ce` build on purpose. Upstream describes that tag as the Community Edition distributed under
the AGPL, and says the unsuffixed tags on Docker Hub carry their own Plus License instead;
this catalogue records PhotoPrism as AGPL-3.0, so the pin has to be the build that claim is
true of.

## 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-photoprism
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# PhotoPrism · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.photoprism.app/getting-started/proxies/traefik/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# PHOTOPRISM_SITE_URL in .env, and the two have to say the same thing.

<DOMAIN> {
	# No `encode`: PhotoPrism compresses its own API responses, and JPEG,
	# HEIC and video do not compress twice.

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8164 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8164
}
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-photoprism /etc/caddy/Caddyfile`,
reload, and paste again. Caddy terminates TLS and speaks plain http to the container, which is
why `PHOTOPRISM_DISABLE_TLS` is `true` in the compose file: without it PhotoPrism would try to
serve its own certificate on a port only Caddy ever connects to.

## 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 `8164` or `3306`.

If you do not: delete anything for `8164` or `3306` with `sudo ufw delete allow 8164`. 8164 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp answers the ACME challenge and redirects
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 further.

## 7. Start and verify

The first start creates the database schema and the superadmin account. The image is about a
gigabyte, so the pull is the slow part.

```bash
cd /srv/photoprism
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v1/status
curl -sS -o /dev/null -w '%{http_code}\n' 'https://<DOMAIN>/api/v1/photos?count=1'
curl -sS https://<DOMAIN>/ | grep -c '<title>PhotoPrism</title>'
```

You should see, in order: the loop reaching `200`, then exactly
`{"status":"operational"}`, then `401`, then `1`.

If you do not: the `401` is the one worth understanding. It means the API is up and refusing a
call with no session, which is what `PHOTOPRISM_AUTH_MODE=password` buys you. A `200` in its
place would mean the library is set to public and every photograph is visible to anyone who
finds the address, and you should stop and fix that before uploading anything. A `404` instead
means Caddy is not reaching the container: check `docker compose ps`. If the loop never
reaches `200`, run `docker compose logs --tail 20 mariadb` first, because a database that
never reports healthy is step 2 done wrong, and `docker compose logs --tail 40 photoprism`
second.

The first screen at https://<DOMAIN> is a sign-in card with a `Name` field, a `Password` field
and a `Sign in` button. Sign in as `admin`, with the password you read once with
`sudo grep PHOTOPRISM_ADMIN_PASSWORD /srv/photoprism/.env`, and put it in your password
manager. Then change it inside PhotoPrism, in Settings, then Account. Editing that .env line
afterwards does nothing at all: the variable is read only when the account is created, which
already happened.

## 8. First backup and restore

Two artifacts, and they are not interchangeable. The dump is the index: albums, labels, faces,
places and where every file is. The archive is the configuration and the sidecar YAML. Neither
one contains a photograph, and the photographs are the third thing.

```bash
cd /srv/photoprism
docker compose exec -T mariadb sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/photoprism/backups/photoprism-db-$(date +%F).sql.gz
sudo tar --exclude='storage/cache' -czf /srv/photoprism/backups/photoprism-config-$(date +%F).tar.gz -C /srv/photoprism compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh /srv/photoprism/backups/
```

You should see: two files, both a few kilobytes to a few megabytes on a fresh install. Nothing
goes offline: `--single-transaction` snapshots a running InnoDB database.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump`
failed and the shell created the file anyway. Run the dump line without `| gzip` to read the
error.

A backup on the same disk as the data is not a backup, and neither of those files holds a
single photograph. Run all of these on your own machine, not the server:

```bash
mkdir -p ~/backups/photoprism
scp vps:/srv/photoprism/backups/* ~/backups/photoprism/
rsync -a vps:/srv/photoprism/originals/ ~/backups/photoprism/originals/
```

You should see: two files copied, and the rsync finishing without error. On a fresh install
the originals directory is empty and the rsync takes a second; once you have a library it is
the long one, and it is the only copy of the pictures.

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 `vps` alias Prompt Zero created
lives.

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

```bash
cd /srv/photoprism
docker compose down
sudo rm -rf /srv/photoprism/mariadb
sudo install -d -m 700 /srv/photoprism/mariadb
docker compose up -d mariadb
sleep 30
gunzip -c /srv/photoprism/backups/photoprism-db-$(date +%F).sql.gz | docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/v1/status
```

You should see: no output from the restore itself, then `{"status":"operational"}` from the
last command, which means the schema survived a database directory that was deleted and
rebuilt.

If you do not: `Access denied for user 'photoprism'` means .env was not in place when MariaDB
initialised the empty directory, so it created the account with a blank password. Untar the
config archive into /srv/photoprism and start again from `docker compose down`. Understand the
stakes before you skip this: the dump knows where every photograph is, the originals directory
is every photograph, and either one alone rebuilds nothing.

## 9. Updating later

Releases are datestamped and listed at https://github.com/photoprism/photoprism/releases, and
the AGPL image for each carries the `-ce` suffix on Docker Hub. Take both backup artifacts
first, then edit the photoprism `image:` line in /srv/photoprism/compose.yml to the new tag and
its digest.

```bash
cd /srv/photoprism
docker compose pull
docker compose up -d
docker compose logs --tail 40 photoprism
```

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

If you do not: put the old tag and digest back and run the same three commands. Then re-run
step 7's status check before you call the update done. Upstream does not backport fixes to
older datestamps, so an instance left alone for a year updates in one jump rather than a
staircase, and that jump is the one to take a backup before.

## 10. What will probably go wrong

You will copy a folder of photographs into /srv/photoprism/originals, reload the browser, and
see an empty library. I did, and spent ten minutes checking the mount, which was fine.
PhotoPrism does not watch that directory: the automatic index fires only for files arriving
over WebDAV, and anything put there another way sits unseen until somebody runs
`docker compose exec -T photoprism photoprism index`, which takes a while on a large folder.

## 11. Out of scope

- Do not add the ollama or open-webui services from upstream's example compose file. They are
  two more containers and a multi-gigabyte model download.
- Do not set `PHOTOPRISM_AUTH_MODE` to `public`. It removes the sign-in screen from a service
  the whole internet can reach, and step 7 asserts against that.
- Do not switch to the unsuffixed image tag for membership features. That build ships under
  PhotoPrism's Plus License rather than the AGPL, and the licence is your decision to make
  deliberately.
- Do not configure hardware video transcoding or mount /dev/dri. It needs devices this install
  never checked for.

317 lines · 14,989 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 PhotoPrism 260728-ce, with the MariaDB that holds its index, under ~/selfhost/photoprism,
answering at http://localhost:8164.

## 1. Preflight

Say both of these to the user before step 2 runs; together they decide whether they want the
install at all. PhotoPrism organises, searches and shows photographs; it does not develop them, so
there is no exposure slider, no masking and no presets. And it answers only at
http://localhost:8164, this computer, so the phone that took the pictures cannot upload to it and
nobody they would share an album with can open it.

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 asks for 2 cores and 3 GB of
physical memory, and this wants 10 GB free on the home disk. Both images publish amd64 and arm64.
If available RAM is under 3072 MB or free disk is under 10 GB, print both numbers and stop. On
macOS and Windows Docker Desktop's virtual machine takes its allocation out of that total.

## 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/photoprism/backups ~/selfhost/photoprism/originals ~/selfhost/photoprism/storage
ls -la ~/selfhost/photoprism
```

Assert: all three present and owned by the user. `originals` is the library, the folder every
photograph goes into; `storage` is cache, sidecar YAML and the nightly dump PhotoPrism writes
itself. On Linux the container writes into these folders as root and
`sudo chown -R "$(id -u):$(id -g)" ~/selfhost/photoprism` hands them back; on macOS and Windows
Docker Desktop maps ownership.

## 4. Secrets

Three secrets: the initial admin password, the database user's password and the MariaDB root
password. Generate all three here, print none, and keep them out of your summary and every log
line.

```bash
umask 077
cat > ~/selfhost/photoprism/.env <<EOF
PHOTOPRISM_SITE_URL=http://localhost:8164/
PHOTOPRISM_ADMIN_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/photoprism/.env
umask 022
ls -l ~/selfhost/photoprism/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same everywhere. Compose
reads this file for the `${...}` substitutions and never mounts it. `PHOTOPRISM_ADMIN_PASSWORD` is
read once, when the superadmin is created on the first start; step 7 says where it is really
changed. On Windows the mode bits are advisory: the real boundary is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/photoprism/compose.yml <<'EOF'
# PhotoPrism · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose .. https://docs.photoprism.app/getting-started/docker-compose/
#   config options .. https://docs.photoprism.app/getting-started/config-options/
#   open source faq . https://www.photoprism.app/oss/faq
#
# The image is the "ce" build, the Community Edition upstream distributes under
# the AGPL; the unsuffixed Docker Hub tags carry their Plus License. Paths are
# relative to ~/selfhost/photoprism/, so one file works on all three systems.
# The database is a named volume because MariaDB chowns its data directory to a
# uid Docker Desktop cannot grant on a home bind mount. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: photoprism-db
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DATABASE: photoprism
      MARIADB_USER: photoprism
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - photoprism-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  photoprism:
    image: photoprism/photoprism:260728-ce@sha256:15deeb6cc6c31f043625579a29a0e26f5f7b328441fc3945a7a0b7e4b54c0a18
    container_name: photoprism
    restart: unless-stopped
    # Relaxed as upstream's example does, for the tools the indexer runs.
    security_opt:
      - seccomp:unconfined
      - apparmor:unconfined
    working_dir: /photoprism
    environment:
      PHOTOPRISM_ADMIN_USER: "admin"
      PHOTOPRISM_ADMIN_PASSWORD: "${PHOTOPRISM_ADMIN_PASSWORD}"
      PHOTOPRISM_AUTH_MODE: "password"
      PHOTOPRISM_SITE_URL: "${PHOTOPRISM_SITE_URL}"
      PHOTOPRISM_DISABLE_TLS: "true"
      PHOTOPRISM_DEFAULT_TLS: "false"
      # Nothing is installed on first start: the container downloads nothing.
      PHOTOPRISM_INIT: ""
      PHOTOPRISM_DISABLE_MCP: "true"
      PHOTOPRISM_BACKUP_DATABASE: "true"
      PHOTOPRISM_DATABASE_DRIVER: "mysql"
      PHOTOPRISM_DATABASE_SERVER: "mariadb:3306"
      PHOTOPRISM_DATABASE_NAME: "photoprism"
      PHOTOPRISM_DATABASE_USER: "photoprism"
      PHOTOPRISM_DATABASE_PASSWORD: "${DB_PASSWORD}"
    volumes:
      # The library, a bind mount so the pictures stay visible in Finder.
      - ./originals:/photoprism/originals
      - ./storage:/photoprism/storage
    ports:
      # Loopback only: no other device on the wifi reaches 8164.
      - "127.0.0.1:8164:2342"
    depends_on:
      mariadb:
        condition: service_healthy

volumes:
  photoprism-dbdata:
EOF
cd ~/selfhost/photoprism && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, one named volume.

## 6. Nothing is public

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

8164 is bound to 127.0.0.1, this computer only. No phone, no laptop on the same wifi, nobody on
the internet. For a photo library that is the trade: every picture stays here, and so does every
way of looking at them. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/photoprism/compose.yml
```

Assert: that prints `1`, the one published port `- "127.0.0.1:8164:2342"`. MariaDB publishes no
host port at all.

## 7. Start and verify

The first start creates the schema and the superadmin account. The image is about a gigabyte.

```bash
cd ~/selfhost/photoprism
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8164/api/v1/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8164/api/v1/status
curl -sS -o /dev/null -w '%{http_code}\n' 'http://localhost:8164/api/v1/photos?count=1'
curl -sS http://localhost:8164/ | grep -c '<title>PhotoPrism</title>'
```

Assert all four, printing what you received. The loop ends on `200`, the status body is exactly
`{"status":"operational"}`, the unauthenticated search prints `401` and the grep prints `1`. That
`401` is the security assert: `PHOTOPRISM_AUTH_MODE` is `password`, not `public`. If any of the
four misses, stop, run `docker compose logs --tail 40 photoprism` and
`docker compose logs --tail 20 mariadb`, and name the cause: a database that never reports healthy
points at step 4, where an empty `DB_PASSWORD` leaves MariaDB refusing to start. For
`port is already allocated`, find what holds 8164 with `lsof -nP -iTCP:8164 -sTCP:LISTEN`, or
`netstat -ano | findstr :8164` on Windows. A running container is not success.

The first screen at http://localhost:8164 is a sign-in card with a `Name` field, a `Password`
field and a `Sign in` button.

STOP: tell the user to open http://localhost:8164 and sign in as `admin` with the password they
read themselves using `grep PHOTOPRISM_ADMIN_PASSWORD ~/selfhost/photoprism/.env`, and wait.
Do not continue until they confirm. Tell them to put it in their password manager; it changes
in Settings, then Account.

## 8. First backup and restore

Two artifacts, not interchangeable. The dump is the index: albums, labels, faces, places and where
every file is. The archive is the configuration and sidecar YAML. Neither holds a photograph.

```bash
cd ~/selfhost/photoprism
docker compose exec -T mariadb sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/photoprism/backups/photoprism-db-$(date +%F).sql.gz
tar --exclude='storage/cache' -C ~/selfhost/photoprism -czf ~/selfhost/photoprism/backups/photoprism-config-$(date +%F).tar.gz compose.yml .env storage
ls -lh ~/selfhost/photoprism/backups/
```

Assert: both exist, both are non-empty, both sizes printed. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or an external drive, copy both archives there with `cp`, then the whole `originals` folder,
which is the photographs. In Git Bash a Windows drive is `/d/Backups`, not `D:\Backups`. Assert:
the user confirms all three are there. If not, say plainly that this install has no backup.

To restore, in this order: `cd ~/selfhost/photoprism`, untar the config archive there first so
.env is back before any container starts, because MariaDB takes `DB_PASSWORD` from it the moment
it initialises an empty volume. Then `docker compose down -v`, which drops the old volume,
`docker compose up -d mariadb`, wait 30 seconds for healthy, pipe `gunzip -c` on the
`.sql.gz` into
`docker compose exec -T mariadb sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
copy `originals` back, then `docker compose up -d`. The dump alone rebuilds an index of files that
are gone.

## 9. Updating later

Releases are datestamped at https://github.com/photoprism/photoprism/releases, and the AGPL image
carries the `-ce` suffix on Docker Hub. Take both backups first, then edit the photoprism image
line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/photoprism
docker compose pull
docker compose up -d
docker compose logs --tail 40 photoprism
```

Watch that log until it settles, then re-run step 7's status check. Upstream does not backport
fixes to older datestamps, so an install left alone updates in one jump.

## 10. What will probably go wrong

I dragged a folder of photographs into ~/selfhost/photoprism/originals, reloaded the browser, and
got an empty library. The mount was fine. PhotoPrism does not watch that folder: its automatic
index fires only for files arriving over WebDAV, so anything copied in by hand sits unseen until
somebody runs `docker compose exec -T photoprism photoprism index`. The other surprise: Docker
Desktop does not always start with the session, so after a reboot nothing answers on 8164 until
`docker compose up -d` runs.

## 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 8164 to 0.0.0.0 so a phone can reach the library. That puts every photograph on
  every network the user joins.
compose.local.ymlthe services, pinned · local layout73 lines

authored from upstream docs, never pasted · 3,079 bytes

# PhotoPrism · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose .. https://docs.photoprism.app/getting-started/docker-compose/
#   config options .. https://docs.photoprism.app/getting-started/config-options/
#   open source faq . https://www.photoprism.app/oss/faq
#
# The image is the "ce" build, the Community Edition upstream distributes under
# the AGPL; the unsuffixed Docker Hub tags carry their Plus License. Paths are
# relative to ~/selfhost/photoprism/, so one file works on all three systems.
# The database is a named volume because MariaDB chowns its data directory to a
# uid Docker Desktop cannot grant on a home bind mount. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: photoprism-db
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DATABASE: photoprism
      MARIADB_USER: photoprism
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - photoprism-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  photoprism:
    image: photoprism/photoprism:260728-ce@sha256:15deeb6cc6c31f043625579a29a0e26f5f7b328441fc3945a7a0b7e4b54c0a18
    container_name: photoprism
    restart: unless-stopped
    # Relaxed as upstream's example does, for the tools the indexer runs.
    security_opt:
      - seccomp:unconfined
      - apparmor:unconfined
    working_dir: /photoprism
    environment:
      PHOTOPRISM_ADMIN_USER: "admin"
      PHOTOPRISM_ADMIN_PASSWORD: "${PHOTOPRISM_ADMIN_PASSWORD}"
      PHOTOPRISM_AUTH_MODE: "password"
      PHOTOPRISM_SITE_URL: "${PHOTOPRISM_SITE_URL}"
      PHOTOPRISM_DISABLE_TLS: "true"
      PHOTOPRISM_DEFAULT_TLS: "false"
      # Nothing is installed on first start: the container downloads nothing.
      PHOTOPRISM_INIT: ""
      PHOTOPRISM_DISABLE_MCP: "true"
      PHOTOPRISM_BACKUP_DATABASE: "true"
      PHOTOPRISM_DATABASE_DRIVER: "mysql"
      PHOTOPRISM_DATABASE_SERVER: "mariadb:3306"
      PHOTOPRISM_DATABASE_NAME: "photoprism"
      PHOTOPRISM_DATABASE_USER: "photoprism"
      PHOTOPRISM_DATABASE_PASSWORD: "${DB_PASSWORD}"
    volumes:
      # The library, a bind mount so the pictures stay visible in Finder.
      - ./originals:/photoprism/originals
      - ./storage:/photoprism/storage
    ports:
      # Loopback only: no other device on the wifi reaches 8164.
      - "127.0.0.1:8164:2342"
    depends_on:
      mariadb:
        condition: service_healthy

volumes:
  photoprism-dbdata:

agent-readable mirror: /self-host/adobe-lightroom.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, pinned82 lines

authored from upstream docs, never pasted · 3,724 bytes

# PhotoPrism · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose .. https://docs.photoprism.app/getting-started/docker-compose/
#   config options .. https://docs.photoprism.app/getting-started/config-options/
#   behind a proxy .. https://docs.photoprism.app/getting-started/proxies/traefik/
#   open source faq . https://www.photoprism.app/oss/faq
#
# Two services: PhotoPrism and the MariaDB holding the index. The image is the
# "ce" build, which upstream describes as the Community Edition distributed
# under the AGPL; the unsuffixed Docker Hub tags carry their Plus License.
# MariaDB 11.8 is the current long-term release, above the 10.5.12 floor
# upstream states. PHOTOPRISM_INIT is empty and DEFAULT_TLS false, so the
# container installs nothing and generates no certificate at start-up. Every
# ${...} comes from /srv/photoprism/.env, mode 600, which Compose reads and
# never mounts. Digests read 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mariadb:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: photoprism-db
    restart: unless-stopped
    command: --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DATABASE: photoprism
      MARIADB_USER: photoprism
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - /srv/photoprism/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  photoprism:
    image: photoprism/photoprism:260728-ce@sha256:15deeb6cc6c31f043625579a29a0e26f5f7b328441fc3945a7a0b7e4b54c0a18
    container_name: photoprism
    restart: unless-stopped
    # Relaxed as upstream's example does, for the tools the indexer runs.
    security_opt:
      - seccomp:unconfined
      - apparmor:unconfined
    working_dir: /photoprism
    environment:
      PHOTOPRISM_ADMIN_USER: "admin"
      PHOTOPRISM_ADMIN_PASSWORD: "${PHOTOPRISM_ADMIN_PASSWORD}"
      PHOTOPRISM_AUTH_MODE: "password"
      # Caddy reaches this over the Docker bridge, inside the proxy range
      # PhotoPrism trusts by default.
      PHOTOPRISM_SITE_URL: "${PHOTOPRISM_SITE_URL}"
      PHOTOPRISM_SITE_CAPTION: ""
      PHOTOPRISM_DISABLE_TLS: "true"
      PHOTOPRISM_DEFAULT_TLS: "false"
      # Nothing is installed on first start: the container downloads nothing.
      PHOTOPRISM_INIT: ""
      PHOTOPRISM_DISABLE_MCP: "true"
      PHOTOPRISM_BACKUP_DATABASE: "true"
      PHOTOPRISM_DATABASE_DRIVER: "mysql"
      PHOTOPRISM_DATABASE_SERVER: "mariadb:3306"
      PHOTOPRISM_DATABASE_NAME: "photoprism"
      PHOTOPRISM_DATABASE_USER: "photoprism"
      PHOTOPRISM_DATABASE_PASSWORD: "${DB_PASSWORD}"
      # Drops to the login user after start-up, so the photographs belong
      # to a person rather than to root.
      PHOTOPRISM_UID: "${PHOTOPRISM_UID}"
      PHOTOPRISM_GID: "${PHOTOPRISM_GID}"
    volumes:
      # The library: everything indexed lives here.
      - /srv/photoprism/originals:/photoprism/originals
      # Cache, sidecar YAML and the nightly dump.
      - /srv/photoprism/storage:/photoprism/storage
    ports:
      # Loopback only: the host's Caddy alone reaches 8164.
      - "127.0.0.1:8164:2342"
    depends_on:
      mariadb:
        condition: service_healthy
Caddyfilethe hostname and TLS25 lines

authored from upstream docs, never pasted · 919 bytes

# PhotoPrism · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.photoprism.app/getting-started/proxies/traefik/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# PHOTOPRISM_SITE_URL in .env, and the two have to say the same thing.

<DOMAIN> {
	# No `encode`: PhotoPrism compresses its own API responses, and JPEG,
	# HEIC and video do not compress twice.

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8164 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8164
}
install.shthe same install, no agent177 lines

authored from upstream docs, never pasted · 8,630 bytes

#!/usr/bin/env bash
# PhotoPrism · 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.photoprism.app/getting-started/docker-compose/
#   https://docs.photoprism.app/getting-started/config-options/
#   https://docs.photoprism.app/getting-started/proxies/traefik/
#   https://www.photoprism.app/oss/faq
#
# The image is the "ce" build, which upstream describes as the Community Edition
# distributed under the AGPL, pinned by tag and digest.
#
# Three secrets are generated here, on this machine: the initial admin password,
# the photoprism database user's password and the MariaDB root password. All
# three go into /srv/photoprism/.env with mode 600 and none is ever printed.
#
# DOMAIN_HOST becomes PHOTOPRISM_SITE_URL, the address PhotoPrism writes into
# every link and share it generates.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/photoprism}"
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"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 3072 ] || die "only ${avail_mb} MB available; upstream's floor is 3 GB physical, which shows as less than 3072 MB available, so plan on a 4 GB machine"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB, and a photo library wants more"

swap_mb="$(free -m | awk '/^Swap:/ {print $2}')"
[ "$swap_mb" -ge 1 ] || echo "install.sh: no swap configured. Upstream asks for 4 GB; the indexer spikes on large files."

uid="$(id -u)"
case "$uid" in
	0|33|5[0-9]|6[0-9]|7[0-9]|8[0-9]|9[0-9]|5[0-9][0-9]|600|9[0-9][0-9]|1[01][0-9][0-9]|12[0-4][0-9]|1250|20[0-9][0-9]|2100) ;;
	*) die "uid ${uid} is outside the ranges upstream supports for PHOTOPRISM_UID (0, 33, 50-99, 500-600, 900-1250, 2000-2100)" ;;
esac

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 ----------------------------------------------------
#
# mariadb stays root-owned at 700: the MariaDB image chowns its own data
# directory and refuses one somebody claimed first. originals is the library;
# storage is cache, sidecar YAML and the nightly dump PhotoPrism writes itself.

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

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64: two travel inside connection strings and the third is
# typed into a browser once. Read the admin password later with
#   sudo grep PHOTOPRISM_ADMIN_PASSWORD /srv/photoprism/.env
# It is only read by PhotoPrism when the superadmin account is created on the
# first start; after that, change it in Settings, then Account.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		PHOTOPRISM_SITE_URL=https://${DOMAIN_HOST}/
		PHOTOPRISM_ADMIN_PASSWORD=$(openssl rand -hex 24)
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	printf 'PHOTOPRISM_UID=%s\nPHOTOPRISM_GID=%s\n' "$(id -u)" "$(id -g)" >> "$APP_DIR/.env"
	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-photoprism"
	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 neither 8164 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8164 and 3306 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 -------------------------------------------------------------
#
# The first start creates the schema and the superadmin account. The image is
# about a gigabyte, so the pull is the slow part.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/api/v1/status" | grep -q '"status":"operational"' \
	|| die "/api/v1/status answered 200 without status operational. Check: docker compose logs --tail 40 photoprism"

# The API must refuse an unauthenticated call. A 200 here would mean the library
# is set to public and every photograph is visible to anyone who finds the name.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/photos?count=1" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."

curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>PhotoPrism</title>' \
	|| die "the site root did not serve the PhotoPrism app shell. Check: docker compose logs --tail 40 photoprism"

# --- 7. The first backup, before day one ends --------------------------------
#
# Taken now, with an empty library, so the restore path is proved before there
# is anything to lose. The archive carries .env, the storage tree and the live
# Caddy site block rather than the <DOMAIN> template.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mariadb sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/photoprism-db-${STAMP}.sql.gz"
sudo tar --exclude='storage/cache' -czf "$APP_DIR/backups/photoprism-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env storage -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/photoprism-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/photoprism-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	PhotoPrism is answering at https://${DOMAIN_HOST}

	  1. Sign in as admin. Read the password once with
	       sudo grep PHOTOPRISM_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here. Then
	     change it inside PhotoPrism, in Settings, then Account: editing that
	     line afterwards does nothing, because it is only read when the account
	     is created.
	  2. Put photographs in $APP_DIR/originals, then index them:
	       cd $APP_DIR && docker compose exec -T photoprism photoprism index
	     PhotoPrism does not watch that directory, so nothing appears in the
	     library until an index run has seen it.
	  3. This install organises and searches photographs. It does not develop
	     them: no exposure slider, no masking, no presets. Keep whatever you
	     edit with.
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive holding .env, the storage tree and the live Caddy site block.
	     Neither one contains a photograph. Copy both off the box tonight, and
	     the library with them:
	       scp vps:$APP_DIR/backups/* ~/backups/photoprism/
	       rsync -a vps:$APP_DIR/originals/ ~/backups/photoprism/originals/

DONE

What 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 Adobe Lightroom.

  • It does not edit photographs. PhotoPrism indexes, searches and shows them, and there is no exposure slider, no masking, no presets and no history stack. Lightroom's develop module is half of what the subscription buys and none of what this replaces, so keep a raw developer on the desktop and let PhotoPrism own the catalogue.
  • The free edition has a boundary, and it is a commercial decision rather than a technical one. This install pins the -ce image, which upstream distributes under the AGPL; the unsuffixed Docker Hub images carry PhotoPrism's own Plus License, and their editions page puts two-factor login, single sign-on, session monitoring, the user-management screen and video transcoding behind a membership. Read that page before a household plans around a feature.
  • Three things to keep, not one. The database knows where every picture is, the storage directory holds the sidecar files and the thumbnails, and the originals directory is the photographs. Restore any one of them without the others and you have an index of files that are gone, or a pile of files nothing can find.
  • Nothing appears until you index. PhotoPrism only watches for files that arrive over WebDAV, so a folder you copy in by hand is invisible to the library until an index run has seen it, and on a real archive that run is measured in hours rather than minutes.
  • A real machine, not the cheapest one. Upstream's floor is 3 GB of memory plus 4 GB of swap, the image alone is about a gigabyte, and under 1 GB PhotoPrism turns TensorFlow and RAW conversion off by itself instead of failing loudly.

Where this came from

“you can alternatively use the "ce" tag instead of "latest" to get a slightly smaller Docker image distributed under the AGPL.”

  • The Community Edition is released under the AGPL, while the standard Docker images on Docker Hub are distributed under PhotoPrism's own Plus License, which is why this install pins the -ce image tag. source
  • Upstream asks for at least 2 cores, 3 GB of physical memory and a 64-bit operating system, with at least 4 GB of swap, because the indexer temporarily needs more memory to process large files. source
  • PHOTOPRISM_ADMIN_PASSWORD sets the initial password of the superadmin account created on first startup, and PHOTOPRISM_AUTH_MODE chooses between public and password authentication. source
  • Behind a reverse proxy that terminates TLS, PhotoPrism takes the external address in PHOTOPRISM_SITE_URL and has PHOTOPRISM_DISABLE_TLS set to true so it serves plain HTTP to the proxy. source
  • When less than 1 GB of system memory is detected PhotoPrism logs that TensorFlow as well as indexing and conversion of RAW images have been disabled automatically, so a small box silently loses the search this install is for. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Adobe Lightroom?

    Not Adobe Lightroom 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 PhotoPrism. A photo library that indexes what is in the picture, so twenty years of files become something you can search. The install is one evening: 2 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 120 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 Adobe Lightroom?

    PhotoPrism. A photo library that indexes what is in the picture, so twenty years of files become something you can search. The closest thing to Lightroom's catalogue half that you can run on your own machine. It indexes a folder tree you already have, recognises faces and subjects on your own CPU rather than in somebody's datacentre, reads RAW files, puts everything on a map and a timeline, and answers to a search box that understands what is in the picture. Be clear about what it is not: it will not develop a photograph. There is no exposure slider, no masking, no preset. If the develop module is what you open Lightroom for, this replaces the half of the subscription that turned into a storage bill and leaves the other half exactly where it is. Also evaluated and ranked below it: Immich. The prompt on this page installs PhotoPrism only. PhotoPrism 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 Adobe Lightroom?

    3072 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. PhotoPrism itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Adobe Lightroom Lightroom (1TB), annual plan, $14.99/mo — $179.88 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the PhotoPrism install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run PhotoPrism 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 PhotoPrism 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: Only this computer can open the library, so the phone that took the pictures cannot upload to it and nobody you would have sent an album to can reach it. 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-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.