Docker

Paperful is a local workbench. The operator install is git clone and docker compose build, then docker compose up at http://127.0.0.1:8765. Build the image on your computer (paperful:local) with docker compose build. Zotero and headed browser login stay on the host; the container talks to them over the local API.

docker compose up keeps the GUI running. CLI one-shots are docker compose run --rm paperful … (the image is also a pack for unattended run, lint, ocr, report, attach once a write key exists). Python 3.12, Poppler (pdftotext), OCRmyPDF and Tesseract (eng), Playwright Chromium. Build mode is selected with PAPERFUL_IMAGE_MODE (see below). Contributors can still use uv on the host (see the README Develop section). Jobs in the GUI: Walkthroughs.

Durable data — config, custom playbook packs, out/, and state/ — still lives outside the container (and, by default, outside the git root).

Mounts (stranger path): host PAPERFUL_DATA → container /data (config.toml, state/, packs). Host PAPERFUL_OUT → /data/out (PDF mirror). Separate mounts by default. Compose does not mount Zotero Storage writable. See Terms.

What CI proves: the GitHub gate is pytest -m smoke on Ubuntu. It does not build the image. Build it yourself (docker compose build) and run doctor --no-guide with no live Zotero. Exit 2 means the Zotero row is red, which is a first-run stranger machine, not a failed Compose install. Putting that check back on CI is step 5 of the CI rebuild.

What still runs on the host

  • Zotero (GUI, local API, “Always allow”)

  • paperful session login scholar|ezproxy (headed Chrome/Edge on the host: campus SSO, Scholar CAPTCHA). An everyday-browser login does not transfer — see Sessions

  • paperful session login mendeley (Elsevier OAuth; the localhost redirect will not reach a container — see Mendeley)

  • EndNote .enl / .Data (desktop library on the host; Paperful never writes SQLite — see EndNote)

  • Any scripts that read out/ / state/ as files

  • Optional vale for critique --vale (not in the image; doctor ambers if missing)

The container talks to host Zotero over the local API (:23119). Session vaults and the write key must be the same state/ tree the container mounts (PAPERFUL_DATA).

Prerequisites

  • Docker with Compose v2

  • Zotero running on the host, local API enabled (Settings → Advanced → Allow other applications on this computer to communicate with Zotero)

  • For attach / fix-metadata --apply: complete the Zotero “Always allow” dialog once on the host (key is stored under state/)

  • For Scholar / EZProxy sessions: a host uv install so you can run paperful session login …, then reuse the mounted state/sessions/ from the container. doctor prints fix steps; use doctor --guide via docker compose run --rm paperful doctor --guide for step-by-step re-check.

Quick start

git clone --branch v1.9.0 https://github.com/glen-w/Paperful.git
cd Paperful
cp .env.example .env

Keep your current repo-local setup (no data move):

cp .env.example .env
# edit .env: PAPERFUL_DATA=. and PAPERFUL_OUT=./out
docker compose build
docker compose run --rm paperful doctor
# Without host Zotero this exits 2 (red Zotero row). That is expected; CI job docker
# asserts the same. Start Zotero, then re-run doctor.
docker compose up
# → http://127.0.0.1:8765  (Wanted). CLI one-shots stay compose run.
docker compose run --rm paperful run --collection interesting --dry-run

Or migrate into sibling mounts (recommended if you use Compose long-term; same layout as paperful.app):

cp .env.example .env   # PAPERFUL_DATA=../paperful-data PAPERFUL_OUT=../paperful-out
mkdir -p ../paperful-data/packs ../paperful-data/profiles ../paperful-data/state
mkdir -p ../paperful-out
cp config.minimal.toml ../paperful-data/config.toml   # or config.example.toml for the full file
# edit email / ezproxy_base / grey_playbooks_dir = "packs" as needed
# optional: move existing state/ into ../paperful-data/ and out/ into ../paperful-out/
docker compose build
docker compose run --rm paperful doctor

PAPERFUL_DATA / PAPERFUL_OUT in .env default to ../paperful-data and ../paperful-out. Compose mounts them at /data and /data/out. Use relative out_dir / state_dir / grey_playbooks_dir in config (e.g. "out", "state", "packs") so they resolve under the mounted data dir. Absolute host paths (e.g. /Users/...) will not land on the volumes.

Environment and override

File

Role

.env.example

Copy to .env — PAPERFUL_DATA, PAPERFUL_OUT, PAPERFUL_ZOTERO_HOST, PAPERFUL_IMAGE_MODE; optional PAPERFUL_OLLAMA_HOST, PAPERFUL_GROBID_HOST, and API keys

compose.yaml

Default service: compose up → GUI; compose run → CLI

compose.override.example.yaml

Optional local Compose tweaks

.env and compose.override.yaml are gitignored so your machine-local paths never land in the repo. Set PAPERFUL_DATA=. and PAPERFUL_OUT=./out to keep config/out/state in the repo; defaults keep data and the PDF mirror outside the git root as sibling dirs.

Image mode: light vs heavy

Mode

PAPERFUL_IMAGE_MODE

Python extras

Typical use

light

light (Compose default when unset)

[serve]

CI, small stranger image

heavy

heavy (set in .env.example)

[serve] [llm] [rag] [browser-agent]

Full local env

rag-docling (Torch) is not in either image; use host uv sync --extra rag-docling if you need Docling parsing.

# Local full pack (recommended after cp .env.example .env):
PAPERFUL_IMAGE_MODE=heavy docker compose build
# or: make docker-build-heavy

# Slim pack (CI / default when the variable is unset):
PAPERFUL_IMAGE_MODE=light docker compose build

Rebuild after changing the mode (docker compose build). The image label paperful.image.mode records which pack was built.

PAPERFUL_ZOTERO_HOST and PAPERFUL_OLLAMA_HOST default to host.docker.internal so Docker Desktop (macOS/Windows) can reach Zotero and Ollama on the host. Compose also adds extra_hosts: host.docker.internal:host-gateway for Linux Docker Engine. Paperful always sends Host: localhost:23119 — Zotero’s local API requires that header even when the TCP peer is host.docker.internal. Outside Docker, host.docker.internal in those env vars falls back to localhost / 127.0.0.1 so a shared .env does not break host uv run.

Optional LLM, RAG, and browser-agent

light ships [serve] only (FastAPI/Jinja for compose up). heavy also installs [llm], [rag] (LanceDB), and [browser-agent] (browser-use), so rag / ask, LiteLLM, and the browser_agent lane work inside the container. Headed session login still stays on the host (shared state/ vault).

Optional GROBID sidecar

The opt-in grobid client talks to an external GROBID REST server. Paperful does not install Java or GROBID models in the light/heavy images. Run the server on the host (docker run -p 8070:8070 lfoppiano/grobid:0.8.2) or a second Compose service via compose.override.example.yaml and point [grobid].base_url at it (for example http://host.docker.internal:8070 from a container, or http://grobid:8070 on the Compose network). Keep loopback in config.toml; Paperful rewrites it via PAPERFUL_GROBID_HOST. Plan for several GB of RAM. Scans need paperful ocr first. critique --lane marg reuses the same TEI cache for PDF chunks.

Optional Camofox sidecar

The opt-in camofox lane talks to an external camofox-browser REST server. Paperful does not install Node or Camoufox in the light/heavy images. Run the server on the host (or a second Compose service) and point [camofox].base_url at it (for example http://host.docker.internal:9377 from a container). Set CAMOFOX_ACCESS_KEY in both places when the server requires a bearer. This is not a Cloudflare-bypass product and does not reuse the vault Chromium profile.

After doctor is green, docker compose up starts the GUI. It binds in the container on 0.0.0.0:8765 (Compose passes --allow-remote-bind; the workbench has no HTTP auth unless PAPERFUL_UI_TOKEN is set) and publishes only 127.0.0.1:8765:8765 (no LAN). Same workbench as host paperful serve (gui.md — Wanted, then Discover; Advanced Repair / Mirror / Index / Briefs). Operator threat model: SECURITY.md.

docker compose up
# → http://127.0.0.1:8765

Host-only uv run paperful serve remains the contributor path. fix-metadata title proposals, the lint identity check, summarize, and synthesize work from the container against an Ollama running on the host. Keep loopback in config.toml (base_url = "http://127.0.0.1:11434"); Paperful rewrites that loopback host to host.docker.internal inside the container via PAPERFUL_OLLAMA_HOST. A remote base_url stays as configured.

On Docker Desktop, the default Ollama loopback bind is enough (same as Zotero). On Linux Docker Engine, bind with OLLAMA_HOST=0.0.0.0 ollama serve so host-gateway can connect. docker compose run --rm paperful doctor shows the LLM row. Details: LLM. See rag.md for the index.

Custom playbook packs

Put extra grey-playbook TOML files in packs/ (under the data dir) and set in config. Optional energy and international-org copies (IEA/IRENA, OECD/WHO/UNEP/UNDP) ship inside the package as paperful/data/grey_playbooks_examples/. Copy those files into packs/. That examples folder does not include the ocean builtin, which is already on by default.

grey_playbooks_dir = "packs"

Merge order: builtin pack → packs/*.toml → inline [[grey_playbooks]] (same name wins later). See Configuration.

Vault and browser-agent PDF successes append state/fetch-wins.jsonl on this volume (agent rows may include steps and promotable click: / rewrite wins). paperful playbooks promote (and [playbooks].promote = "auto") writes packs/learned.toml when grey_playbooks_dir = "packs". Both state/ and packs/ are gitignored. auto may promote a fluke; the default is gated. Headed session login stays on the host; a container run still writes wins onto the shared state/ mount.

Run configs

profiles/ next to config.toml holds named run configs (paperful all --profile, paperful profile save). That directory is not the grey-lit packs/ folder and not state/packs/.

mkdir -p ../paperful-data/profiles
docker compose run --rm paperful profile list
docker compose run --rm paperful all --profile bbnj-journal --dry-run

See Workflows.

After doctor is green

Keep Zotero running. docker compose up starts the GUI at http://127.0.0.1:8765. CLI one-shots use compose run (pass the subcommand — bare run would also start serve):

docker compose up                                    # stays up; GUI
# other terminal:
docker compose run --rm paperful doctor
docker compose run --rm paperful collections
docker compose run --rm paperful run --collection interesting --dry-run
docker compose run --rm paperful run --collection interesting
docker compose run --rm paperful report

docker compose stop (or Ctrl-C on up) gives the container 30 s (stop_grace_period) to finish the job it is writing before it is killed; a Grab interrupted after that is resumable like any run.

--no-attach writes to out/ only. --library walks the whole library (resumable; Ctrl-C then rerun). Same flags as Commands, including --year-from / --year-to and --type / -T.

docker compose run --rm paperful run -C BBNJ --year-from 2023 -T journalArticle --dry-run

Common commands

docker compose up                           # stays up; GUI at http://127.0.0.1:8765
# other terminal:
docker compose run --rm paperful doctor
docker compose run --rm paperful doctor --no-guide
docker compose run --rm paperful collections
docker compose run --rm paperful dedupe -C BBNJ --dry-run
docker compose run --rm paperful run --collection interesting --dry-run
docker compose run --rm paperful run --collection interesting
docker compose run --rm paperful report
make docker-build          # uses PAPERFUL_IMAGE_MODE from .env
make docker-build-heavy    # force heavy pack
make docker-doctor         # doctor --guide (interactive Enter walk)

Pass any CLI flag after the service name; the image ENTRYPOINT is paperful. The same commands as uv snippets — docker compose run --rm paperful instead of uv run paperful. Headed session login is still host-only.

Image contents

  • Python 3.12, Paperful + Playwright Chromium (htmlpdf / session vault reuse)

  • Poppler (pdftotext), OCRmyPDF, Tesseract (eng)

  • [serve] always; heavy also [llm] [rag] [browser-agent]

  • Vale is not in the image. doctor ambers when vale is missing; install it on the host (or a custom image) if you want critique --vale

  • Non-root user paperful (uid 1000)

If bind-mounted out/ / state/ are not writable, fix ownership on the host (chown -R 1000:1000 …) or run with a matching user override.

Sessions and attach

Headed Chromium login and Zotero’s authorize dialog need the host GUI. Typical flow:

  1. docker compose run --rm paperful doctor — prints fix steps for amber session checks. Run paperful session login ezproxy / scholar on the host (same PAPERFUL_DATA / state/ the container mounts), then re-run doctor. For Enter-to-re-check: doctor --guide (use compose run, not compose up).

  2. Approve attach once on the host so state/zotero-local-api-key.json exists.

  3. Run fetch/attach from the container as above.

Usual path (uv)

uv sync --group dev
uv run pytest
uv run paperful doctor

See Commands, Architecture, and Zotero.