PhotoPrism is a self-hosted photo library that indexes your pictures, reads their EXIF metadata, detects faces and labels, and shows geotagged photos on a map, all without sending data to a third-party service. In this tutorial you will run PhotoPrism and MariaDB with Docker Compose on Ubuntu 24.04, put Nginx with a Let's Encrypt certificate in front of it, add photos to the library, and schedule indexing and database backups.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 CPU cores and 4 GB of RAM. Indexing and face detection are CPU and memory intensive; with less RAM, add swap.
  • Enough disk space for your photos plus roughly 20-30% extra for thumbnails and sidecar files.
  • A non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed. See How to install Docker on Linux.
  • A domain name, referred to as your_domain (for example photos.example.com), with a DNS A record pointing to your server's public IP.

Step 1 - Creating the project directory and secrets

Keep everything PhotoPrism needs under /opt/photoprism: the Compose file, the database files, the generated cache (storage) and your photos (originals).

sudo mkdir -p /opt/photoprism/{originals,import,storage,database}
cd /opt/photoprism

Generate random passwords for the PhotoPrism admin account and the database, and store them in a .env file that Docker Compose reads automatically:

sudo tee /opt/photoprism/.env > /dev/null <<EOF
PHOTOPRISM_ADMIN_PASSWORD=$(openssl rand -base64 18)
MARIADB_PASSWORD=$(openssl rand -hex 24)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 24)
EOF
sudo chmod 600 /opt/photoprism/.env

Print the file once and save the admin password in your password manager; you need it to log in:

sudo cat /opt/photoprism/.env

Step 2 - Writing the Docker Compose file

Create the Compose file:

sudo nano /opt/photoprism/compose.yaml

Paste the following configuration and replace your_domain:

services:
  photoprism:
    image: photoprism/photoprism:latest
    restart: unless-stopped
    depends_on:
      - mariadb
    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: "https://your_domain/"
      PHOTOPRISM_DISABLE_TLS: "true"
      PHOTOPRISM_ORIGINALS_LIMIT: 5000
      PHOTOPRISM_HTTP_COMPRESSION: "gzip"
      PHOTOPRISM_LOG_LEVEL: "info"
      PHOTOPRISM_DATABASE_DRIVER: "mysql"
      PHOTOPRISM_DATABASE_SERVER: "mariadb:3306"
      PHOTOPRISM_DATABASE_NAME: "photoprism"
      PHOTOPRISM_DATABASE_USER: "photoprism"
      PHOTOPRISM_DATABASE_PASSWORD: "${MARIADB_PASSWORD}"
    working_dir: "/photoprism"
    volumes:
      - ./originals:/photoprism/originals
      - ./import:/photoprism/import
      - ./storage:/photoprism/storage

  mariadb:
    image: mariadb:11
    restart: unless-stopped
    security_opt:
      - seccomp:unconfined
      - apparmor:unconfined
    command: --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512
    environment:
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_INITDB_SKIP_TZINFO: "1"
      MARIADB_DATABASE: "photoprism"
      MARIADB_USER: "photoprism"
      MARIADB_PASSWORD: "${MARIADB_PASSWORD}"
      MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD}"
    volumes:
      - ./database:/var/lib/mysql

A few settings are worth explaining:

  • 127.0.0.1:2342:2342 publishes the web port only on the loopback interface. Ports published by Docker bypass UFW, so binding to localhost keeps PhotoPrism reachable only through Nginx.
  • PHOTOPRISM_SITE_URL must be the public HTTPS URL, otherwise share links and redirects point to the wrong address.
  • PHOTOPRISM_DISABLE_TLS turns off PhotoPrism's built-in TLS because Nginx terminates HTTPS.
  • PHOTOPRISM_ORIGINALS_LIMIT is the maximum size of a single original file in MB.

Step 3 - Starting PhotoPrism

Pull the images and start both containers in the background:

cd /opt/photoprism
sudo docker compose up -d

The first start takes a minute or two while MariaDB initializes and PhotoPrism creates its tables. Follow the log and press Ctrl+C once it settles:

sudo docker compose logs -f photoprism

Check that both containers are running:

sudo docker compose ps

Then query the status endpoint on the loopback port:

curl -s http://127.0.0.1:2342/api/v1/status
{"status":"operational"}

Step 4 - Configuring Nginx and HTTPS

Install Nginx and Certbot from the Ubuntu repositories:

sudo apt update
sudo apt install nginx certbot python3-certbot-nginx

Create a server block for PhotoPrism:

sudo nano /etc/nginx/sites-available/photoprism
server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    client_max_body_size 500M;

    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 Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_buffering off;
        proxy_read_timeout 600s;
    }
}

The Upgrade and Connection headers are required because the PhotoPrism interface uses WebSockets for live updates. client_max_body_size allows large uploads from the browser.

Enable the site, test the configuration and reload Nginx:

sudo ln -s /etc/nginx/sites-available/photoprism /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Allow SSH, HTTP and HTTPS in UFW. Allow OpenSSH before enabling the firewall so you do not lock yourself out:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Request a certificate. Certbot adds the TLS settings to the server block and redirects HTTP to HTTPS:

sudo certbot --nginx -d your_domain

Open https://your_domain in your browser and log in as admin with the password from /opt/photoprism/.env.

Step 5 - Adding photos and indexing the library

PhotoPrism works with two folders:

  • originals is your library. PhotoPrism indexes files here in place and does not move them. Browser uploads also end up here.
  • import is a drop folder. The import command moves files from it into originals, sorted into folders by date, and skips duplicates.

To add an existing collection, copy it into originals, for example with rsync:

sudo rsync -a --info=progress2 /path/to/your/photos/ /opt/photoprism/originals/

Then run the indexer. The first run reads metadata, generates thumbnails and runs label and face detection, so it can take hours on a large library:

cd /opt/photoprism
sudo docker compose exec photoprism photoprism index --cleanup

The --cleanup flag removes index entries for files that no longer exist. When the command returns, reload the web interface: your photos appear under Search and Library, and photos with GPS coordinates in their EXIF data appear under Places.

To use the drop folder instead, place files in /opt/photoprism/import and run:

sudo docker compose exec photoprism photoprism import

Both actions are also available in the web interface under Library.

Step 6 - Scheduling indexing and database backups

Files added to originals from outside the web interface (rsync, a sync client, a backup job) only appear after an index run. The database holds the index, albums, labels and people, so it needs its own backup.

Create the backup script first. It dumps the database with the root password stored inside the MariaDB container, compresses the dump and keeps 14 days:

sudo nano /usr/local/bin/photoprism-db-backup
#!/usr/bin/env bash
set -euo pipefail

backup_dir="/var/backups/photoprism"
mkdir -p "$backup_dir"

cd /opt/photoprism
docker compose exec -T mariadb sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction photoprism' \
  | gzip > "$backup_dir/photoprism-$(date +%F).sql.gz"

find "$backup_dir" -name 'photoprism-*.sql.gz' -mtime +14 -delete

Make it executable and run it once to test it:

sudo chmod 750 /usr/local/bin/photoprism-db-backup
sudo /usr/local/bin/photoprism-db-backup
ls -lh /var/backups/photoprism
-rw-r--r-- 1 root root 2.1M Sep 25 10:12 photoprism-2026-09-25.sql.gz

Now schedule both jobs with a cron file:

sudo nano /etc/cron.d/photoprism
# Index new and changed files every night at 03:00
0 3 * * * root cd /opt/photoprism && docker compose exec -T photoprism photoprism index --cleanup > /dev/null 2>&1
# Dump the database every night at 04:30
30 4 * * * root /usr/local/bin/photoprism-db-backup

Your photos themselves live in /opt/photoprism/originals. Include that folder and /var/backups/photoprism in your off-server backups.

Step 7 - Updating PhotoPrism

Run the backup script, then pull the new images and recreate the containers. PhotoPrism migrates its database schema on start:

sudo /usr/local/bin/photoprism-db-backup
cd /opt/photoprism
sudo docker compose pull
sudo docker compose up -d

Troubleshooting

Nginx returns 502 Bad Gateway. PhotoPrism is not listening yet or has stopped. Check sudo docker compose ps and sudo docker compose logs --tail 50 photoprism. Database connection errors right after the first start usually mean MariaDB is still initializing; PhotoPrism retries on its own.

Indexing is very slow or the container restarts. Check memory with sudo docker stats and look for out-of-memory kills with sudo dmesg | grep -i oom. Add RAM or swap. For a very large first run you can temporarily add PHOTOPRISM_DISABLE_FACES: "true" and PHOTOPRISM_DISABLE_CLASSIFICATION: "true", run sudo docker compose up -d, index, then remove both settings and index again.

New files do not show up. Files copied into originals from outside PhotoPrism only appear after an index run. Run the index command from Step 5 and read its output for unsupported or damaged files.

Uploads fail with "413 Request Entity Too Large". Raise client_max_body_size in the Nginx server block and reload Nginx.

Conclusion

PhotoPrism now runs with Docker Compose and MariaDB behind Nginx with HTTPS, indexes your library every night and keeps two weeks of database dumps. As next steps, install PhotoPrism as a progressive web app on your phone from the browser menu, sync photos from a desktop through PhotoPrism's WebDAV endpoint, and copy originals and the database dumps off the server with a tool such as restic.