15 KiB
Installation and operations
From nothing to a running instance pointed at your photo library, and everything you need afterwards: upgrading, backing up, restoring, and understanding a refusal.
Two ways to install. Container is the deployment this project builds and ships; host is what you want for development or a single machine you already manage. Both run the same two processes against the same database.
Read what it will not do first. Several of the steps below only make sense once you know which rules the application is keeping.
Before you start
| you need | version | why |
|---|---|---|
| Python | 3.12 or newer | the application |
exiftool |
13.x (the image pins 13.25+dfsg-1) |
every EXIF read and write |
immich-go |
0.32.0 (pinned in the image) | the upload stage only |
| Docker + the compose plugin | any current | the container installation only |
| a vision provider key | — | the analysis stage only |
You also need a photo library you can afford to be wrong about. Take a backup of it
before pointing anything at it for the first time, and consider running with
PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL on.
Host installation
python3.12 -m venv .venv
.venv/bin/pip install -e ".[vision]" # drop [vision] for a review-only install
Configure it. Every setting is an environment variable; a .env file in the working
directory is read at startup:
cp .env.example .env && $EDITOR .env
.env.example lists every variable with its default and no values at all. For a
loopback installation you need exactly one:
PHOTO_PIPELINE_LIBRARY_ROOTS=/home/you/Pictures
Then migrate and start the two processes:
.venv/bin/python -m photo_pipeline migrate # create or upgrade the database
.venv/bin/python -m photo_pipeline serve # API and browser app on 127.0.0.1:8000
.venv/bin/python -m photo_pipeline worker # second terminal
Both processes are required. serve enqueues work and renders the application;
nothing is scanned, scored, analysed, renamed, uploaded, or archived without a
worker. Open http://127.0.0.1:8000/app/ and confirm the workflow page loads.
The configuration file
.env, or any path named by PHOTO_PIPELINE_ENV_FILE. It is parsed, never executed:
KEY=value lines, # comments, optional quotes, no interpolation and no export. A
configuration file that can run code is a configuration file that can be a
vulnerability.
Anything already exported in the shell wins. The file is your standing configuration; the environment is the override for one run.
The archived CLI's names still work, so an existing photo_analyzer.env can be used
as it is:
| in the file | applied as |
|---|---|
LLM_API_KEY / GEMINI_API_KEY |
OPENAI_API_KEY |
LLM_BASE_URL |
OPENAI_BASE_URL |
LIBRARY |
PHOTO_PIPELINE_LIBRARY_ROOTS |
.env and *.env are gitignored and refused by the repository's safety checks. The
file holds a real key; it must never be committed.
Container installation
cp .env.example .env && $EDITOR .env
docker compose up -d --build
The composition is one serve container, one worker container, a one-shot
migrate that both wait for, one bind-mounted library, and one named data volume.
Three variables it cannot start without:
| variable | is |
|---|---|
PHOTO_PIPELINE_LIBRARY_HOST_PATH |
the library on this host |
PHOTO_PIPELINE_LIBRARY_ROOTS |
where that library is mounted inside the container |
PHOTO_PIPELINE_ACCESS_SECRET |
required, because publishing a port means the app is reachable from outside the container |
Set PHOTO_PIPELINE_UID and PHOTO_PIPELINE_GID to the owner of the library: what
the containers rename and rewrite keeps that ownership.
The data volume
The data volume holds the database, its write-ahead log, the thumbnail cache, the
operation journals, and the backups. It must stay on a local filesystem. SQLite
in WAL mode needs real local locking, so NFS, SMB, and network volume drivers do not
slow it down — they corrupt it. The library bind mount has no such restriction.
Ports and reaching it
The API port is published to 127.0.0.1 unless PHOTO_PIPELINE_PUBLISH_ADDRESS says
otherwise. Being reachable was the authentication in earlier versions: whoever could
open the port owned the library. So the moment the application answers to anything but
loopback — a hostname, a proxy, 0.0.0.0 — the access secret becomes mandatory and
serve refuses to start without it rather than publishing your photographs.
Behind a reverse proxy:
PHOTO_PIPELINE_ALLOWED_HOSTS=photos.example.com
PHOTO_PIPELINE_ACCESS_SECRET=… # python -c 'import secrets; print(secrets.token_urlsafe(32))'
PHOTO_PIPELINE_TRUSTED_PROXIES=10.0.0.2 # only the proxy's own address
X-Forwarded-Proto and X-Forwarded-Host are believed only from a trusted-proxy
address, so a client cannot declare its own origin. The browser asks for the secret
once per tab. Wrong secrets are rate-limited and logged with the caller's address
only. The health endpoints stay open so an orchestrator can restart the container;
nothing else is.
Deployment
The stack is managed from git by Portainer, which redeploys on a webhook after CI
publishes an image built from main. Runtime secrets live in the Portainer stack's
environment rather than in the repository, so rotation happens in one place and
reading the repository discloses nothing.
The settings
Every variable, its default, and whether it is a secret. .env.example is the same
list in copyable form.
Library and data
| variable | default | meaning |
|---|---|---|
PHOTO_PIPELINE_LIBRARY_ROOTS |
none | the library boundary, os.pathsep-separated. No path outside these roots is ever read or written |
PHOTO_PIPELINE_DATA_DIR |
data |
database, WAL, thumbnail cache, journals, backups. Never inside the library |
PHOTO_PIPELINE_DB_PATH |
<data dir>/photo_pipeline.db |
the database file, if it must live elsewhere |
PHOTO_PIPELINE_ENV_FILE |
.env |
where to read the configuration file from |
Serving and the trust boundary
| variable | default | meaning |
|---|---|---|
PHOTO_PIPELINE_HOST |
127.0.0.1 |
bind address |
PHOTO_PIPELINE_PORT |
8000 |
port |
PHOTO_PIPELINE_ALLOWED_HOSTS |
none | comma-separated names the app answers to besides loopback. Naming one makes the access secret mandatory |
PHOTO_PIPELINE_ACCESS_SECRET |
none | secret. Traded for the session cookie at GET /api/v1/session |
PHOTO_PIPELINE_TRUSTED_PROXIES |
none | comma-separated peer addresses whose forwarded headers may be believed |
PHOTO_PIPELINE_MAX_REQUEST_BYTES |
1048576 |
largest request body accepted |
Logging
| variable | default | meaning |
|---|---|---|
PHOTO_PIPELINE_LOG_LEVEL |
INFO |
standard Python levels |
PHOTO_PIPELINE_LOG_FORMAT |
json |
json or text |
Limits
| variable | default | meaning |
|---|---|---|
PHOTO_PIPELINE_THUMBNAIL_CACHE_QUOTA_BYTES |
500000000 |
cache quota; over it is a diagnostics warning |
PHOTO_PIPELINE_THUMBNAIL_MAX_PIXELS |
100000000 |
refuse to decode anything larger. This is the decompression-bomb guard |
PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES |
1000000000 |
free space an archive destination must keep beyond the transfer itself |
The safety gate
| variable | default | meaning |
|---|---|---|
PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL |
false |
refuse every mutating request until a read-only dry run of this library has been produced and approved |
External services
| variable | default | meaning |
|---|---|---|
PHOTO_PIPELINE_VISION_API_KEY |
none | secret. Without it the analysis stage cannot run |
PHOTO_PIPELINE_IMMICH_SERVER_URL |
none | the Immich server |
PHOTO_PIPELINE_IMMICH_API_KEY |
none | secret. |
PHOTO_PIPELINE_IMMICH_GO_BINARY |
immich-go |
the uploader, found on PATH |
Composition only
Read by docker-compose.yml, not by the application:
PHOTO_PIPELINE_IMAGE, PHOTO_PIPELINE_LIBRARY_HOST_PATH,
PHOTO_PIPELINE_PUBLISH_ADDRESS, PHOTO_PIPELINE_UID, PHOTO_PIPELINE_GID.
A secret is never written to a log, never returned by the API, never stored in the
database, and never recorded in a backup manifest — a manifest says configured, not
the value. Do not put a real key in an example, a ticket, or a screenshot.
First run checklist
Not "it started" — verified.
- The database is at the current revision.
python -m photo_pipeline migrateexits 0. - The application answers.
curl -s localhost:8000/api/v1/health/readyreturns 200. - The library was found. Open the app, run a scan from the Inventory view, and
check the count against what you expect. Anything under
_IGNORE/must be missing from it — that is the exclusion working, not a bug. - The worker is claiming. The scan job reaches
succeeded. If it staysqueued, no worker is running. - Diagnostics are clean.
python -m photo_pipeline diagnosticsreports free space and an emptywarnings. - Nothing was modified. The scan is read-only; your files' timestamps are unchanged.
Only then point it at the whole library.
Operations
Every operation is the same CLI, on a host or in the composition:
python -m photo_pipeline diagnostics
docker compose run --rm --no-deps api diagnostics
--no-deps keeps a one-off command from starting a second stack; api is only the
service it borrows the image and mounts from.
Upgrading
python -m photo_pipeline backup --reason before-upgrade- Pull the new version (or
docker compose pull && docker compose up -d). - Migrations run by themselves at startup, and a pending schema change is
snapshotted first. If the upgrade fails, the previous database and its
pre-migrationbackup are both intact, and the error log names the backup directory.
An up-to-date database is not backed up again on every start.
Backing up
python -m photo_pipeline backup --reason weekly --keep 7
python -m photo_pipeline verify-backup data/backups/<name>
Backups go through SQLite's online backup API, never a file copy: with WAL enabled
the .db file alone is missing every committed page still in the write-ahead log.
Each backup is a directory holding the snapshot and a manifest.json — schema
revision, SHA-256, row counts, the archive media the library depends on, and which
settings were configured.
verify-backup runs PRAGMA integrity_check and PRAGMA foreign_key_check,
compares the snapshot's SHA-256 against the manifest, and re-counts every table it
recorded. Bit rot, a truncated copy, and a "repaired" snapshot all fail it.
--keep N prunes the oldest and never the newest. The same is available at
GET /api/v1/diagnostics, GET|POST /api/v1/backups,
GET /api/v1/backups/{name}/verify, and POST /api/v1/backups/prune.
Restoring
Restore is deliberately not an API call. It replaces the state of an installation, so it belongs to a stopped one and a person at a terminal.
- Stop
serveandworker. python -m photo_pipeline verify-backup data/backups/<name>— never restore an unverified snapshot, andrestorewill refuse one anyway.python -m photo_pipeline restore data/backups/<name> --into /path/to/fresh-data. A target that already holds a database is refused; recovering in place means moving the old data directory aside first.- Point
PHOTO_PIPELINE_DATA_DIRat the restored directory and runmigrate. - Run an inventory scan, so paths are reconciled against the real library.
- Mount every archive location the manifest names before archiving again. The database records where archived originals are; it does not contain them.
Practise this against a copy before you need it.
Watching it
diagnostics reports the database, write-ahead log, thumbnail cache, uploader
reports, backups, and logs separately, with free space and warnings for low disk
(disk_low, disk_critical), a cache over its quota, a write-ahead log outgrowing
its database, and a legacy CLI writing the library.
Logs go to stdout in JSON by default — docker compose logs -f worker, or your
service manager's journal on a host. Uploader output is kept per attempt under the
data directory with the API key scrubbed line by line.
When it refuses to start
Every refusal below is deliberate. The application would rather stop and explain than guess about somebody's photographs.
| exit code | meaning | what to do |
|---|---|---|
1 |
the operation failed and said why — an unverifiable backup, an occupied restore target, an unknown benchmark profile | read the message; nothing was changed |
2 |
the library lock is held. Another serve or worker owns this data directory |
stop the other process. The message names the holder; a lock whose process is gone is taken over automatically |
3 |
a legacy CLI looks active. The frozen command-line tools are writing this library | stop them. --allow-legacy overrides and you own the outcome |
4 |
configuration refused. The application is reachable beyond loopback and no access secret is set | set PHOTO_PIPELINE_ACCESS_SECRET, or bind to loopback only |
5 |
a library root is not mounted (containers only) | fix the bind mount. Refusing is what stops the container writing into its own throwaway layer instead of your library |
Other things that look like faults and are not:
403 host_not_allowed— theHostheader is not a name inPHOTO_PIPELINE_ALLOWED_HOSTS. Add the real hostname; do not add*.409 lock_held— a mutating job is already running. One at a time is what makes a crash recoverable.409 rename_recovery_required— an interrupted rename is unresolved. Resolve it in the Renames view; unrelated mutations stay blocked until then, on purpose.- A job stuck in
queued— no worker is running. - An empty scan — check
PHOTO_PIPELINE_LIBRARY_ROOTS, and in a container check that the path is the container-side mount, not the host path.
What an installer must not work around
- One writer. Do not run two workers, and do not run the frozen CLI beside the application. Each is safe alone and destructive together — the lock is not bureaucracy.
_IGNORE/is never read. Do not "fix" the exclusion.- EXIF is verified before upload. Do not skip the checkpoint to make a stage finish; an unverified projection is exactly the case where the wrong bytes reach Immich.
- The data directory is not the library. Keep them apart, and keep the data directory on a local filesystem.
- Secrets stay in the environment. Not in the database, not in a log, not in a commit.