Skip to main content

📖 Advanced docker-compose usage

This page is the reference for the environment variables LibrePhotos reads and what each one does. The bundled .env file names most of them in lowerCamelCase (the .env key); with the Compose setup you set the .env key, and the variable is the name the backend container receives. Where a variable has no .env key, pass it to the container directly, as the examples below show.

PostgreSQL v18+ Volume Mount Change​

The bundled Compose files run pgautoupgrade/pgautoupgrade:18-alpine, PostgreSQL 18. Releases up to 1.3.0 used the latest tag, which moves to a new PostgreSQL major as soon as one comes out, so any docker compose pull could upgrade your database. The major is now pinned, and moving to the next one is a LibrePhotos release with its own upgrade note. Back up the database directory (${data}/db, or ${data}/dbcursor with docker-compose.unified.yml) before such an upgrade.

Breaking Change

With Postgres v18+, mounting a volume to /var/lib/postgresql/data can fail in Docker/Kubernetes due to an upstream change that enforces a new data directory (/var/lib/postgresql/MAJOR/docker) and adds a symlink from /var/lib/postgresql/data to /var/lib/postgresql.

Action required: Mount your volume to /var/lib/postgresql instead of /var/lib/postgresql/data. pgautoupgrade will detect any existing data there and migrate it to the new structure.

Utilizing GPU Acceleration​

To leverage GPU acceleration for neural networks and face detection, follow these steps:

  1. Update NVIDIA GPU Driver: Ensure you have the latest NVIDIA GPU driver installed on your system. You can download and install it from the official NVIDIA website.

  2. Install NVIDIA Container Toolkit: Install the NVIDIA Container Toolkit, which enables GPU support within Docker containers. You can do this by running the following command:

    sudo apt install nvidia-container-toolkit
  3. Update Docker Compose Configuration: Modify your docker-compose.yml file to specify the reallibrephotos/librephotos-gpu image as the backend for your application. Locate the relevant service definition for your backend, and update it to use the GPU-enabled image. For example:

    services:
    backend:
    image: reallibrephotos/librephotos-gpu:${tag}
    # ... other configuration settings ...
  4. Configure GPU Resources in Docker Compose: Add a deploy section to your Docker Compose file to allocate GPU resources to the backend service. Ensure you specify the correct GPU driver, count, and capabilities. Here's an example of how to do this:

    services:
    backend:
    image: reallibrephotos/librephotos-gpu:${tag}
    # ... other configuration settings ...
    deploy:
    resources:
    reservations:
    devices:
    - driver: nvidia
    count: 1
    capabilities: [gpu]
note

The GPU image is only available for x86 architecture. ARM is not supported for the GPU image.

Which device the models run on​

Every machine learning model (face recognition, semantic search, scene tags, captions, OCR) runs on ONNX Runtime. The models use CUDA when the installed ONNX Runtime offers it, which the GPU image does, and fall back to the CPU otherwise; the CPU image needs no configuration.

VariableDefaultWhat it does
ONNX_PROVIDERSCUDA if available, then CPUComma-separated ONNX Runtime execution providers, most preferred first. CPUExecutionProvider keeps the GPU image off the GPU, for example to leave it to another application. Names the installed ONNX Runtime does not offer are skipped.
ONNX_INTRA_OP_THREADSONNX Runtime's default (one per physical core)How many threads one model may use at a time. See Threads per model.
CUDA_VISIBLE_DEVICES0 when the container has nvidia-smiWhich GPUs CUDA may use, as a comma-separated list of indices (1, 0,1). The entrypoint prints nvidia-smi and sets 0 unless you set it; releases up to 1.3.0 always set 0 in the GPU image.

None of them is in the bundled .env file; pass them to the backend container directly:

services:
backend:
environment:
- ONNX_PROVIDERS=CPUExecutionProvider

Before 1.2.0, every model ran on the CPU, even in the GPU image.

Limiting CPU and memory usage​

The backend container runs two things: uvicorn, which answers API requests, and a pool of background workers, which scan your library — thumbnails, face detection, captioning. Almost all of the CPU and memory LibrePhotos uses goes to the background workers, and by default there is one worker per CPU core.

Start with the worker count, not a CPU limit​

The instinct is to cap the container in docker-compose.yml:

services:
backend:
cpus: 0.8

On its own this usually backfires. A cpus: limit throttles the container, but it does not change how many workers LibrePhotos starts — the worker pool is sized from the number of cores the host reports, which a cpus: limit does not change. You end up with just as many workers competing for a fraction of the CPU, and the API gets slow along with them.

So set the worker count instead, with workerConcurrency in your .env (the variable it fills in is WORKER_CONCURRENCY):

# One background worker instead of one per core
workerConcurrency=1

This is the setting that actually gives resources back to the rest of the machine. Scanning takes longer, but the API stays responsive and nothing gets killed. Each worker is recycled once it passes roughly 300 MB of resident memory, so the worker count is also the main lever on the backend's memory footprint.

If you also want a hard cap​

Once the worker count is sensible, a container limit is a reasonable backstop:

services:
backend:
cpus: 2.0
mem_limit: 4g

cpuset works too, if you would rather pin LibrePhotos to specific cores than give it a fraction of all of them:

services:
backend:
cpuset: "0-2"

Under Docker Swarm the equivalent is a deploy.resources block:

services:
backend:
deploy:
resources:
limits:
cpus: "2.0"
memory: 4G
note

cpu_shares only sets a relative priority between containers and has no visible effect when nothing else is competing for the CPU, which is why it often looks like it does nothing.

Threads per model​

Each machine learning service is a separate process, and by default each model in it may use one thread per physical core. With several of them busy during a scan they compete for the same cores. ONNX_INTRA_OP_THREADS caps every model at that many threads (0 or unset keeps the default), which pairs well with a cpus: limit: set it to the number of cores you give the container, or fewer. The face recognition models are the exception, because the library that loads them does not pass the setting through.

Memory the models keep between photos​

ONNX Runtime keeps a memory arena per model: once a model has needed a buffer, it holds on to it for as long as the model stays loaded. For text recognition (OCR) and captioning, whose input sizes change from photo to photo, that kept the memory of the largest photo they ever saw, several hundred MB each. Since 1.3.0 those two run without the arena, which costs a few percent of their speed; the other models keep it. ONNX_CPU_ARENA=1 gives every model the arena again, ONNX_CPU_ARENA=0 takes it from every model. The face recognition models keep their default either way.

The services also start with OPENBLAS_NUM_THREADS=1 unless you set it: they do their heavy work in ONNX Runtime, and otherwise every one of them reserved buffers for a thread per core.

warning

Do not cap the container so hard that the first scan cannot finish. Face detection and captioning load sizeable models; below roughly 2 GB of memory the backend will be killed by the kernel — and that really is an out-of-memory kill.

Requests at the same time​

Two settings size the web API, separately from the background workers. WEB_CONCURRENCY is the number of uvicorn worker processes that answer requests. The bundled .env sets it through gunniWorkers (default 2); the backend image defaults to 1 and the single-container image to 4. WEB_THREADS (default 16) is how many requests each worker serves at once, each holding a thread while it runs. With more than one worker, each is restarted after WEB_MAX_REQUESTS requests.

Variable.env keyDefaultWhat it does
WEB_CONCURRENCYgunniWorkers1 (backend image), 4 (single-container image)Number of uvicorn worker processes. Above 1 each worker is recycled after WEB_MAX_REQUESTS requests, so a recycled one never takes the whole API down.
WEB_MAX_REQUESTS(none)50 (backend image), 2000 (single-container image)With more than one worker, the requests a worker answers before it is restarted. The single-container image serves the web app and the photos through Django too, so it restarts them less often.
WEB_THREADS(none)16Requests one uvicorn worker serves at the same time; further requests wait for a free thread.

Turning features off​

Every part of the library scan that costs real CPU, memory or network can be switched off at deploy time with a FEATURE_* environment variable. All of them default to on, so an upgrade changes nothing until you set one.

Accepted "on" values are true, 1, yes and on, and "off" values false, 0, no and off (any capitalisation). Anything else counts as off too, and the backend log says so with a warning that names the variable. An empty value (FEATURE_VIDEO=) also counts as off. Every on/off variable on this page is read the same way, DEMO_SITE, NEXTCLOUD_ENABLED and LOG_TO_CONSOLE included; ALLOW_UPLOAD too, except that an empty value leaves uploads on, as it always has. (Not released yet: up to 1.3.0, NEXTCLOUD_ENABLED=t also switched the Nextcloud integration on. It now counts as off, so write true.)

Number variables (worker counts, the TRANSCODE_* limits, REFRESH_TOKEN_DAYS and the like) fall back to their default when empty. A value that is not a number stops the backend at startup with a message that names the variable.

Variable.env keyWhat turning it off stops
FEATURE_VIDEOfeatureVideoVideo files are no longer imported. A scan skips them the same way it skips a file it cannot read, so no Photo is created and no video thumbnail is generated. The motion video inside a "live photo" is not extracted either, so those stay ordinary still images.
FEATURE_FACE_DETECTIONfeatureFaceDetectionNo faces are extracted from photos, neither during a scan nor when you upload one. The face scan is left out of the scan pipeline and Scan faces in the UI reports an error instead of starting a job. The face recognition service is not started, so its model is never loaded, and (not released yet) the model is not downloaded.
FEATURE_FACE_CLUSTERfeatureFaceClusterFaces are still detected, but never grouped into people to label. Clustering is skipped at the end of a face scan and Train faces reports an error.
FEATURE_IMAGE_CAPTIONINGfeatureImageCaptioningNo automatic captions are generated, neither during a scan nor from the "Generate caption" button on a photo. Captions you typed yourself are unaffected. The captioning service is not started, and (not released yet) the captioning model is not downloaded.
FEATURE_REVERSE_GEOCODINGfeatureReverseGeocodingGPS coordinates are no longer turned into place names, so no lookups run, offline or at an online map provider. Photos keep their coordinates and still show up on the map of an album and of a single photo, but without a place name they do not appear on the Places page, get no Places album, and cannot be searched by place. Searching for a place in the search bar still works.
FEATURE_SCENE_CLASSIFICATIONfeatureSceneClassificationPhotos are no longer tagged by what is in them (beach, kitchen, sunset, ...), so the "Things" albums stay empty for new photos. The tags service keeps running: the same model makes the semantic-search embeddings.
FEATURE_VIDEO_PREVIEWSfeatureVideoPreviewsNot released yet. Videos get no animated preview in the photo grid. A scan makes two WebP stills from the video's first frame instead of encoding two short mp4 clips, which is the slowest step of importing a video and takes most of its thumbnail space. Videos still play in full in the photo viewer. Previews made before stay until the video is rescanned.
FEATURE_PROCESS_EMBEDDED_MEDIAfeatureProcessEmbeddedMediaThe short video stored inside a "live photo" or motion photo is no longer extracted, so those files stay ordinary stills. FEATURE_VIDEO has to be on as well for extraction to happen. See Feature Toggles for the one way this switch differs from the others.

Turning a feature off never deletes anything that was already generated - the existing captions, faces and place names stay in the database and remain visible. Turning it back on picks up where the scan left off.

Not released yet: all of these but FEATURE_PROCESS_EMBEDDED_MEDIA also have a switch in the Features card of the site settings that an admin can flip without a restart. A variable set to off is a hard off that the site setting cannot override; see Feature Toggles.

With the bundled Compose setup, set the lowerCamelCase keys in your .env:

# A machine without much CPU: keep the photos, skip the machine learning
featureFaceDetection=false
featureFaceCluster=false
featureImageCaptioning=false
featureSceneClassification=false

If you run your own Compose file or Kubernetes manifests, pass the FEATURE_* names to the backend container directly:

services:
backend:
environment:
- FEATURE_VIDEO=false
note

FEATURE_PROCESS_EMBEDDED_MEDIA is checked only at the moment a file is first imported. Turning it on for a library that has already been scanned extracts nothing for the photos already there — not even with Rescan All Photos — so only files added afterwards are affected.

The machine learning services follow the switches​

note

New in 1.2.0 On 1.1.0 the switches stop the processing, but the services still start.

The backend runs its heavy models in separate sidecar processes, and a watchdog restarts any of them that dies. A switch that is off keeps its service from being started at all, and the watchdog leaves it alone rather than bringing it back a minute later — which is where the memory saving actually comes from, since a loaded model costs its memory whether or not anything asks it a question.

SwitchService that stops being started
FEATURE_FACE_DETECTIONface_recognition
FEATURE_IMAGE_CAPTIONINGimage_captioning

The remaining services — exif, thumbnail, tags and image_similarity — carry the scanning and search that the rest of LibrePhotos is built on, so they have no switch and always run. tags runs OpenCLIP, which makes the tags and the semantic-search embeddings alike, so FEATURE_SCENE_CLASSIFICATION stops the tagging but not the service (since 1.3.0). The other feature flags (FEATURE_VIDEO, FEATURE_VIDEO_PREVIEWS, FEATURE_FACE_CLUSTER, FEATURE_REVERSE_GEOCODING, FEATURE_PROCESS_EMBEDDED_MEDIA) gate work that happens inside the backend itself and have no service of their own to stop.

ocr has no environment variable either, but it is not always on: it follows the OCR model choice in Site Settings, which ships with nothing selected. The service starts once a model is picked, and stops being started if the choice is set back to none — the watchdog re-reads the setting every minute, so neither direction needs a restart. A configuration the backend cannot read counts as a model being selected, so a database that is still starting can never take the service away.

Not released yet. image_captioning follows the Captioning Model choice the same way: with None selected the service is not started and the model is not downloaded, even while FEATURE_IMAGE_CAPTIONING allows captioning.

A skipped service is named once in the backend log at startup, so docker logs backend tells you why something is not running. The Admin Area's Services list shows one as Disabled rather than as unhealthy, and offers no Start button for it.

Cached video conversions​

A video is converted as it plays when the viewer's browser says it cannot play it, or when it turns out not to decode (see Videos your browser cannot play). A user who turns on Always transcode videos (Settings → Experimental) has every video converted. A conversion happening live has no known length, so it carries no Content-Length and no Accept-Ranges, and it cannot be sought at all — no duration, no scrub bar, no skipping. The same conversion is therefore written to a file once, and every later play of that video is served from the file instead, as an ordinary seekable mp4. The first play still streams live and starts exactly as quickly as it does today: the copy never starts alongside a live conversion that has only just begun or is struggling to keep ahead of playback, because that conversion would lose a share of the machine to a second ffmpeg. It starts once the live conversion has finished, or, while that conversion is held to TRANSCODE_LIVE_READRATE, once it is a minute ahead of anything the viewer can have reached, which at the default settings is about half a minute in. Otherwise (a host too slow to get that far ahead, the readrate at 0, or an ffmpeg without -readrate) it waits until the live conversion has finished, or the viewer closes the video, whichever comes first. The copy is also niced and limited to half the cores, so playback, thumbnails and a running scan all outrank it.

The cache costs somewhere between 10 and 20 MB per minute of video — how much movement there is in the footage decides where in that range it lands — and only for the converted videos somebody actually opens. No setting has to be on for this: since 1.3.0, a library of HEVC phone videos watched in Firefox fills the cache on its own. A library whose videos all play in your users' browsers, with nobody using Always transcode videos, writes nothing.

Variable.env keyDefaultWhat it does
TRANSCODE_CACHE_MAX_GBtranscodeCacheMaxGb10How large the cache may grow, in GB. Roughly an hour of video per GB. Set it to 0 to switch caching off entirely and keep only the live streaming.
TRANSCODE_CACHE_MIN_FREE_GBtranscodeCacheMinFreeGb2How much free space to leave alone on the volume, in GB. The cache never writes into this, and a conversion already running is abandoned if the free space drops into it.
TRANSCODE_CACHE_MAX_CONCURRENTtranscodeCacheMaxConcurrent1How many conversions may be written at once. Each is an ffmpeg process, so raising this trades CPU for having more videos become seekable sooner.
TRANSCODE_CACHE_NICEtranscodeCacheNice10How far the background conversion stands back from everything else, as a nice value. 0 turns the courtesy off.

When either ceiling is reached, the least recently played entries are deleted until the new one fits; if even that is not enough, the video simply is not cached and plays live as before. Nothing is ever served before its conversion has finished, so an interrupted one leaves no half-playable file behind.

The files live under protected_media/transcoded/, named by image hash, and are deleted along with the photo. Deleting the directory by hand is safe at any time — it costs only the CPU to convert those videos again.

What a live conversion may take​

The conversion a viewer is waiting on is bounded too, and for a different reason than the cached copy. Left to itself ffmpeg uses every core and converts as fast as the hardware allows, so one person opening one video can starve the web UI, a running scan and every other user — while playback needs output only a little faster than real time. The extra throughput buys nothing: on a fast host it races through footage the viewer may never reach.

Variable.env keyDefaultWhat it does
TRANSCODE_LIVE_CPU_FRACTIONtranscodeLiveCpuFraction2Cap the conversion at 1/N of the machine's cores. 2 is half; 1 lets it use all of them. Never fewer than one core, whatever the number. The cap is close rather than exact — decoding, filtering and encoding are each held to it, but reading and muxing are not.
TRANSCODE_LIVE_READRATEtranscodeLiveReadrate2Hold the conversion to this multiple of real time, so a minute of video takes about half a minute to convert. Set it to 0 to convert as fast as the host can.
TRANSCODE_LIVE_BURST_SECONDStranscodeLiveBurstSeconds30How much video to convert at full speed before the rate limit applies, so playback still starts instantly and the browser still has a buffer ahead of it.

Unlike the cached copy, the live conversion is not niced: somebody is watching it, so it should outrank the background work rather than yield to it.

TRANSCODE_LIVE_READRATE and TRANSCODE_LIVE_BURST_SECONDS need a recent ffmpeg — -readrate arrived in ffmpeg 5.0 and -readrate_initial_burst in 6.1. The CPU image has both. The GPU image does not: it is built on Ubuntu 22.04, whose ffmpeg is 4.4, so on that image these two settings have no effect and only TRANSCODE_LIVE_CPU_FRACTION applies. The same goes for a host supplying its own older ffmpeg. Nothing has to be configured for that — the option is simply not passed, and the core cap still holds.

TRANSCODE_LIVE_CPU_FRACTION is a divisor, so a larger number means fewer cores: 4 is stricter than 2. Raising it, or lowering the readrate, makes a busy server more responsive while a video is playing; going the other way favours the person watching. If a video stutters on a slow machine, set TRANSCODE_LIVE_CPU_FRACTION to 1 first: a stutter means the conversion cannot keep ahead of playback, and it is the core cap that decides how fast it can go — the readrate is a ceiling it never reached. For scale, one core converts 1080p to 720p at about 1.5x real time, and two at about 2x, so a machine with few cores has little margin at 1080p and none to spare for a second viewer.

Thumbnail size and quality​

Every photo gets three WebP thumbnails: a big one, 1080 px high by default, which the lightbox shows and the duplicate detection, face detection and search read, and two squares of 500 and 250 px, which the timeline and album grids show by the hundreds. The big one is encoded at quality 95. The squares are encoded at 80 since 1.3.0 (95 before): they come out 60–70 % smaller and encode a third faster, so a grid loads fewer bytes and the server sends more of them per second, for a difference that is hard to see at their size. A video gets a still big thumbnail and, instead of the squares, two short silent mp4 previews of its first seconds, 500 and 250 px high, which the grids play. Not released yet: with the animated previews turned off (FEATURE_VIDEO_PREVIEWS above, or Animated previews in the Features card), a video gets two WebP squares of its first frame instead.

Variable.env keyDefaultWhat it does
SQUARE_THUMBNAIL_QUALITYsquareThumbnailQuality80WebP quality (1–100) of the 500 and 250 px square thumbnails. 95 brings back the old size and look.
BIG_THUMBNAIL_QUALITYbigThumbnailQuality95Not released yet. WebP quality (1–100) of the big thumbnail.
BIG_THUMBNAIL_HEIGHTbigThumbnailHeight1080Not released yet. Height in pixels (500–4320) of the big thumbnail. A photo smaller than that is never enlarged.
VIDEO_PREVIEW_SECONDSvideoPreviewSeconds5Not released yet. Length in seconds (1–30) of the animated video previews.
VIDEO_PREVIEW_CRFvideoPreviewCrf20Not released yet. x264 quality (CRF, 0–51) of the animated video previews. Lower looks better and takes more space; every 6 roughly doubles or halves the file size.

A value outside its range is set to the nearest limit. The thumbnails stay WebP and the previews mp4: every client and the server's media serving rely on those formats, so there is no setting for the format beyond turning the previews off.

A larger or higher-quality big thumbnail looks sharper in the lightbox, especially on a 4K screen, but takes more disk space and makes each scan and the jobs that read it (faces, search, captions) slower. A taller big thumbnail also means fewer RAW files can use the preview their camera embedded, and more of them go through the slower RAW conversion. Longer video previews take longer to make and more space, and the grids download them in full.

These settings apply to thumbnails rendered from then on: those of newly scanned photos, and of a photo you rotate or whose file changed on disk. Existing thumbnails keep their size and quality until they are rendered again; there is no job that renders every thumbnail again. When a big thumbnail is rendered again at a new height, the face boxes found on the old one are scaled with it. Do not delete thumbnail files by hand to have them rendered again: a face box is stored in pixels of the big thumbnail its face was found on, and it would no longer line up with the face.

Stuck jobs​

Once an hour, LibrePhotos marks a job failed when it has made no progress for a day, so a job whose worker was killed (out of memory, a restart) does not keep the queue busy forever. The day counts from the job's last progress, not from its start: a first scan of a large library on slow hardware can take several days and is left alone as long as it keeps importing photos. Not released yet: up to 1.3.0, every job was marked failed 24 hours after it started.

Variable.env keyDefaultWhat it does
STUCK_JOB_HOURSstuckJobHours24Hours without progress after which a job counts as stuck. Raise it only if a single photo can take longer than this on your machine.

Internal service address​

The backend talks to its helper services (thumbnails, metadata, faces, tags, captions, OCR, search) over HTTP inside the container. They have no authentication, so they listen on 127.0.0.1 only and cannot be reached from outside the container.

VariableDefaultWhat it does
SERVICE_HOST127.0.0.1Address the helper services listen on. The backend always calls them on 127.0.0.1, so there is rarely a reason to change it; anything else exposes unauthenticated services to whatever can reach that address.

Releases before 1.2.0 listen on 0.0.0.0.

Each helper service has its own port. In Docker nothing else runs in the container, so they never need to move. On the Windows standalone build and other installs without Docker, another program on the PC may already use one of them; the service then cannot start, the log and the Services list in the Admin area name the program holding the port, and one of these variables moves the service to a free port. Set it for the whole backend (every process reads it), not for one service alone. Not released yet.

VariableDefaultService
SIDECAR_PORT_IMAGE_SIMILARITY8002Similar photos and the second step of semantic search
SIDECAR_PORT_THUMBNAIL8003Thumbnails of RAW files
SIDECAR_PORT_FACE_RECOGNITION8005Face detection and recognition
SIDECAR_PORT_IMAGE_CAPTIONING8007Captions
SIDECAR_PORT_EXIF8010Reading and writing metadata (ExifTool)
SIDECAR_PORT_TAGS8011Tags and search embeddings (OpenCLIP)
SIDECAR_PORT_OCR8012Text recognition

A value that is not a port number between 1 and 65535 stops the backend with a message naming the variable.

Which Nextcloud servers may be contacted​

With the Nextcloud integration switched on, every user enters their own Nextcloud server address, and it is the backend that connects to it. So that this cannot be used to make the backend reach things only it can reach, LibrePhotos looks up the address before each connection and before saving it, and refuses it if the host name resolves to any of these:

  • loopback addresses (localhost, 127.0.0.1, ::1), which would be the backend's own services
  • link-local addresses (169.254.0.0/16, fe80::/10), which include the metadata endpoint of cloud providers
  • unspecified, multicast and reserved addresses

Redirects from the Nextcloud server are checked the same way. Only http:// and https:// addresses are accepted.

Addresses on a private network - your LAN (192.168.x.x, 10.x.x.x), a Docker network such as http://nextcloud in the same Compose project, a Tailscale address (100.64.0.0/10) or an IPv6 unique local address - are allowed, because that is where most self-hosted Nextcloud instances live.

Variable.env keyDefaultWhat it does
NEXTCLOUD_ALLOW_PRIVATE_ADDRESSESnextcloudAllowPrivateAddressestrueSet it to false if users on your instance should only be able to connect to Nextcloud servers on the public internet, for example when you host LibrePhotos for people you do not know and do not want them to reach anything on your local network.
NEXTCLOUD_CA_BUNDLEnextcloudCaBundle(empty)Path, inside the container, of a PEM file with the certificates to check Nextcloud servers against, for a Nextcloud with a self-signed certificate or one from your own certificate authority. Empty means the public certificate authorities. With a path set, only the certificates in the file are trusted for Nextcloud, and a missing or unreadable file stops browsing and scanning with a message naming this variable. Certificate checking cannot be switched off. See Nextcloud with a self-signed certificate. Not released yet.

A Nextcloud on the same machine as LibrePhotos cannot be reached as localhost; use its Docker service name, or host.docker.internal for one running directly on the host.

Logging​

The backend writes its log files into the directory named by BASE_LOGS. librephotos.log is the one to look at first (ownphotos.log in 1.3.0 and older, see Upgrading); it is also downloadable from the Admin Area (see Internal files and Library).

VariableDefaultWhat it does
BASE_LOGS/logs/Directory the log files are written to. It is created on startup if it is missing; if it cannot be created the backend stops with an error naming the path it tried, rather than starting up with no log at all. With the bundled Compose setup you do not need to set this - the path inside the container is fixed at /logs, and the host directory behind it follows data from your .env (${data}/logs:/logs). Set it when you run the backend outside that setup, for example directly on the host.
LOG_LEVELINFOLowest level that gets written. One of CRITICAL, ERROR, WARNING, INFO, DEBUG. An unrecognised value falls back to INFO and says so in the log.
LOG_LEVELS(empty)Per-logger overrides of LOG_LEVEL, as comma-separated logger=LEVEL pairs, for example api.directory_watcher=DEBUG,nextcloud=WARNING. Logger names are Python module paths, so a package name covers every module in it. A malformed entry is skipped and says so in the log.
LOG_TO_CONSOLE1Also send the log to the container's standard output, where docker logs backend (or kubectl logs) can read it. Set it to 0 to write only the file. Accepted "on" values are the same as for the feature switches above.

LOG_LEVEL=DEBUG adds per-photo and per-request detail. That is what you want while reproducing a bug, but on a large library the file grows quickly - put it back afterwards. If you already know which part is misbehaving, LOG_LEVELS=api.directory_watcher=DEBUG (the scanner, in this example) keeps the rest of the log at its usual volume.

Keep LOG_TO_CONSOLE on if the log directory does not survive a restart. On Kubernetes /logs is often an emptyDir, and then standard output, kept by a cluster log collector, is the only copy of the log that can outlive the pod.

None of these is in the bundled .env file; pass them to the backend container directly:

services:
backend:
environment:
- LOG_LEVEL=DEBUG
warning

In 1.3.0 and older, secret.key lives in BASE_LOGS, next to the log files. Zipping that whole folder for a bug report hands out your Django secret key, which is what every session and token on your instance is signed with. Attach ownphotos.log by itself instead. Deleting secret.key is not a fix either: it logs every user out.

Releases after 1.3.0 keep the key in protected_media instead and move an existing BASE_LOGS/secret.key there on their first start; see Internal files.

Debug mode​

VariableDefaultWhat it does
DEBUG(off)Set to exactly 1 to run the development settings (librephotos.settings.development), with Django's debug mode on, which shows tracebacks to whoever gets an error, and uvicorn with --reload. true does not count. The bundled Compose file sets DEBUG=0.

This is for working on LibrePhotos, not for a server other people use.

Data directory​

VariableDefaultWhat it does
BASE_DATA/The root of the data LibrePhotos keeps: protected_media/ (thumbnails, converted videos and the machine learning models) and, with SQLite, db/. The containers use /; the Windows standalone app points it at its data folder.
PHOTOS$BASE_DATA/dataThe top folder a library may be scanned from: scan directories must be inside it. The Windows standalone app uses your home folder unless you pass --photos.

Secret key​

Variable.env keyDefaultWhat it does
SECRET_KEYshhhhKey(generated)The Django secret key that signs sessions and tokens. Set it and LibrePhotos uses it. Leave it unset and the backend generates one and keeps it in secret.key in protected_media (in the logs directory in 1.3.0 and older; a newer release moves it on its first start, see Internal files). Changing the key signs every user out.

Telling LibrePhotos its own public address​

FRONTEND_BASE_URL is the URL your users actually browse to, for example https://photos.example.com (no trailing slash). Leave it unset and LibrePhotos falls back to the origin each request appears to have arrived on.

That fallback is wrong behind the bundled proxy, which forwards /api/ to the backend with the Host header rewritten to backend — a name that only resolves inside the Docker network. The rewrite is deliberate (it means Django's host validation passes whatever domain you use), but it does mean the backend cannot work out its own public address on its own.

So set this whenever LibrePhotos has to hand a URL to something outside the container:

  • Password-reset emails — the link in the email. Without it the fallback can produce a link pointing at http://backend/..., which no mail recipient can open. The unified and standalone images accept any Host header, so there the request's origin is only used when it is listed in CSRF_TRUSTED_ORIGINS; otherwise no reset email is sent and the backend log asks for FRONTEND_BASE_URL. A link built from a Host header the requester chose would hand the reset token to whatever site they named.
  • Single sign-on — the OAuth redirect_uri sent to your identity provider, which the browser has to follow and the provider has to recognise. SSO refuses to start rather than send a broken one, so this is effectively required for OIDC.

Outgoing email​

The mail server itself is set in the app, under Admin area → Site Settings → Email. The variable here only supplies a default. The password-reset rate limit is under Sign-in rate limits.

VariableDefaultWhat it does
DEFAULT_FROM_EMAILLibrePhotos <no-reply@localhost>The sender address for mail when the Email settings have no From address. A From address saved there takes precedence.

Sign-in rate limits​

Not released yet: up to 1.3.0, only the password-reset request was rate limited.

Signing in, refreshing a token, setting a new password from a reset link and signing up are rate limited per client. A client that goes over a limit gets 429 Too Many Requests with a Retry-After header until its counter runs out. Sign-in counts per client address and username together, so people who share an address (a household behind one router) only use up each other's attempts at the same account, and someone guessing passwords for one account does not lock the others out. An admin adding users in the user editor is not limited. Wrong passwords in Basic auth requests (curl -u) come out of the same per-address-and-username budget as sign-in; requests with the right password are not counted.

VariableDefaultWhat it limits
AUTH_THROTTLE_RATE10/minuteSign-in (/api/auth/token/obtain/) and wrong passwords in Basic auth requests, together, per address and username; setting the new password from a reset link; sign-up and first-time setup.
TOKEN_REFRESH_THROTTLE_RATE60/minute/api/auth/token/refresh/, per address. Every open tab and the mobile app refresh their token every few minutes, and a refused refresh signs the web app out, so keep this well above the number of devices behind one address.
PASSWORD_RESET_THROTTLE_RATE5/hourRequesting a password-reset email, per address.
NUM_PROXIES1 (Compose, Kubernetes); 0 (unified image, Windows standalone)How many reverse proxies in front of the backend append the client address to X-Forwarded-For. See below.

A rate is a number, a slash and second, minute, hour or day (only the first letter counts, so 10/m works too).

The backend counts per client address, which it takes from the X-Forwarded-For header: the entry the proxy closest to it added, NUM_PROXIES entries from the end. Entries before that were written by the client and are ignored, so a client cannot pick its own address. Set it to match your setup:

  • Bundled Compose setup: 1, the default. The proxy container appends the client address.
  • Your own reverse proxy (Traefik, Caddy, nginx, a load balancer) in front of the bundled proxy: 2, as long as your proxy also appends to X-Forwarded-For (they all do by default). The Kubernetes manifests set 2 for the ingress controller in front of the proxy container.
  • Unified image or Windows standalone reached directly: 0, their default. The address of the connection is used and the header is ignored.
  • Unified image behind your own reverse proxy: 1.

With a value that is too low, every client shows up with your proxy's address and shares its counters, so the limits are reached sooner; sign-in still counts each username separately. With a value that is too high, clients can choose the address they are counted under and get around the limits.

The counters are kept in the memory of each backend process. With WEB_CONCURRENCY above 1, each process counts on its own, so a client gets up to that many times the rate before every process refuses it, and a restart of the backend starts every counter again.

Trusted origins for the Django admin​

CSRF_TRUSTED_ORIGINS (csrfTrustedOrigins in .env) lists the addresses the Django admin at /api/django-admin/ accepts logins from. Set it to the URL your users browse to, including the scheme, for example https://photos.example.com. Several origins are separated by commas:

csrfTrustedOrigins=https://photos.example.com,https://photos.internal.lan

Each entry must match the address in the browser exactly, scheme and port included. The photo app itself authenticates with JWT and is not affected by this setting.

note

Releases before 1.2.0 treated the whole value as a single origin, so a comma-separated list only worked with the unified image.

Secure auth cookies​

Not released yet: up to 1.3.0, the auth cookies were never Secure.

VariableDefaultWhat it does
AUTH_COOKIE_SECURE0Gives the sign-in cookies (jwt, and access/refresh after an SSO login) the Secure flag, so the browser sends them over HTTPS only.

The backend already marks the cookies Secure when a request reaches it over HTTPS. Behind the bundled proxy it never does: the proxy talks plain HTTP to the backend and forwards its own scheme, which is http when your reverse proxy in front of it terminates TLS, and the backend does not trust a forwarded scheme anyway (Django's SECURE_PROXY_SSL_HEADER is not set). So:

  • Reached only over HTTPS (a domain behind Traefik, Caddy, nginx or a tunnel): set AUTH_COOKIE_SECURE=1 on the backend container.
  • Reached over plain HTTP, also only sometimes (http://192.168.1.10 on the LAN): leave it off. Browsers refuse a Secure cookie over plain HTTP; signing in still works, but photos and videos stop loading.

The other flags are fixed: see Cookie flags.

Database password​

DB_PASS (dbPass in .env) is the password of the PostgreSQL user in DB_USER. The bundled Compose file, the unified image instructions and the Kubernetes manifests all pass it. If it is missing, the backend still falls back to the old built-in default password so existing installs keep starting, but it now logs a librephotos.W001 warning on every start (in the migrate output). Set DB_PASS explicitly to silence it; the fallback may be removed in a future release.

Database connection​

These describe the PostgreSQL server. The bundled .env sets the first three; DB_PORT is not in it.

Variable.env keyDefaultWhat it does
DB_HOSTdbHostdbHost name of the PostgreSQL server. The bundled Compose file uses the db service.
DB_PORT(none)5432Port of the PostgreSQL server.
DB_NAMEdbNamedbName of the database.
DB_USERdbUserdockerThe user the backend connects as. Its password is DB_PASS.

Single-container image​

The single-container image (see Unified deployment) has three variables of its own. The Compose stack sets DB_BACKEND=postgresql and leaves the others unset.

VariableDefaultWhat it does
SERVE_FRONTEND(off)true makes the image serve the web app from Django on port 8001, with no proxy in front. Its entrypoint then uses the no-proxy settings.
DB_BACKENDsqlitesqlite keeps the database in BASE_DATA/db; postgresql connects with the DB_* variables above. The Compose stack always uses PostgreSQL.
DB_BACKUP_KEEP3With SQLite, how many of the database backups made before a migration stay in BASE_DATA/db/backups; older ones are deleted. Applies to the Windows standalone build too. Not released yet; see Automatic backups.

Demo mode​

DEMO_SITE=true runs LibrePhotos as a public demo: password changes submitted through the API are ignored. It is off unless set to true, 1, yes or on. (Before 1.2.0, any value other than exactly False - including false, 0 or an empty value - switched demo mode on.)

Hosting under a sub-path (subdirectory)​

By default LibrePhotos is served from the root of a domain (e.g. https://photos.example.com/). If you need to host it under a sub-path instead (e.g. https://example.com/photos/), the frontend has to know that base path.

The frontend reads it from the VITE_PUBLIC_URL build-time variable. It sets the base path the app's static assets are loaded from and the prefix its API calls are made against, and defaults to /. (PUBLIC_URL is also read by vite.config.ts, but only for the asset paths — set it on its own and the app's API calls still point at the domain root and fail, so use VITE_PUBLIC_URL.)

Because it is applied when the frontend is built, the prebuilt images are served from /. To use a sub-path you need to build the frontend yourself with the variable set, for example:

# building the frontend directly
VITE_PUBLIC_URL=/photos yarn build
note

Do not add a trailing slash: use /photos, not /photos/. The app joins this value directly with /api and /login, so a trailing slash produces doubled paths such as /photos//api. Vite adds the slash it needs for the asset URLs on its own.

The bundled proxy — not the frontend container — is the one published to the host (on httpPort, 3000 by default), and its nginx config matches /api, /media and the other backend paths anchored at the root. So an outer reverse proxy has to strip the sub-path before handing requests on to it, for example:

location /photos/ {
# the trailing slash on proxy_pass strips the /photos/ prefix
proxy_pass http://127.0.0.1:3000/;
}

Alternatively, re-anchor the location blocks in deploy/docker/proxy/nginx.conf under the sub-path yourself.

warning

Sub-path hosting is not fully working today. VITE_PUBLIC_URL re-bases the assets and the API calls, but the client-side router (apps/frontend/src/App.tsx) is created without a matching base path, so once the app has loaded its in-browser routes are still matched against root-relative paths and navigation breaks. Fixing this needs a change in the frontend, not just the build variable.

Changing the container names​

Container names are set directly in docker-compose.yml through the container_name keys (proxy, db, frontend and backend). Edit those values if you want different names, for example when running more than one LibrePhotos stack on the same host:

services:
backend:
container_name: myphotos-backend

These are the names you pass to docker exec, docker logs and similar commands.

They are separate from the service names — the proxy:, db:, frontend: and backend: keys themselves — which the containers use to reach each other over the Compose network. The bundled proxy resolves frontend and backend by service name, so leave those keys as they are.

note

Older guides mention a make rename helper that rewrote these names from your .env file. It was removed together with deploy/Makefile; edit docker-compose.yml directly instead.

Backend host name and proxy user IDs​

VariableDefaultWhat it does
BACKEND_HOSTbackendThe host name the bundled proxy gives the backend in the Host header. Django accepts it in ALLOWED_HOSTS, and requests carrying it count as coming from inside the deployment. Change it only together with the backend service name, which the proxy resolves.
WEBSERVER_UID101The user ID the proxy's nginx runs as inside its container. The media diagnostics use it to explain why a file cannot be served.
WEBSERVER_GID101The group ID, as WEBSERVER_UID. Set both only if you rebuilt the proxy to run as another user. The Kubernetes config sets 65534, the user the Pod runs as.

Old environment variables​

These are now site settings: setting them here only supplies the default used on the first set-up, after which the value lives in the site settings and any change you make there takes precedence.

# Comma delimited list of patterns to ignore (e.g. "@eaDir,#recycle" for synology devices)
skipPatterns=
# Allow uploading files
allowUpload=true
# Where place names come from: exiftool (offline, the default), nominatim,
# mapbox, maptiler, tomtom or opencage.
mapApiProvider=exiftool
# API key for the geocoding map provider. Only needed if you switch the provider
# to Mapbox, MapTiler, TomTom or OpenCage; ExifTool and Nominatim
# (OpenStreetMap) need no key.
mapApiKey=

The same settings have environment variables, which set the first-run default in the same way:

Variable.env keyDefaultWhat it does on the first set-up
SKIP_PATTERNSskipPatterns(empty)The patterns of files and folders to ignore, as in the example above.
ALLOW_UPLOADallowUploadtrueWhether uploads are allowed. An empty value leaves them allowed.
MAPBOX_API_KEYmapApiKey(empty)The key for the geocoding provider, as in the example above.
MAP_API_PROVIDERmapApiProviderexiftoolThe geocoding provider: exiftool (offline, no key needed), nominatim (no key needed), mapbox, maptiler, tomtom or opencage. It applies only until the setting is first stored, which happens with the first place lookup or a save in Site Settings, so changing it on an install that has already geocoded photos does nothing: change Map Provider in Site Settings instead. Not released yet (comes after 1.3.0) The default was nominatim up to 1.3.0, and the compose files did not pass it on.
MAP_TILE_PROVIDER(none)photoprismThe map background behind the photo pins. You can change it afterwards in Site Settings, without a restart.

Removed variables​

Not released yet: DEFAULT_FAVORITE_MIN_RATING no longer exists as a variable. Up to 1.3.0 it set the rating a photo needs to count as a favourite for users created afterwards (default 4). It is now always 4, and a value you set is ignored. Users who already exist keep their own value, which they change under Settings, in Minimum image rating to interpret as favorite (see Settings). If your .env sets it, you can remove the line. A value other than 4 also made the models disagree with the database migrations; that no longer happens.

Admin account variables​

userName, userPass and adminEmail are not site settings. They are still live environment variables, wired to ADMIN_USERNAME, ADMIN_PASSWORD and ADMIN_EMAIL in docker-compose.yml:

# Username for the Administrator login.
userName=admin
# Password for the administrative user you set above.
userPass=admin
# Email for the administrative user.
adminEmail=admin@example.com

Whenever both userName and userPass are set, the entrypoint runs createadmin on every container start. All images do this the same way, including the single-container image:

  • On the first start it creates the superuser. The username is lower-cased, and adminEmail is used for the account. A blank adminEmail creates the account without an address; you can add one in the UI later.
  • On every later start the existing user's password is overwritten with userPass. Leaving userPass set therefore resets the admin password on each restart — a password you changed in the UI will not survive a docker compose restart. This is the same behaviour described under How to change the admin password, when you can't log in.
  • adminEmail is only applied when the account is first created; on later starts createadmin ignores it (and says so in the log when it differs), so the address you set in the UI stays. Releases up to 1.3.0 had the single-container image write adminEmail to the account on every start, clearing the address when adminEmail was blank.

To stop the password from being reset, clear userPass (or userName): the entrypoint then skips createadmin. Releases up to 1.3.0 still ran it with only userName set, and it aborted on each start with "Admin password cannot be empty".