Docker Compose¶
The compose stack is the canonical Filearr deployment. This page walks through the prerequisites, the compose file, the environment variables you must set, and the two gotchas that bite people.
Prerequisites¶
- Docker Engine with the Compose v2 plugin (
docker compose, not the olddocker-composebinary — the stack uses profiles and healthcheck conditions). Any current Engine release qualifies. gitto fetch the source. The shipped compose file builds the images locally (build: .), so the full source tree is required; pre-built multi-arch images also exist atghcr.io/filearr/filearr/ghcr.io/filearr/filearr-agentif you'd rather swapbuild:forimage:.- Your media reachable on the host at a stable path (local disk, or an SMB/NFS/rclone mount) — mounted read-only into the containers.
Quick start¶
git clone https://github.com/filearr/filearr.git && cd filearr
cp .env.example .env # then edit the secrets (see below)
docker compose up -d # builds images on first run, then starts the stack
The app container runs the idempotent DB bootstrap itself on start (waiting
for Postgres), so docker compose up -d is the whole quick start. Running the
bootstrap explicitly still works — useful for watching migration output — and
FILEARR_AUTO_INIT_DB=false on the app service disables the automatic run:
The web UI is then at http://localhost:8484 (first visit shows the one-time
create-the-administrator-account screen — see
First run) and the interactive API docs at
http://localhost:8484/api/docs.
The compose file, service by service¶
app— the FastAPI application (REST API + the built SPA), listening on8000in-container and published as8484on the host. It mounts your media read-only at/data/mediaand a./configvolume for caches/thumbnails.worker— the Procrastinate worker. Runs the scan, extract, index and maintenance jobs. Concurrency and which queues it serves are env-driven:FILEARR_WORKER_CONCURRENCY— parallel jobs per worker (default 4).FILEARR_WORKER_QUEUES— comma-separated queues, or empty for all.- Scale out extraction with
docker compose up -d --scale worker=3, or pin a dedicated worker to theextractqueue (see the comments indocker-compose.yml). Extract jobs run at a lower priority than scan control, so a freshly triggered scan is never stuck behind a big extract backlog.
postgres—postgres:18.4. The source of truth and the job queue.meilisearch—getmeili/meilisearch:v1.53.0, analytics disabled, master key from.env. Its LMDB store lives on a local named volume, never on the media mount.caddy(optional TLS front) andstep-ca(optionalagentsprofile) — see Proxmox and Distributed agents.watcher(optional) — filesystem watch mode. Watch is local-disk only; inotify is unreliable over SMB/NFS, so scheduled polling scans are the default for network mounts.
The media bind uses rslave propagation¶
The media bind is a long-form bind with bind.propagation: rslave. This is
deliberate: if the underlying FUSE/SMB mount is remounted on the host (for
example after an rclone restart), a running container sees the new mount
instead of a dead endpoint. Without it you get OSError: EIO after the mount
flaps. Keep it.
Environment variables¶
Start from .env.example and change at least these (generate the two
passwords with openssl rand -hex 24 or
python -c "import secrets; print(secrets.token_urlsafe(32))" — they are
service credentials nobody types):
POSTGRES_PASSWORD=change-me-too
MEILI_MASTER_KEY=change-me
FILEARR_DATABASE_URL=postgresql+psycopg://filearr:change-me-too@postgres:5432/filearr
FILEARR_PROCRASTINATE_DSN=postgresql://filearr:change-me-too@postgres:5432/filearr
FILEARR_MEILI_URL=http://meilisearch:7700
FILEARR_MEILI_MASTER_KEY=change-me
FILEARR_AUTH_ENABLED=true
# Host path to your media, mounted read-only at /data/media
MEDIA_PATH=/mnt/user/data/media
A few more you will likely want:
FILEARR_SECRET_KEY— the envelope key used to encrypt alert-channel secrets (AES-GCM). Required to create alert channels; when unset the alert-channels API returns 503 rather than storing plaintext. Generate one withpython -c "import secrets; print(secrets.token_urlsafe(48))". It is never rotated automatically (rotating orphans already-encrypted secrets).FILEARR_SOURCE_URL— the AGPL section 13 "Source" link shown in the UI footer. Point it at your source if you run a fork.FILEARR_AUTH_ENABLED=false— turns authentication off (handy for a first look; do not run open on an untrusted network).
The full, grouped list is in the Configuration reference.
Optional features are declared explicitly
The bundled docker-compose.yml sets every optional feature
knob —
FILEARR_SEMANTIC_ENABLED, FILEARR_CONTENT_SNIFF_ENABLED,
FILEARR_UPDATE_CHECK_AUTO, FILEARR_THUMBNAIL_BUDGET_GB,
FILEARR_LOG_DB_ENABLED, FILEARR_AGENTS_ENABLED, plus the optional
HF_TOKEN (blank = anonymous model download) — in the environment:
map of both app and worker, with its safe default, written as
${VAR:-default}. So they are visible in the deployment file rather than
inferred, and because the fallback only applies when the variable is
unset, a value you put in .env still wins. .env.example lists the
same six.
Secrets never belong in a committed file
FILEARR_SECRET_KEY, MEILI_MASTER_KEY, POSTGRES_PASSWORD, and (for the
agent CA) FILEARR_CA_PROVISIONER_JWK / FILEARR_PROXY_SHARED_SECRET are
secrets. Keep them in .env (the compose env_file), never in the committed
compose file or in a deploy config that gets checked in.
The bootstrap: init_db.py¶
The app container runs this automatically on every start (retrying while
Postgres comes up; FILEARR_AUTO_INIT_DB=false opts out) — invoke it manually
only when you want to watch migration output or manage migrations yourself:
This is idempotent and safe to re-run. It:
- Creates or migrates the schema. On a brand-new database it stamps the Alembic
baseline and runs migrations to head; on a database that has tables but no
alembic_versionrow it stamps the baseline before migrating. - Applies the Procrastinate job-queue schema (checking first — the Procrastinate
apply_schemastep is not itself idempotent, so Filearr guards it). - Ensures the Meilisearch index exists.
Two gotchas that will bite you¶
PostgreSQL 18 mounts at /var/lib/postgresql, not .../data
The Postgres 18 Docker image changed the volume convention: mount the
parent directory /var/lib/postgresql, not /var/lib/postgresql/data.
The shipped compose file already does this. If you copy an older compose file
from elsewhere, fix this or Postgres will not persist correctly.
PYTHONPATH=/app is required
Scripts and the Procrastinate CLI in the image need PYTHONPATH=/app. It is
set in the image's ENV; do not drop it if you customize the entrypoint or
the worker command.
HTTPS with the bundled Caddy¶
The compose file ships an optional caddy TLS front (a custom build:
Caddy + the Cloudflare DNS and layer-4 plugins) publishing 443/8443. Two
modes, chosen by which Caddyfile the service loads:
- Internal CA (LAN, the default
Caddyfile.internal): runs as part of the normaldocker compose up -dand serveshttps://<host>:8443with certificates from Caddy's own local CA; each client browser must trust that CA root once (from thecaddy_datavolume underpki/authorities/local) or click through the warning. - Real certificates (DNS-01 via Cloudflare): set in
.envFILEARR_CADDYFILE=Caddyfile.acme,FILEARR_TLS_DOMAIN=<your.zone>,FILEARR_ACME_EMAIL=<you@example.com>, and aCLOUDFLARE_API_TOKENscoped to DNS edits for the zone, thendocker compose up -d caddyto reload. DNS-01 issuance needs no inbound port from the internet — LAN-only deployments get real certificates.
Any other reverse proxy you already run (SWAG, NPM, Traefik…) works exactly
as well — point it at app:8000 / host port 8484 and forward
X-Forwarded-*. Then tell the app to trust that proxy with
FILEARR_TRUSTED_PROXIES=<its IP or CIDR> — forwarding the header alone does
nothing, because X-Forwarded-For is ignored from untrusted peers (the bundled
Caddy is exempt: it proves itself with the shared secret). Without it every
login in the audit log shows the proxy's address. Serve HTTPS before relying on
logins: the session cookie is Secure-only.
Verifying it works¶
docker compose ps # every service Up; postgres healthy
docker compose logs app --tail 20 # look for the init_db bootstrap + uvicorn startup
curl http://localhost:8484/api/v1/health # -> 200 {"status":"ok"}
Then do the first-run flow in the browser: create the
admin account, add a library, scan it, and search. The equivalent API calls
need credentials once the admin exists (auth is on by default) — a session
cookie from login, or FILEARR_AUTH_ENABLED=false in a throwaway dev stack:
# dev stack with auth disabled ONLY:
curl -X POST http://localhost:8484/api/v1/libraries \
-H 'Content-Type: application/json' \
-d '{"name":"media","root_path":"/data/media"}'
curl -X POST http://localhost:8484/api/v1/libraries/<id>/scan
Results appear in search as the scan commits batches; heavier metadata extraction back-fills over the following minutes.
After it's up¶
Follow the shared first-run guide — first admin, first
library, TLS, Postgres backups (scripts/backup.sh), and where to go next
(agents, alerts, upgrades).