Use case · Photo library

PhotoPrism self hosted on a dedicated server

PhotoPrism self hosted needs, by its own documentation, at least 2 cores, 3 GB of physical memory, a 64-bit system and at least 4 GB of swap, and the project recommends running it with Docker Compose next to MariaDB. This page takes the official compose.yaml onto an Ubuntu dedicated server, keeps port 2342 on loopback until the admin password is changed, puts nginx with WebSocket headers in front, and backs up all three things PhotoPrism stores: originals, the storage folder and the index database.

See the machines

What the documentation asks of the machine

ResourcePhotoPrism documentation
CPUAt least 2 cores
MemoryAt least 3 GB of physical memory; beyond that, "the amount of RAM should match the number of CPU cores"
SwapAt least 4 GB configured; no hard memory limit, which can cause unexpected restarts while indexing large files
DiskLocal SSD storage, especially for the database and cache files
DatabaseMariaDB 10.5.12+ or SQLite 3; MySQL 8 support has been discontinued
Low-memory cut-offFace detection and recognition, and RAW indexing and conversion, are disabled at 1 GB of memory or less

RAW images and high-resolution panoramas may need more swap or memory than the minimum, the docs add. Our reading of the RAM rule is one GB per core, so a 16-core box wants about 16 GB; where memory is short, the performance guide says to cut PHOTOPRISM_WORKERS. MariaDB's InnoDB buffer pool is 512M in the example; the docs suggest 1 or 2 GB when the host has plenty of memory.

Which machines fit

Indexing is the heavy part: every original is read, thumbnailed and passed through the classification and face models. On a dedicated server every core and all the memory serve that job, with root and no other tenant on the hardware. The 16-core Xeon D-1587 line ships with an NVMe-SSD 1.92 TB and takes 16 to 128 GB, which keeps the database on the local SSD the docs ask for. Libraries larger than one SSD belong on a custom build, where drives, RAID level and memory are chosen part by part; for the case for NVMe under a database, see NVMe servers.

The X-Gene ARM cartridge is out: it boots only Ubuntu 16.04 and 18.04. Delivery runs about 30 minutes for a ready, racked server once its payment is approved, and 4 to 24 hours for a built-to-order line. Uploading a library costs nothing extra, because inbound traffic is not billed and the port is unmetered. Console scope: every racked single-, dual- and quad-socket server and every blade has a full out-of-band console. The D-1587 line above is a Moonshot cartridge, and among cartridges only the m510 and m710x have a console; the rest have power control only. If recovering from a bad firewall rule without a ticket matters to you, pick a rack server or confirm the model first.

Firewall first, then the compose file

Turn ufw on before any container runs. If swapon --show lists less than 4 GB, add a swap file first (fallocate -l 4G /swapfile, chmod 600, mkswap, swapon, plus an /etc/fstab line). Install Docker Engine (release 28 onward, for the loopback point below) and its Compose plugin, then:

sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo mkdir -p /opt/photoprism/originals /opt/photoprism/storage
cd /opt/photoprism
sudo wget https://dl.photoprism.app/docker/compose.yaml
sudo chmod 600 compose.yaml
openssl rand -base64 24

That file is the same setup/docker/compose.yaml the GitHub repo carries. It holds four passwords, all set to insecure, hence the chmod. Edit it before the first start:

  • PHOTOPRISM_ADMIN_PASSWORD: a fresh random value, 8 to 72 characters. It cannot be changed through this variable after the first start, and a literal $ must be written $$.
  • PHOTOPRISM_DATABASE_PASSWORD and MARIADB_PASSWORD: one shared new value; MARIADB_ROOT_PASSWORD: another. MariaDB takes these only on its first start.
  • ports: change "2342:2342" to "127.0.0.1:2342:2342". Our change: a mapping with no address is published on 0.0.0.0 and [::]; Docker-published traffic skips ufw on IPv4, and on IPv6 too once Docker's own IPv6 networking is enabled. Loopback-only publishing holds on Docker Engine 28+ (moby#45610).
  • Volumes: replace ~/Pictures, ./storage and ./database with /opt/photoprism/originals, /opt/photoprism/storage and /opt/photoprism/database. The docs recommend absolute paths over relative ones or named volumes.
  • Leave PHOTOPRISM_SITE_URL at http://localhost:2342/ for now, and leave restart: unless-stopped commented, as the file itself advises, until the setup is tested.

First sign-in through an SSH tunnel

  1. Start the stack: sudo docker compose -f /opt/photoprism/compose.yaml up -d.
  2. On your workstation, tunnel in through your own login: ssh -L 2342:127.0.0.1:2342 you@your-server.
  3. Browse to http://localhost:2342/ and log in with user admin and the initial password.
  4. Change it under Settings > Account > Change Password, or on the server with sudo docker compose -f /opt/photoprism/compose.yaml exec photoprism photoprism passwd admin.
  5. Index from Library > Index. The first pass over a large library is the longest job this machine will do.

HTTPS with nginx in front

PhotoPrism's docs say a public instance must sit behind an HTTPS reverse proxy, with only 80 and 443 exposed and 2342 kept private. Do it in this order: open 80 and 443 in ufw, then DNS, then the certificate, then the config.

  1. Open the web ports and install the tools: sudo ufw allow 80,443/tcp, then sudo apt install -y nginx certbot python3-certbot-nginx. Host nginx is an ordinary process, so without that rule ufw blocks it on IPv4 and IPv6 alike.
  2. Point an A record (and AAAA if you serve IPv6) for photos.example.com at the server and wait until it resolves.
  3. Get the certificate: sudo certbot certonly --nginx -d photos.example.com. On a first run certbot asks for an email address ("used for urgent renewal and security notices"), asks you to agree to the Let's Encrypt Terms of Service ((Y)es/(N)o, required), and asks whether to share that address with the EFF ((Y)es/(N)o, optional).
  4. Save the server blocks below as /etc/nginx/sites-available/photoprism, link them into sites-enabled, then sudo nginx -t && sudo systemctl reload nginx.
map $http_upgrade $pp_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 80;
    listen [::]:80;
    server_name photos.example.com;
    location / { return 301 https://$host$request_uri; }
}
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name photos.example.com;
    ssl_certificate     /etc/letsencrypt/live/photos.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/photos.example.com/privkey.pem;
    client_max_body_size 512M;
    proxy_buffering off;
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
    location / {
        proxy_pass http://127.0.0.1:2342;
        proxy_http_version 1.1;
        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_set_header X-Forwarded-Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $pp_upgrade;
    }
}

The headers, body size and timeouts are the docs' NGINX example. Our changes: proxy_pass targets the loopback port instead of a container name, the upgrade map carries its own variable name so it cannot collide with one already in nginx.conf, the redirect sits inside a location so certbot's renewal challenge is never redirected, and the two certbot include lines are dropped. The Upgrade and Connection headers matter: PhotoPrism pushes indexing and import progress over a WebSocket, and without them the UI stops updating until a reload. Finally set PHOTOPRISM_SITE_URL to https://photos.example.com/ and PHOTOPRISM_DISABLE_TLS to "true", then run sudo docker compose -f /opt/photoprism/compose.yaml up -d again. Once the site answers over HTTPS, uncomment restart: unless-stopped and run the same up -d once more so the stack comes back after a reboot. PhotoPrism trusts forwarded headers from 172.16.0.0/12, 127.0.0.0/8 and ::1 by default; our inference is that host nginx reaching the published port arrives from the Docker bridge gateway, inside that range.

Backups that cover originals, storage and the database

The docs define a backup as the originals plus a copy of the index database, and recommend the storage folder too, since it holds config, thumbnails, sidecars and the dumps. photoprism backup -i -f writes storage/backup/mysql/YYYY-MM-DD.sql. Do not copy the live database folder; the dump is the consistent copy. Copy the result off the machine yourself; the script below pushes it to a host you control, without --delete so a photo removed by mistake survives there.

sudo mkdir -m 700 -p /var/log/photoprism-backup
sudo tee /usr/local/sbin/photoprism-backup.sh > /dev/null <<'EOF'
set -euo pipefail
C="docker compose -f /opt/photoprism/compose.yaml"
$C exec -T photoprism photoprism backup -i -f
rsync -a /opt/photoprism/compose.yaml /opt/photoprism/storage \
  /opt/photoprism/originals backup@backup-host:photoprism/
EOF
printf '30 3 * * * root umask 077; bash /usr/local/sbin/photoprism-backup.sh > /var/log/photoprism-backup/last.log 2>&1\n' | sudo tee /etc/cron.d/photoprism-backup

Cron runs this as root, so root needs its own key on the backup host. If sudo test -f /root/.ssh/id_ed25519 fails, create one with sudo ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519; if the key already exists, reuse it and answer n if ssh-keygen asks "Overwrite (y/n)?". Then run sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub backup@backup-host. On first contact it asks "Are you sure you want to continue connecting (yes/no/[fingerprint])?", which cron could not answer, and then the backup user's password. Before trusting cron, execute sudo bash /usr/local/sbin/photoprism-backup.sh once by hand and check last.log. -T stops exec asking for a terminal, which cron does not have. PhotoPrism also writes its own daily dump and keeps three, so a copy of storage always carries recent ones. The copied compose.yaml holds the passwords: keep the backup host private.

Restoring onto the running instance

  1. Check the copy exists on the backup host: ls -l photoprism/storage/backup/mysql/ should list a recent .sql file.
  2. While the app still runs, dump the current index under its own name, as a way back: sudo docker compose -f /opt/photoprism/compose.yaml exec photoprism photoprism backup -i -f /photoprism/storage/backup/before-restore.sql.
  3. Stop PhotoPrism: sudo docker compose -f /opt/photoprism/compose.yaml stop photoprism.
  4. Move the current folders aside: sudo mv /opt/photoprism/storage /opt/photoprism/storage.old, and sudo mv /opt/photoprism/originals /opt/photoprism/originals.old only if the originals are damaged too. Then make sure the set-aside copy holds data: sudo test -s /opt/photoprism/storage.old/backup/before-restore.sql && sudo du -sh /opt/photoprism/storage.old.
  5. Copy back: sudo rsync -a backup@backup-host:photoprism/storage /opt/photoprism/, plus sudo rsync -a backup@backup-host:photoprism/originals /opt/photoprism/ if you moved the originals aside. Check the dump arrived with sudo ls -l /opt/photoprism/storage/backup/mysql/.
  6. The container runs as root by default; if you set PHOTOPRISM_UID, chown -R the copied folders to that user.
  7. Start it and load the index: sudo docker compose -f /opt/photoprism/compose.yaml up -d, then sudo docker compose -f /opt/photoprism/compose.yaml exec photoprism photoprism restore -i -f /photoprism/storage/backup/mysql/YYYY-MM-DD.sql.
  8. Sign in and check the library. Restoring also restores accounts and passwords, so use the ones valid when the dump was taken.
  9. If that check fails, roll back: sudo docker compose -f /opt/photoprism/compose.yaml stop photoprism, sudo mv /opt/photoprism/storage /opt/photoprism/storage.failed, sudo mv /opt/photoprism/storage.old /opt/photoprism/storage (and, if you moved the originals aside: sudo mv /opt/photoprism/originals /opt/photoprism/originals.failed then sudo mv /opt/photoprism/originals.old /opt/photoprism/originals), sudo docker compose -f /opt/photoprism/compose.yaml up -d, then sudo docker compose -f /opt/photoprism/compose.yaml exec photoprism photoprism restore -i -f /photoprism/storage/backup/before-restore.sql.
  10. Only after everything checks out: sudo rm -rf /opt/photoprism/storage.old, and /opt/photoprism/originals.old if you made one.

On a new machine, put the backed-up compose.yaml in /opt/photoprism before MariaDB ever starts: it creates its user from the MARIADB_ variables on the first start only.

Updates and the licence

Take a named dump first, since a new version may upgrade the index database when it starts: sudo docker compose -f /opt/photoprism/compose.yaml exec photoprism photoprism backup -i -f /photoprism/storage/backup/before-update.sql. Then sudo docker compose -f /opt/photoprism/compose.yaml up -d --pull always. MariaDB is pinned to mariadb:12.3; change major versions by hand after a backup, because MariaDB does not support downgrades. Watchtower can automate image updates, but the docs warn it can interrupt indexing and imports.

The source in the GitHub repo is AGPL-3.0, a copyleft licence. Our general reading, not legal advice: anyone who distributes copies, modified or not, must make the corresponding source available, and section 13 adds a duty to offer the source of a modified version to everyone who uses it over a network. The standard Docker images, :latest included, ship under PhotoPrism's Plus License so member extensions can be bundled; the ce tag is a slightly smaller image under the AGPL, though third-party components inside keep their own terms. Plus members unlock extra features by signing in as the admin and following the activation guide; source builds include all Community Edition features and most Essentials features, except additional user roles. The licence's additional terms also bar the PhotoPrism trademark from the name of your app or service.

Questions

Is SQLite good enough instead of MariaDB?

For a small library it can be. The docs say SQLite is generally not a good choice where scalability and performance matter, cap it at four index workers, and suggest switching to MariaDB when indexing runs short of resources. The official compose.yaml uses MariaDB.

What is the default PhotoPrism login?

Sign in as admin; the password is whatever PHOTOPRISM_ADMIN_PASSWORD held at the first start. The example file says insecure; the docs say to change it before starting and that there is no default when none is given.

Can PhotoPrism use a GPU for its AI models?

With an NVIDIA card in an x86-64 server (Intel or AMD), the photoprism/photoprism:cuda image runs the built-in models on the GPU, with a device reservation in the compose file. Without one, the models run on the CPU.

How much RAM does PhotoPrism need for a large library?

The floor is 3 GB with 4 GB of swap, and the docs want RAM to match the CPU core count. RAW files and large panoramas can need more than the minimum, and the MariaDB buffer pool benefits from 1 or 2 GB when memory allows.

Do I need a PhotoPrism membership to self-host?

No. The docs recommend new users start on the free Community Edition; a membership adds features that are activated from the admin account.

Put the library on its own machine

Whole servers with root, local SSD or NVMe, and an unmetered port for the first upload.

See the machines

Related on Server Room

Other use cases