Docker
saneless publishes an OCI container image for deployment alongside paperless-ngx.
Image
| Property | Value |
|---|---|
| Image | ghcr.io/kdknigga/saneless:0.2 |
| Base | python:3.14-slim |
| Entrypoint | saneless |
| Default command | serve |
| Platforms | linux/amd64 |
| Port | 8080 |
| User | 1000:1000 (non-root) |
| Working directory | /var/lib/saneless |
The entrypoint is the saneless program and serve is only its default
argument. Anything you put after the image name in docker run replaces
serve, so ending the line with doctor instead runs saneless doctor in a
fresh container.
The image is built for linux/amd64 only. There is no Arm image, so on a
Raspberry Pi or another Arm host
install saneless on bare metal instead.
The published port serves a UI with no login, bound to 0.0.0.0 -- all network
interfaces -- so every host that can reach it can start a scan; see
Running behind a reverse proxy
and the Web API notes on the trust model.
Publishing the port
Docker publishes a port by writing its own iptables rules, and container
traffic is routed by them before ufw's or firewalld's rules are consulted. A
port published as "8080:8080" is therefore reachable from the network even
when the host firewall says 8080 is closed. To keep saneless off the LAN,
publish it on the loopback address only and put a
reverse proxy
on the host in front of it:
ports:
- "127.0.0.1:8080:8080"
The examples on this page publish "8080:8080", so that a browser anywhere on
your LAN reaches the UI with no proxy in between.
Image tags
Every release publishes the image under more than one tag. Which tags a release gets depends on whether it is a final release or a release candidate:
| Tag | Example | Published for | Moves? |
|---|---|---|---|
X.Y.Z |
0.2.0 |
every final release | Never. It always names that release. |
X.Y.Z-rc.N |
0.2.0-rc.7 |
every release candidate | Never. It always names that candidate. |
X.Y |
0.2 |
final releases only | Yes. It moves to each new patch release of that minor version. |
latest |
latest |
final releases only | Yes. It moves to the newest final release. |
next |
next |
every release, candidate or final | Yes. It moves to the newest release of either kind. |
Release candidates never receive X.Y or latest. Pulling either before
the first final release fails with "manifest unknown", because the tag does not
exist yet.
Pin X.Y to pick up patch releases when you pull, or pin X.Y.Z to change
versions only when you edit the tag yourself. Avoid latest: it can move you
across a minor version, and minor versions may carry breaking changes.
Follow next to try each release candidate as it is published. Because a final
release moves it too, it is never behind latest, and like latest it can move
you across a minor version.
Until a minor version's first final release is published, the examples in these
docs pin the current release-candidate tag, because that version's X.Y tag
does not exist yet. From its first final release on, they pin X.Y.
Verifying the image
Every image the release pipeline publishes carries two signed attestations,
recorded with GitHub and pushed beside the image to the registry: a SLSA
build-provenance attestation, recording which workflow run in
kdknigga/saneless built it from which commit, and an SPDX SBOM attestation
listing what the image contains.
Attestations are bound to the image's digest, not to a tag, so start from the
digest of the image you actually run. Replace <image> with the reference you
pulled, exactly as your compose file or docker run line names it, tag
included:
docker image inspect --format '{{index .RepoDigests 0}}' <image>
That prints ghcr.io/kdknigga/saneless@sha256:<digest>. Check the provenance
with the GitHub CLI:
gh attestation verify oci://ghcr.io/kdknigga/saneless@sha256:<digest> -R kdknigga/saneless
and the SBOM by naming its predicate type:
gh attestation verify oci://ghcr.io/kdknigga/saneless@sha256:<digest> -R kdknigga/saneless \
--predicate-type https://spdx.dev/Document/v2.3
gh resolves an oci:// reference against the registry, so it needs you to be
logged in there already (docker login ghcr.io), and it fetches the
attestations through GitHub's API, so it needs gh auth login as well. A
successful check proves the image was built by this repository's
release workflow; it does not replace pinning a tag you have reviewed.
User and file ownership
The container runs as UID/GID 1000, not root, and everything it writes on a
mounted host directory is owned by 1000. On a single-user Linux host your own
account is 1000, so the ./config directory you created is already correct and
nothing further is needed.
If id -u reports something else, hand the config directory over once. Only root
can give a file to another user, so this needs sudo; a plain chown fails
with Operation not permitted:
sudo chown -R 1000:1000 ./config
This applies to bind mounts only. A named or anonymous volume -- what
/var/lib/saneless gets -- inherits 1000:1000 from the image the first time
it is used, so durable state needs no fix-up. A bind mount keeps whatever
ownership the host directory already has, and without write access there
saneless cannot rewrite saneless.toml when it saves a generated profile. The
shipped docker-compose.yml also carries a commented user: line for running
the container as your own UID instead.
config/saneless.toml holds your paperless-ngx API token, so make it readable
by the container's user alone. Both steps run as root: the chown needs it to
give the file to UID 1000, and the chmod needs it once the file is no longer
yours:
sudo sh -c 'chown 1000:1000 config/saneless.toml && chmod 600 config/saneless.toml'
saneless keeps that mode and owner each time it rewrites the file.
Rootless Podman is the exception. There your own host account maps to root
inside the container, and the container's UID 1000 is one of your subordinate
UIDs on the host, so neither a file you own nor one handed to host UID 1000 is
readable by the container's user once it is 0600. Make the change from
inside Podman's user namespace instead, where 1000 means the container's
user:
podman unshare sh -c 'chown 1000:1000 config/saneless.toml && chmod 600 config/saneless.toml'
Afterwards, edit the file through podman unshare as well, since your own
account no longer owns it.
Healthcheck
The image includes a built-in healthcheck:
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=4)"]
The probe is Python's standard library, run by the image's own interpreter; the
image carries no separate HTTP client. urlopen raises on a refused connection
and on any non-2xx response, so either one exits non-zero and counts as a failed
check. --start-period=30s gives the first SANE initialisation 30 seconds
before failed checks start to count.
The /health endpoint returns 200 when saneless is healthy. It returns 503 when the worker thread is down (worker thread is down) or when the worker is degraded because its job store is failing (job store failing); see GET /health. After three failed checks in a row, Docker reports the container as unhealthy.
Docker's restart policies act only when a container exits, so an unhealthy container keeps running and restart: unless-stopped does not restart it. A degraded worker clears itself once the job store accepts writes again; a worker thread that is down needs saneless restarted.
Volumes
| Mount Point | Purpose | Required |
|---|---|---|
/etc/saneless |
Configuration directory holding saneless.toml (mount read-write; a missing saneless.toml means defaults plus environment variables). A config.toml in this directory is not read -- the Configuration row on the status page names it and gives the rename to saneless.toml |
Recommended |
/var/lib/saneless |
Durable state: the job database (saneless.db) and preserved scans (failed/) |
Yes -- do not treat as disposable |
/tmp/saneless-1000 |
Scratch space for the scan in progress; every file in it is deleted as the scan finishes. The default tmp_dir is $TMPDIR/saneless-<uid>, and the image runs as uid 1000 |
No (ephemeral OK) |
/consume |
Consume directory fallback for file-based ingestion | No (only if using fallback) |
/consume is the path the shipped docker-compose.yml uses, as a commented
line you uncomment. Share it with paperless-ngx -- mount the same volume at
paperless-ngx's PAPERLESS_CONSUMPTION_DIR -- and set
paperless.consume_dir to the same container path in saneless.toml. The
mount alone changes nothing: saneless falls back only when consume_dir names
a directory. See Consume directory
fallback for when it activates
and what it costs.
Mount the configuration directory (./config:/etc/saneless), not saneless.toml itself. saneless rewrites saneless.toml by writing a temp file beside it and renaming it over the original; over a single-file bind mount that rename fails with EBUSY, and over a read-only mount the write is refused. See Moving from a single-file config mount.
On a host with SELinux enforcing (Fedora, RHEL, Rocky), the container cannot read a bind mount until it is relabelled: add :z to the mount, as in ./config:/etc/saneless:z.
/var/lib/saneless is not optional, and it is not the same kind of directory
/tmp/saneless-1000 is. When a scan cannot be delivered to paperless-ngx at all --
the upload fails and no consume directory is configured, paperless-ngx rejects
the upload outright, the consumption task reports a failure, or the task has not
finished when paperless_task_timeout expires -- saneless moves the assembled
PDF into /var/lib/saneless/failed/. Unless paperless-ngx did file it after
all -- which a task that outlived paperless_task_timeout may still do -- that
copy is the only one there is. Pruning the volume, or leaving it unmounted so that it vanishes
when the container is recreated, destroys scans that were never ingested.
The image sets XDG_STATE_HOME=/var/lib, so data_dir defaults to
/var/lib/saneless -- the directory it declares as a VOLUME -- and one-shot
commands such as docker compose exec saneless saneless jobs log to
/var/lib/saneless/saneless.log. It is only a default: [output] data_dir in
saneless.toml, or SANELESS_OUTPUT__DATA_DIR, still overrides it. A plain
docker run -v saneless-data:/var/lib/saneless is therefore correct without
setting anything else. /tmp/saneless-1000 is not mounted by the compose files
below: it holds nothing worth keeping between runs.
Preserved scans in failed/
/var/lib/saneless/failed/ grows over time and saneless never deletes, moves
or rotates anything in it. That is deliberate: automatically removing a file
there would destroy the only copy of a scanned document. Draining the directory
is an operator task.
- One PDF is written per unrecoverable delivery -- two for a manual duplex scan whose fronts and backs were uploaded as separate halves. Each file corresponds to a job the web UI shows as Failed, whose error message names that exact path.
- Partial scans land here too. A scanner fault part-way through a stack keeps the sheets already fed, and a manual duplex job whose second pass or flip failed keeps the fronts. Each is a PDF, named so you can tell it from a complete one. Budget for them: a jam on a 50-sheet job writes a PDF of everything up to the jam, and a rescan of the same stack writes a second, complete one.
- A scan that could not be assembled leaves a directory, not a file. When PDF assembly itself fails, the individual page files are preserved instead, in a job-keyed subdirectory holding one PNG per sheet. Those PNGs are uncompressed- document-scale: roughly 13 MB per A4 300 DPI colour page, so one such directory can be larger than any PDF beside it.
- Once the directory holds 20 or more preserved scans -- counting those subdirectories alongside the PDFs -- saneless logs a WARNING each time it preserves another, naming the count, the total size and the path. Watch for it in the container log: individual failures show up as failed jobs, but that warning is the only signal that the directory as a whole is filling up.
- To drain it: confirm the documents are in paperless-ngx, or re-ingest the PDFs
by copying them into the paperless-ngx consume directory, then delete the
files you have accounted for. A preserved page directory has no PDF to
re-ingest -- assemble or rescan it, then remove the directory. The PNG names
are the order the sheets were acquired, one pass at a time, which is document
order for a simplex scan but not for a manual duplex one; see
what
failed/holds before assembling a duplex job by hand. - Check paperless-ngx before re-ingesting. It recognises a duplicate by the
file's checksum, but what it does with one depends on its release
(checked against paperless-ngx 3.2.1 and 2.20.15): 2.x refuses it, while 3.x
stores it as a second document and flags it, unless
PAPERLESS_CONSUMER_DELETE_DUPLICATESis enabled. On 3.x, dropping a preserved PDF that paperless-ngx already holds back into the consume directory makes a second copy; see Duplicates.
saneless keeps what it writes under /var/lib/saneless to its own user.
The image's /var/lib/saneless is 0700, and a new volume starts with that
mode. A new job database (saneless.db, with its -wal and -shm files) is created
0600. failed/ and each preserved page directory are created 0700, and
each preserved PDF and page file is 0600.
Environment Variables
All SANELESS_* environment variables are supported inside the container. Common ones for Docker deployments:
| Variable | Typical Value | Purpose |
|---|---|---|
SANELESS_SCANNER__HOST |
192.168.1.50 |
Network scanner IP address |
SANELESS_PAPERLESS__URL |
http://paperless:8000 |
Paperless-ngx URL (Docker network) |
SANELESS_PAPERLESS__TOKEN |
your-api-token-here |
Paperless-ngx API token. Prefer saneless.toml -- see below |
SANELESS_OUTPUT__WEB_PORT |
8080 |
The container's port is fixed at 8080. web_port is a bare-metal setting: setting it here moves the server off the port the image exposes and the healthcheck probes, so the container reports unhealthy while the UI is in fact running somewhere else. Remap on the host instead -- -p 8888:8080 |
SANELESS_OUTPUT__DATA_DIR |
/var/lib/saneless |
Durable state directory. Not set by the image: it defaults to /var/lib/saneless because the image sets XDG_STATE_HOME=/var/lib. Set it only if you mount the volume somewhere else |
TZ |
America/Chicago |
Standard container variable, not a saneless setting. A container's clock reports UTC without it, and saneless renders every timestamp in the server's local zone, so TZ is what makes the job history, saneless jobs and the fallback document title show your local time |
See Environment Variables for the full list, and
Where saneless reads settings
for which wins when a variable and saneless.toml both set the same thing.
Placeholder tokens are detected
saneless keeps a small fixed list of literal token values that mean "nobody
configured this" -- changeme, your-api-token-here and their family -- and
treats an empty or whitespace-only token the same way. When the configured
token is one of them, the server still starts, so you can see why, but the
status strip shows Paperless red, the web UI's Scan button is disabled with the
reason, and saneless scan exits 2 before the scanner is opened. The check is
exact, never a substring match: changeme7f3a91 is a real token.
So do not copy a placeholder into a deployment expecting to fix it later -- nothing will scan until it is replaced.
Keep the token in config/saneless.toml and out of the compose
environment: block; see
Where saneless reads settings.
Minimal docker-compose.yml
services:
saneless:
image: ghcr.io/kdknigga/saneless:0.2
stop_grace_period: 90s
ports:
- "8080:8080"
volumes:
- ./config:/etc/saneless
- saneless-data:/var/lib/saneless
volumes:
saneless-data:
The data volume is part of the minimum. Without it the job database and any preserved scans live inside the container's writable layer and are lost the next time the container is recreated.
Scanner Access
The container reaches scanners over the network only. No device mapping and no
--privileged flag appear anywhere on this page, and none is needed.
The image enables exactly two SANE backends, net and escl. The stock list
enables about eighty, and SANE probes every one of them the first time it looks
for devices, which slows that first scan by seconds even with no scanner
attached. The consequence is that USB and every other local backend are not
available inside the container: a scanner plugged into the host is reached
through saned on that host, like any other.
Through saned (primary route)
saned owns the scanner, and saneless talks to saned over the SANE network
protocol. This works for any scanner SANE supports, including USB scanners on
the container's own host. Set the scanner host to the machine saned runs on --
the container's own host, or another one:
services:
saneless:
image: ghcr.io/kdknigga/saneless:0.2
stop_grace_period: 90s
ports:
- "8080:8080"
volumes:
- ./config:/etc/saneless
- saneless-data:/var/lib/saneless
environment:
- SANELESS_SCANNER__HOST=192.168.1.50
volumes:
saneless-data:
saneless injects this value into SANE_NET_HOSTS, unless SANE_NET_HOSTS is already set to a non-empty value, before initializing the SANE backend, enabling automatic scanner discovery inside the container.
For detailed setup instructions, see Scanner Host Discovery.
Direct eSCL (driverless network scanners)
Many current network scanners and multifunction printers speak eSCL (Apple
AirScan), which the image's escl backend reaches directly, with no saned in
between. Name the scanner in an escl.conf holding one device line -- its URL,
then an optional model name:
device http://192.168.1.60:80
Use https:// and the matching port when the scanner serves eSCL over TLS.
Mount the file read-only over the image's own copy:
services:
saneless:
image: ghcr.io/kdknigga/saneless:0.2
stop_grace_period: 90s
ports:
- "8080:8080"
volumes:
- ./config:/etc/saneless
- saneless-data:/var/lib/saneless
- ./escl.conf:/etc/sane.d/escl.conf:ro
volumes:
saneless-data:
saneless devices lists the scanner under a name beginning escl:. With
scanner.device empty saneless uses the first device it finds; set
scanner.device to that name to choose it explicitly when saned offers
others too.
Automatic eSCL discovery over mDNS, with no device line, is untested in
the container. The escl backend discovers scanners through the host's
avahi-daemon over the system D-Bus, so it would need the host's avahi-daemon
running and its D-Bus socket (/run/dbus/system_bus_socket) mounted into the
container. Host networking alone is not enough: the container still has no
avahi-daemon to ask. Prefer the explicit device line.
Full Example
services:
saneless:
image: ghcr.io/kdknigga/saneless:0.2
stop_grace_period: 90s
ports:
- "8080:8080"
volumes:
- ./config:/etc/saneless
- saneless-data:/var/lib/saneless
# Optional consume-directory fallback; also set
# paperless.consume_dir = "/consume" in saneless.toml.
# Uncomment this mount and the matching volume below together.
# - paperless-consume:/consume
environment:
- TZ=America/Chicago
- SANELESS_SCANNER__HOST=192.168.1.50
# The paperless-ngx URL and token belong in config/saneless.toml. Setting
# them here overrides that file silently.
restart: unless-stopped
volumes:
saneless-data:
# The volume paperless-ngx consumes from. If another compose project
# created it, add `external: true` and its real `name:` beneath.
# paperless-consume:
config/saneless.toml alongside it carries the connection:
[paperless]
url = "http://paperless:8000"
token = "your-api-token-here"