Every PhotoPrism Docker Compose guide you find still ships a MariaDB container, because until the October 2026 release that was the only serious choice. That release added experimental PostgreSQL support, a photoprism database transfer command to move an existing index across, and a Model Context Protocol (MCP) endpoint that lets an AI agent search your library with your own permissions. MCP is switched on by default, so it is worth knowing what it exposes before you open the port.
We built the whole stack on a small homelab VM called nas01. You get the exact compose.yaml for PhotoPrism on PostgreSQL 18, Nginx with a certbot certificate in front, a real MariaDB to PostgreSQL migration, and Claude Code connected to the MCP endpoint with the tool list it returned. The later steps cover locking MCP down, updating the stack and a short PhotoPrism vs Immich comparison. I ran through this with PhotoPrism 261029 on Ubuntu 24.04 in November 2026, and it works end to end.
How the PhotoPrism stack fits together
Everything runs on one host, and only Nginx listens on the network. That single decision shapes most of the Compose file below.

PhotoPrism publishes its port on 127.0.0.1 only, and PostgreSQL has no published port at all. That matters more than it looks, because Docker writes its own iptables rules and a port published on 0.0.0.0 skips UFW entirely. Do you need the AI agent box at all? If nothing will ever talk to the library over MCP, follow Steps 1 to 5 and then turn the endpoint off as shown in Step 7.
What the October 2026 release changes
The 261029 release notes bundle four changes that affect how you deploy PhotoPrism, and the first one comes with limits you should read before you commit a library to it:
- PostgreSQL as the index database (16 or newer per the docs, tested on 18)
- PostgreSQL support marked experimental, schema changes may land without migrations
photoprism database transferbetween MariaDB, SQLite and PostgreSQL- A read-only MCP server at
/api/v1/mcp, on by default - OAuth sign-in for connecting apps and remote MCP clients
The experimental label is the one to take seriously. The PostgreSQL page in the PhotoPrism docs says plainly that you should not run a library you depend on against it yet, and that nobody has compared its performance with MariaDB. We would use it today on a new library where the originals and the YAML sidecar files are the real backup, and keep an existing family archive on MariaDB until the schema settles. File names also have to be valid UTF-8 on PostgreSQL, so a folder of old camera files with odd encodings may not index at all.
Prerequisites and sizing
PhotoPrism asks for at least 2 CPU cores, 3 GB of RAM and 4 GB of swap, and it warns against a hard memory limit on the container because the indexer spikes on large RAW files. Our lab box was a little above that floor:
- Ubuntu 24.04 LTS VM, 4 vCPU, 6 GB RAM, 4 GB swap file
- 40 GB disk, with
/opt/photoprismon the same SSD - Docker Engine with the Compose plugin
- A DNS A record for
photos.example.compointing at the server - Ports 80 and 443 reachable from the internet for the certificate check
If Docker is not installed yet, our guide to installing Docker CE on Ubuntu sets up the official repository and the Compose plugin, and the same steps apply on 24.04. Add your user to the docker group with sudo usermod -aG docker $USER and log in again, so the commands below run without sudo. Membership in that group is root-equivalent on the host, so add only accounts you trust. After indexing a 72-file sample library, the PhotoPrism container sat at 194 MiB and PostgreSQL at 47 MiB, so RAM is driven by indexing and thumbnails, not by the database. For a real library of 50,000 photos with face detection we would give the VM 4 GB of RAM or more on top of the 4 GB of swap, and the thumbnail cache in storage usually ends up larger than the database.
Step 1: Create the project folder and the secrets file
PhotoPrism needs three folders: originals for your photos, storage for the cache, sidecar files and backups, and database for the PostgreSQL data. We keep them on a local disk as plain bind mounts, because the docs warn that named volumes can lose data when a container is recreated. Create them and hand the tree to your user:
sudo mkdir -p /opt/photoprism/{originals,storage,database}
sudo chown -R $USER:$USER /opt/photoprism
cd /opt/photoprism
The passwords and the public URL go into a .env file next to the Compose file, so the YAML never holds a secret and the PostgreSQL password is typed exactly once. Change photos.example.com to your own domain before you run this, because PhotoPrism builds every link and the MCP resource URL from it:
echo "PHOTOPRISM_SITE_URL=https://photos.example.com/" > .env
echo "PHOTOPRISM_ADMIN_PASSWORD=$(openssl rand -base64 18)" >> .env
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
chmod 600 .env
Read the admin password back with grep ADMIN .env and store it in your password manager, since it is the only way into the web UI on first start. The hex PostgreSQL password avoids $ characters on purpose: Compose treats a bare $ in a value as a variable, and the docs list that as the usual cause of a wrong-password error.
Step 2: Write the PhotoPrism docker compose yml for PostgreSQL
This is the exact file that ran on nas01. It is the official example trimmed to the settings that matter, with the MariaDB service swapped for postgres:18-alpine. Open the file:
vim /opt/photoprism/compose.yaml
Paste in the two services:
services:
photoprism:
image: photoprism/photoprism:latest
restart: unless-stopped
stop_grace_period: 15s
depends_on:
- postgres
security_opt:
- seccomp:unconfined
- apparmor:unconfined
ports:
- "127.0.0.1:2342:2342"
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_TRUSTED_PROXY: "172.16.0.0/12"
PHOTOPRISM_LOG_LEVEL: "info"
PHOTOPRISM_DISABLE_MCP: "false"
PHOTOPRISM_SIDECAR_YAML: "true"
PHOTOPRISM_BACKUP_ALBUMS: "true"
PHOTOPRISM_BACKUP_DATABASE: "true"
PHOTOPRISM_BACKUP_SCHEDULE: "daily"
PHOTOPRISM_ORIGINALS_LIMIT: 5000
PHOTOPRISM_UPLOAD_LIMIT: 5000
PHOTOPRISM_DATABASE_DRIVER: "postgres"
PHOTOPRISM_DATABASE_SERVER: "postgres:5432"
PHOTOPRISM_DATABASE_NAME: "photoprism"
PHOTOPRISM_DATABASE_USER: "photoprism"
PHOTOPRISM_DATABASE_PASSWORD: "${POSTGRES_PASSWORD}"
working_dir: "/photoprism"
volumes:
- "./originals:/photoprism/originals"
- "./storage:/photoprism/storage"
postgres:
image: postgres:18-alpine
restart: unless-stopped
stop_grace_period: 15s
volumes:
- "./database:/var/lib/postgresql"
environment:
POSTGRES_DB: "photoprism"
POSTGRES_USER: "photoprism"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=und"
PGDATA: "/var/lib/postgresql/18/docker"
A few lines in that file do more than they appear to. PhotoPrism is published on 127.0.0.1:2342 with TLS disabled, because Nginx terminates HTTPS in Step 4 and nothing else should reach the app directly. PHOTOPRISM_TRUSTED_PROXY covers the Docker bridge range, so PhotoPrism believes the client address and scheme Nginx forwards. On the database side, PostgreSQL 18 images moved their data folder, so the mount is /var/lib/postgresql (not the old /data path) and PGDATA pins the versioned subfolder. The ICU locale und only applies on the first initialization and makes name sorting match MariaDB.
Check that Compose parses the file and resolves the variables from .env before starting anything:
docker compose config --quiet && echo CONFIG-OK
Silence followed by CONFIG-OK means the YAML is valid. An indentation slip prints the line number instead, which is far easier to fix now than after a container has half started. The :latest tag pulled the 261029 build in our lab. If you prefer to upgrade on your own schedule while PostgreSQL support is experimental, pin photoprism/photoprism:261029, which exists on Docker Hub.
Step 3: Start the stack and index the library
Copy some photos into /opt/photoprism/originals first. We used the sample images from the PhotoPrism source repository, 72 files including JPEG, HEIC, PNG, GIF and a few MP4 clips. Then pull the images and bring both services up:
docker compose pull
docker compose up -d
Compose creates the network and starts PostgreSQL before PhotoPrism, because of the depends_on line:
Network photoprism_default Created
Container photoprism-postgres-1 Creating
Container photoprism-postgres-1 Created
Container photoprism-photoprism-1 Creating
Container photoprism-photoprism-1 Created
Container photoprism-postgres-1 Starting
Container photoprism-postgres-1 Started
Container photoprism-photoprism-1 Starting
Container photoprism-photoprism-1 Started
Give PhotoPrism about 30 seconds to create its tables, then look at the end of its log. The line you want is the experimental PostgreSQL notice followed by the listener:
docker compose logs photoprism | tail -12
Our first start on nas01 printed this:
photoprism-1 | time="2026-11-03T10:16:27Z" level=info msg="config: using case-sensitive lookups for storage (detected)"
photoprism-1 | time="2026-11-03T10:16:27Z" level=info msg="config: using case-sensitive lookups for originals (detected)"
photoprism-1 | time="2026-11-03T10:16:27Z" level=info msg="config: support for PostgreSQL is experimental"
photoprism-1 | time="2026-11-03T10:16:28Z" level=info msg="faces: loading yunet on the cpu"
photoprism-1 | time="2026-11-03T10:16:28Z" level=info msg="faces: loading sface on the cpu"
photoprism-1 | time="2026-11-03T10:16:28Z" level=info msg="Become a member today, support our mission and enjoy our member benefits! 💎"
photoprism-1 | time="2026-11-03T10:16:28Z" level=info msg="Visit https://www.photoprism.app/membership/ to learn more."
photoprism-1 | time="2026-11-03T10:16:29Z" level=info msg="server: started as pid 746"
photoprism-1 | time="2026-11-03T10:16:29Z" level=info msg="webdav: shared /originals/"
photoprism-1 | time="2026-11-03T10:16:29Z" level=info msg="webdav: shared /import/"
photoprism-1 | time="2026-11-03T10:16:29Z" level=info msg="server: disabled auto tls"
photoprism-1 | time="2026-11-03T10:16:29Z" level=info msg="server: listening on 0.0.0.0:2342 [10.640957ms]"
The 0.0.0.0:2342 there is inside the container. On the host, Docker still only binds loopback, which you can confirm in the PORTS column of docker compose ps below. If the log instead shows a connection error, PhotoPrism started before PostgreSQL accepted connections, and a docker compose restart photoprism clears it. Now run the first index, which also creates thumbnails and runs face and label detection:
docker compose exec photoprism photoprism index --cleanup
On four vCPUs the 72 sample files took under 12 seconds, and the last lines report the totals:
time="2026-11-03T10:17:30Z" level=info msg="faces: 0 of the 10 new markers required for clustering clear the 112 px size and 85 score thresholds"
time="2026-11-03T10:17:30Z" level=info msg="index: updated 70 files [11.188185327s]"
time="2026-11-03T10:17:30Z" level=info msg="cleanup: removed 0 index entries and deleted 0 sidecar files [524.206µs]"
time="2026-11-03T10:17:30Z" level=info msg="cleanup: searching for orphaned cache files"
time="2026-11-03T10:17:30Z" level=info msg="cleanup: deleted 0 files from cache [2.073902ms]"
time="2026-11-03T10:17:30Z" level=info msg="library: indexed 72 files in 11.557608031s"
To prove the index really lives in PostgreSQL and not in a fallback SQLite file, ask the database directly. The screenshot below shows the container status, the PhotoPrism version and the photo count from psql in one pass.

Sixty-two photo rows from 72 files is expected: PhotoPrism groups duplicates and the related files of one picture into a single photo, and the transfer in Step 8 later listed four duplicate rows for this set. Our psql session also reported version 18.6 and the ICU locale on the database:
docker compose exec postgres psql -U photoprism -c "SELECT datname, datlocprovider, datlocale FROM pg_database WHERE datname = 'photoprism';"
An i provider with locale und means POSTGRES_INITDB_ARGS took effect. A c or d provider means the data folder already existed when the container first started, so the setting was ignored:
datname | datlocprovider | datlocale
------------+----------------+-----------
photoprism | i | und
(1 row)
That locale only changes the sort order of names and titles in lists, but fixing it later means a backup, an empty data folder and a restore, so it is worth checking now.
Step 4: Put Nginx and Let’s Encrypt in front of PhotoPrism
The PhotoPrism docs are blunt about this. Outside your home network it must sit behind an HTTPS proxy, or passwords and photos cross the wire in clear text. MCP tokens make it more pressing, because an app password with the photos scope reads your whole library. Install Nginx and certbot from the Ubuntu archive:
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
Create a server block for the photo site:
sudo vim /etc/nginx/sites-available/photoprism
The WebSocket headers matter here. PhotoPrism pushes indexing progress and notifications to the browser over a WebSocket, and Nginx drops that connection unless it forwards the Upgrade header. The 500 MB body limit is what lets browser uploads of videos through:
server {
listen 80;
listen [::]:80;
server_name photos.example.com;
client_max_body_size 500M;
location / {
proxy_pass http://127.0.0.1:2342;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Enable the site, test the syntax and reload:
sudo ln -s /etc/nginx/sites-available/photoprism /etc/nginx/sites-enabled/photoprism
sudo nginx -t
sudo systemctl reload nginx
Open the firewall for SSH and web traffic before you enable UFW, so the rule for your own session exists first. The OpenSSH profile only covers port 22, so if your SSH daemon listens elsewhere, allow that port instead or you lock yourself out. Port 2342 stays closed because nothing outside the host needs it:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
Now request the certificate. Certbot proves you own the name with the HTTP-01 challenge on port 80, which works with any DNS provider, then rewrites the server block for TLS and adds the redirect:
sudo certbot --nginx -d photos.example.com --non-interactive --agree-tos --redirect -m [email protected]
Our nas01 VM sits on a private LAN with no public port 80, so we ran this exact command against Pebble, the small ACME test server that Let’s Encrypt publishes, by adding its --server URL. That is why the expiry date below reads 2031. A real Let’s Encrypt certificate from the default profile is valid for 90 days, and the certbot timer renews it:
Account registered.
Requesting a certificate for photos.example.com
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/photos.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/photos.example.com/privkey.pem
This certificate expires on 2031-11-03.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
Deploying certificate
Successfully deployed certificate for photos.example.com to /etc/nginx/sites-enabled/photoprism
Congratulations! You have successfully enabled HTTPS on https://photos.example.com
Confirm renewal works with sudo certbot renew --dry-run, which ended with “all simulated renewals succeeded” against the same test server in our lab.
Alternative: DNS-01 for a server behind NAT
If port 80 cannot reach your server from the internet, prove ownership through a DNS TXT record instead. Certbot has a plugin per DNS host in the Ubuntu archive, for example:
- Cloudflare:
python3-certbot-dns-cloudflare - Route 53:
python3-certbot-dns-route53 - DigitalOcean:
python3-certbot-dns-digitalocean - Your own BIND server:
python3-certbot-dns-rfc2136
Each plugin reads an API credential file. Combine it with the Nginx installer, for example sudo certbot run -a dns-cloudflare --dns-cloudflare-credentials ~/.secrets/cloudflare.ini -i nginx -d photos.example.com, and certbot validates through DNS but still writes the TLS lines into the server block from this step. Issuing the certificate this way does not need ports 80 and 443 open to the internet, though browsers and agents still reach the site on 443 over your LAN or VPN. We did not run it in the lab, so follow the plugin documentation for the credential file format.
Step 5: Sign in and check the library
Open https://photos.example.com in a browser, and PhotoPrism redirects you to its sign-in page.

- Type
adminin the Name field. - Paste the generated password from
.envinto Password. - Click Sign in.
The admin password in .env is only read when the account is first created. Changing it in the file later does nothing, so change it in the UI or with docker compose exec photoprism photoprism passwd admin instead. After sign-in you land on the library, with the photos from the first index:

Videos show their duration and codec on the card, and GIFs are flagged in the corner, which is a quick sign that FFmpeg inside the image works. The Labels page is the better test of the AI side, since every label there came from image classification during the index:

The core install is done at this point, with PhotoPrism on PostgreSQL behind HTTPS and a working index. Everything after this is optional. If you never plan to connect an AI agent, skip Step 6 but still apply the switch-off in Step 7, because MCP is on by default. Skip Step 8 if you are starting fresh.
Step 6: Connect a local agent to the PhotoPrism MCP server
The PhotoPrism MCP server answers at /api/v1/mcp over Streamable HTTP. It offers six reference tools (config options, search filters, CLI commands, file formats, metadata fields and thumbnail sizes) and six library tools that search and browse photos, albums, people and labels with the permissions of the account behind the token. Nothing in it can change or delete a photo. A client needs a token whose scope includes mcp plus each library resource it should read. Create an app password for the admin account:
cd /opt/photoprism
docker compose exec photoprism photoprism auth add -n "Claude Code" -s "mcp read photos albums people labels" admin
The password is printed once, so copy it straight into your agent config. We replaced ours with x characters:
PLEASE COPY THE FOLLOWING RANDOMLY GENERATED APP PASSWORD AND KEEP IT IN A SAFE PLACE, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:
┌─────────────────────────────┬──────────────────────────────────────┐
│ App Password │ Authorization Scope │
│ xxxxxx-xxxxxx-xxxxxx-xxxxxx │ albums labels mcp people photos read │
└─────────────────────────────┴──────────────────────────────────────┘
Keep read in the scope. The docs point out that a resource scope without read also allows changes through the REST API, and leave out places unless the agent should see GPS coordinates. You can also create and revoke these passwords in the web UI:

- Open Settings and switch to the Account tab.
- Click Apps and Devices to list, add or delete app passwords.
- Use Connected Apps to see and disconnect clients that signed in with OAuth.
The password we created from the CLI showed up there with its scope and the time it was last used, which is a handy way to spot a token you forgot about.

For the agent we used Claude Code, set up as in our Claude Code install guide. Registering an MCP server and checking its health needs no Claude login, so this part works on a fresh machine. Set the endpoint and token once, then add the server:
export MCP_URL="https://photos.example.com/api/v1/mcp"
export PHOTOPRISM_TOKEN="xxxxxx-xxxxxx-xxxxxx-xxxxxx"
claude mcp add --transport http photoprism "$MCP_URL" --header "Authorization: Bearer ${PHOTOPRISM_TOKEN}"
claude mcp list
Claude Code masks the header in its own output and stores the server in ~/.claude.json for the current project. To see exactly what the agent will be offered, call tools/list yourself with curl and jq. MCP over HTTP is a session: initialize returns an Mcp-Session-Id header, and every later call sends it back:
AUTH="Authorization: Bearer ${PHOTOPRISM_TOKEN}"
CT="Content-Type: application/json"
ACCEPT="Accept: application/json, text/event-stream"
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
SID=$(curl -s -D - -o /dev/null "$MCP_URL" -H "$AUTH" -H "$CT" -H "$ACCEPT" -d "$INIT" | awk -F': ' 'tolower($1)=="mcp-session-id" {print $2}' | tr -d '\r')
curl -s "$MCP_URL" -H "$AUTH" -H "Mcp-Session-Id: $SID" -H "$CT" -H "$ACCEPT" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s "$MCP_URL" -H "$AUTH" -H "Mcp-Session-Id: $SID" -H "$CT" -H "$ACCEPT" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | sed -n 's/^data: //p' | jq -r '.result.tools[] | [.name, (.annotations.readOnlyHint|tostring)] | @tsv'
Twelve tools came back, every one flagged read-only, and Claude Code reported the server as connected.

The initialize reply named the server photoprism-mcp with version 261029-963b58c50-Linux-AMD64-Plus. Calling a library tool shows what an agent actually receives. Here is list_labels with a limit of five:
curl -s "$MCP_URL" -H "$AUTH" -H "Mcp-Session-Id: $SID" -H "$CT" -H "$ACCEPT" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_labels","arguments":{"limit":5}}}' | sed -n 's/^data: //p' | jq -r '.result.content[0].text'
The answer is compact JSON with counts and a paging hint, the same labels the web UI showed in Step 5:
{"items":[{"categories":["Animal"],"media_count":5,"name":"Cat","slug":"cat","uid":"ltnx8kyl7r3ykrd5"},{"categories":["Animal"],"media_count":4,"name":"Dog","slug":"dog","uid":"ltnx8kz2m81p8drt"},{"categories":["Animal","Wildlife"],"media_count":2,"name":"Elephant","slug":"elephant","uid":"ltnx8l0gz4vyttcb"},{"categories":["Animal"],"media_count":4,"name":"Wildlife","slug":"wildlife","uid":"ltnx8l0x7tfy1ova"},{"media_count":20,"name":"Animal","slug":"animal","uid":"ltnx8kyfuykpz51o"}],"limit":5,"offset":0,"total":10,"truncated":true,"warnings":["returned 5 of 10 matches starting at offset 0; call again with offset 5 to retrieve more"]}
A search_media call with label:animal returned titles, capture dates and place names such as “Steglitz, Berlin, Germany”, so an agent can answer “which zoo photos did I take in Berlin” without seeing a single file path. If you run your own models through Ollama, any MCP client that speaks Streamable HTTP with custom headers connects the same way.
Which accounts can use MCP in the free edition?
Only admins, in practice. The docs grant the HTTP endpoint to admins in every edition, to users and viewers in Plus and Pro, and to managers in Pro. The free Community edition cannot even create a user or viewer account, which we confirmed in the lab. Running photoprism users add -r user family refused with “additional user account roles are available with a membership to support development and maintenance”. So on a free install, the token you hand an agent belongs to an admin, and it sees private and archived photos too. Scope the token tightly and give each agent its own app password, so you can revoke one without breaking the others.
Step 7: Lock down or switch off MCP
MCP is enabled by default for every eligible account, so a fresh install answers on /api/v1/mcp whether you asked for it or not. Unauthenticated calls get 401 Unauthorized with a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource/api/v1/mcp, which is how OAuth-capable clients discover where to sign in. There are two MCP switches and one related OAuth setting, from narrow to global:
- One account:
docker compose exec photoprism photoprism users mod --mcp=false admin && docker compose restart photoprism - No OAuth app sign-in (app passwords still work):
PHOTOPRISM_OAUTH_APPS: "none" - The whole endpoint off:
PHOTOPRISM_DISABLE_MCP: "true"
The per-account switch surprised us. The docs say turning MCP off disconnects the apps of that account and signs out its other sessions, but run from the CLI it behaved differently. After users mod --mcp=false admin, the MCP column in users ls read Disabled, and an app password we created just before and had not called yet was refused with 403 straight away. An app password that had already made a call a moment earlier kept getting 200 responses, including full list_labels results, until we ran docker compose restart photoprism. We reproduced that twice. Treat a restart as part of that command, and check the result with a real call rather than the user table.
To turn MCP off for everyone, change the MCP line in compose.yaml and add the OAuth line under it:
PHOTOPRISM_DISABLE_MCP: "true"
PHOTOPRISM_OAUTH_APPS: "none"
Apply it with docker compose up -d, which recreates only the PhotoPrism container. The endpoint then returns a plain 404, and Claude Code reports that it cannot find the server.

A 404 is the answer you want here, because it means the route is gone rather than merely refusing your token. We switched it back to "false" afterwards for the rest of the lab.
Step 8: Migrate an existing MariaDB install to PostgreSQL
If you already run PhotoPrism on MariaDB, photoprism database transfer copies the index across, including users, sessions, albums and settings. We tested it on a second stack in /opt/photoprism-old built from the official MariaDB example, with the same 72 files indexed and an album called Zoo Day holding six photos. A plain index backup is not an alternative, because a MariaDB dump only restores into MariaDB. Take one anyway, since it is your way back:
cd /opt/photoprism-old
docker compose exec photoprism photoprism backup -i
The dump lands in storage/backup/mysql/ with the current date as the name, owned by root with mode 600. Next, add the same postgres service block from Step 2 to the old compose.yaml, with its own data folder (we used ./pgdata). That stack has no .env file, so replace ${POSTGRES_PASSWORD} with a literal password, or Compose substitutes an empty value and the transfer cannot log in. Leave the PHOTOPRISM_DATABASE_* lines pointing at MariaDB for now. Start only the new service, then stop PhotoPrism so nothing writes to the index during the copy:
mkdir -p pgdata
docker compose up -d postgres
docker compose stop photoprism
Run the transfer in a throwaway container. The --entrypoint option is required, because without it the command does not read the MariaDB settings of the photoprism service and transfers an empty index. Use the password you gave the new postgres service, which in our lab was still the insecure value from the upstream example. If your old stack runs PhotoPrism as a non-root user through PHOTOPRISM_UID and PHOTOPRISM_GID, add --user 1000:1000 (with your IDs) after --rm so files in storage keep the right owner.
docker compose run --rm --entrypoint /opt/photoprism/bin/photoprism photoprism database transfer --target-driver postgres --target-server postgres:5432 --target-name photoprism --target-user photoprism --target-password insecure
It migrates both schemas first and then copies table by table. Our run finished with these lines, 977 rows in under a second:
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 62 rows from photos"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 6 rows from photos_albums"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 360 rows from photos_keywords"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 53 rows from photos_labels"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 0 rows from photos_users"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 17 rows from places"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 0 rows from reactions"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 0 rows from services"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 0 rows from subjects"
time="2026-11-03T10:43:35Z" level=info msg="transfer: copied 977 rows in 903.932593ms"
time="2026-11-03T10:43:35Z" level=info msg="transfer: change the database options to the target database and restart PhotoPrism to use it"
Do what the last line says. In the old compose.yaml, set PHOTOPRISM_DATABASE_DRIVER to "postgres", PHOTOPRISM_DATABASE_SERVER to "postgres:5432", the password to the new one, and change depends_on from mariadb to postgres. Then start it again with docker compose up -d and count the rows in the new database.

All 62 photos, 43 labels and the Zoo Day album with its six photos came across, and the old admin password still signed in because the password table moved too. Leave the mariadb service running for a week as a fallback, then remove it and its database folder once you trust the new index.
Step 9: Update the PhotoPrism Docker Compose stack
A PhotoPrism docker compose update is one command, but a new release may migrate the schema on its first start, and on experimental PostgreSQL that is the moment to have a fresh backup. Take the index backup first, then pull and recreate:
cd /opt/photoprism
docker compose exec photoprism photoprism backup -i
docker compose up -d --pull always
On PostgreSQL the backup uses pg_dump from inside the PhotoPrism image and writes to storage/backup/postgres/. When no newer image exists, Compose pulls, finds nothing to change and leaves both containers running:
Image photoprism/photoprism:latest Pulling
Image postgres:18-alpine Pulling
Image photoprism/photoprism:latest Pulled
Image postgres:18-alpine Pulled
Container photoprism-photoprism-1 Running
Container photoprism-postgres-1 Running
The postgres:18-alpine tag only moves within PostgreSQL 18, so minor updates are fine to pull. It does move its Alpine base too, and a new ICU library on a database created with the ICU locale can log “collation version mismatch” warnings. The fix is REINDEX DATABASE photoprism; followed by ALTER DATABASE photoprism REFRESH COLLATION VERSION; in a psql session, or pin a tag such as 18-alpine3.24 to avoid surprises. A major upgrade such as 18 to 19 is different, because the new server cannot read the old data files. The docs give the route: run docker compose exec photoprism photoprism backup -i, start the new version with an empty data folder and the new PGDATA path, then run docker compose exec photoprism photoprism restore -i -f and restart PhotoPrism. Never just change the image tag on a full data folder.
PhotoPrism vs Immich: which one fits?
Pick Immich if phones are where your photos come from, and PhotoPrism if you already have a large archive on disk that you want to browse, tag and search. That is the split most people land on, and the details below explain why:
| PhotoPrism | Immich | |
|---|---|---|
| Mobile backup | PWA, plus third-party apps such as PhotoSync over WebDAV | Official iOS and Android apps with background backup |
| Database | MariaDB or SQLite, PostgreSQL experimental | PostgreSQL with VectorChord, required |
| Machine learning | Built into the main container (faces, labels) | Separate immich-machine-learning container |
| Official MCP server | Yes, read-only, since October 2026 | No, community projects only |
| Editions | Free Community (admin and guest roles), paid Essentials and Plus add user and viewer roles | All features free, optional purchase to support the project |
The edition row is the one that catches families. On free PhotoPrism everyone who logs in is either an admin or a guest on shared links, so a household with separate private libraries needs a paid plan, while Immich gives each person an account for free. Even then, the MCP docs only open the endpoint to user and viewer accounts on Plus and Pro, so Essentials adds the roles but not agent access for them. PhotoPrism is lighter to run, with one app container and no separate ML service, and its MCP endpoint is the only official one of the two right now. If Immich sounds like the better fit, our Immich install guide for Ubuntu covers the full stack, and the comparison of Cloudreve, Nextcloud, Seafile and Immich helps if you also need file sync. Either app runs well on the boxes in our NAS picks for a photo library, and old prints can join the library through one of the photo scanners we recommend.
What broke in the lab
These are the errors we hit on nas01 while building this, each with the cause and the fix. They are worth a search because most of them do not appear in the docs yet.
“No help topic for ‘mcp'” when running photoprism mcp serve
The MCP docs show a stdio transport started with photoprism mcp serve, handy for the MCP Inspector. In the 261029 image that command does not exist: docker compose exec photoprism photoprism mcp serve printed “No help topic for ‘mcp'” and exited with code 3, and photoprism help lists no mcp command. Use the HTTP endpoint from Step 6 instead. Stdio would only give you the reference tools anyway, since the library tools need an authenticated HTTP session.
“Error running MCP Inspector: CustomEvent is not defined”
Ubuntu 24.04 ships Node.js 18.19 in its archive, and the current MCP Inspector requires Node 22.19 or newer (the inspector package and its undici dependency both declare >=22.19.0). npx @modelcontextprotocol/inspector --cli printed EBADENGINE warnings and then this error. Install a newer Node from your usual source, or skip the Inspector and use the curl session from Step 6, which only needs curl and jq. Claude Code itself ran fine on Node 18 in the same VM.
“the target is the index database, choose another database”
We got this by running database transfer a second time after already switching the photoprism service to PostgreSQL. The command reads its source from the service settings, so source and target were the same database. The same run also warned that “the index database has 2 other connections”, because PhotoPrism was still up. Run the transfer while the service still points at MariaDB and with PhotoPrism stopped. If the target already holds data, it refuses unless you pass --force, which wipes it.
“Error: in 18+, these Docker images are configured to store database data in a format which is compatible with pg_ctlcluster”
A postgres:18-alpine container exits with this if a volume is mounted at /var/lib/postgresql/data, which is where every guide written for PostgreSQL 17 and earlier puts it. We reproduced it with a throwaway container and an empty folder, so it is not only an upgrade problem. Mount the folder at /var/lib/postgresql and set PGDATA as in Step 2. If the folder holds an older major version, do the backup and restore from Step 9 rather than moving files around.
MCP still answers after users mod –mcp=false
As described in Step 7, the CLI change was stored at once, and a fresh app password was refused, but a password that was already in use kept working with full results until the PhotoPrism container restarted. Restart the service after the command and test with a real initialize call, which returned 403 Permission denied once the change took hold. For a homelab we would rather set PHOTOPRISM_DISABLE_MCP and avoid the per-account toggle entirely, unless one family member really needs an agent.
One last number from nas01. The whole stack, MCP included, idles at under 250 MiB of RAM after indexing, so a spare mini PC from our open source homelab build runs it comfortably. Give PostgreSQL support a few releases before you move a library you cannot re-index, and keep those YAML sidecar files backed up in the meantime.