Run Docker images as non-root user and default to /data workdir

Harden the Docker images by dropping root privileges, and make the
bind-mount workflow less fiddly.

- Create a non-root `app` user (uid/gid 1000) in both images and add
  `USER app` before the entrypoint, so ocrmypdf (and the
  webservice/watcher) no longer run as root. This also fixes the
  previously dangling `--chown=app:app`, which referenced a user that
  was never created. The Ubuntu base ships a default `ubuntu`/1000 user,
  so remove it first so `app` can take uid 1000 (parity with Alpine).

- Add `WORKDIR /data` (created and app-owned) so bind-mounted input and
  output can be passed as relative paths without `--workdir`. The
  webservice/watcher are now invoked by absolute path (`/app/*.py`)
  since the working directory is no longer `/app`.

- Drop the redundant `ppa:alex-p/tesseract-ocr5` from the Ubuntu image:
  Tesseract 5 ships in the Ubuntu archive as of 24.04, and the PPA had
  no build for the 26.04 base, which broke the build outright.

- Rewrite docs/docker.md rootless-first: stdin/stdout piping as the
  recommended permission-free path, then per-runtime volume guidance
  (rootless Docker `--user 0:0`, Podman `--userns keep-id`, rootful
  Docker as the special case). Update batch.md and the compose example
  to match (absolute script paths, per-runtime `user:` guidance).
This commit is contained in:
James R. Barlow
2026-06-17 15:13:19 -07:00
parent e0ee0882ef
commit ff45e54c07
5 changed files with 151 additions and 32 deletions
+27 -5
View File
@@ -60,10 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM base FROM base
RUN apt-get update && apt-get install -y software-properties-common # Tesseract 5 ships in the Ubuntu archive as of 24.04, so no third-party PPA is
# needed. (Previously this used ppa:alex-p/tesseract-ocr5.)
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ghostscript \ ghostscript \
fonts-droid-fallback \ fonts-droid-fallback \
@@ -81,6 +79,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unpaper \ unpaper \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create a non-root user to run the application (defense in depth). The build
# stages above need root to install packages, but the entrypoint should not.
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
# and matches the --chown below. See docs/docker.md for the volume/permissions
# implications under rootless vs rootful Docker.
# The Ubuntu base ships a default "ubuntu" user at uid/gid 1000; remove it so
# "app" can claim that uid for parity with the Alpine image.
RUN userdel -r ubuntu 2>/dev/null; groupdel ubuntu 2>/dev/null; \
groupadd -g 1000 app \
&& useradd -u 1000 -g app -m -d /home/app app
ENV HOME=/home/app
WORKDIR /app WORKDIR /app
COPY --from=builder /usr/local/lib/ /usr/local/lib/ COPY --from=builder /usr/local/lib/ /usr/local/lib/
@@ -90,9 +100,21 @@ COPY --from=builder --chown=app:app /app /app
RUN rm -rf /app/.git && \ RUN rm -rf /app/.git && \
ln -s /app/misc/webservice.py /app/webservice.py && \ ln -s /app/misc/webservice.py /app/webservice.py && \
ln -s /app/misc/watcher.py /app/watcher.py ln -s /app/misc/watcher.py /app/watcher.py && \
chown app:app /app
# Default working directory for bind-mounted data, so relative input/output
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
RUN mkdir -p /data && chown app:app /data
WORKDIR /data
ENV PATH="/app/.venv/bin:${PATH}" ENV PATH="/app/.venv/bin:${PATH}"
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
# overridden) as the unprivileged app user. Override with `--user root` if you
# need root inside a running container (e.g. to apt install extra packages).
USER app
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"] ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
+22 -1
View File
@@ -62,14 +62,35 @@ RUN apk add --no-cache \
unpaper \ unpaper \
&& rm -rf /var/cache/apk/* && rm -rf /var/cache/apk/*
# Create a non-root user to run the application (defense in depth). The build
# stages above need root to install packages, but the entrypoint should not.
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
# and matches the --chown below. See docs/docker.md for the volume/permissions
# implications under rootless vs rootful Docker.
RUN addgroup -g 1000 app \
&& adduser -u 1000 -G app -D -h /home/app app
ENV HOME=/home/app
WORKDIR /app WORKDIR /app
COPY --from=builder --chown=app:app /app /app COPY --from=builder --chown=app:app /app /app
RUN rm -rf /app/.git && \ RUN rm -rf /app/.git && \
ln -s /app/misc/webservice.py /app/webservice.py && \ ln -s /app/misc/webservice.py /app/webservice.py && \
ln -s /app/misc/watcher.py /app/watcher.py ln -s /app/misc/watcher.py /app/watcher.py && \
chown app:app /app
# Default working directory for bind-mounted data, so relative input/output
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
RUN mkdir -p /data && chown app:app /data
WORKDIR /data
ENV PATH="/app/.venv/bin:${PATH}" ENV PATH="/app/.venv/bin:${PATH}"
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
# overridden) as the unprivileged app user. Override with `--user root` if you
# need root inside a running container (e.g. to apk add extra packages).
USER app
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"] ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
+9 -1
View File
@@ -174,7 +174,15 @@ docker run \
--env PYTHONUNBUFFERED=1 \ --env PYTHONUNBUFFERED=1 \
--interactive --tty --entrypoint python3 \ --interactive --tty --entrypoint python3 \
jbarlow83/ocrmypdf \ jbarlow83/ocrmypdf \
watcher.py /app/watcher.py
:::
:::{note}
The image runs as the non-root `app` user (uid 1000) by default, so it
may not be able to write to the `/output` and `/processed` volumes unless
you add a `--user` argument. The correct value depends on whether you use
rootful Docker, rootless Docker, or Podman -- see
{ref}`Bind-mounted volumes <docker-volumes>` for details.
::: :::
This service will watch for a file that matches `/input/\*.pdf`, convert This service will watch for a file that matches `/input/\*.pdf`, convert
+84 -23
View File
@@ -71,15 +71,29 @@ application (as opposed to the more conventional case, where a Docker
container runs as a server). For that reason we usually use the `--rm` container runs as a server). For that reason we usually use the `--rm`
argument to delete the container when it exits. argument to delete the container when it exits.
:::{note}
The image runs as a non-root user (`app`, uid/gid 1000) by default,
rather than as root. This is a defense-in-depth measure: a flaw in
OCRmyPDF or one of its dependencies cannot trivially act as root inside
the container. The examples below assume **rootless Docker** or
**Podman**; the differences for traditional *rootful* Docker are
described separately under *Special case: rootful Docker* below.
:::
To start a Docker container (instance of the image): To start a Docker container (instance of the image):
:::{code} bash :::{code} bash
docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - - docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - -
::: :::
For convenience, create a shell alias to hide the Docker command. It is ### Recommended: pipe through stdin and stdout
easier to send the input file as stdin and read the output from stdout
-- **this avoids the messy permission issues with Docker entirely**. The easiest and most portable way to use the image is to send the input
file on stdin and read the output from stdout. This **avoids file
permission issues entirely** -- nothing is written to a mounted
directory, so it does not matter which user the container runs as, nor
whether you use rootless or rootful Docker. For convenience, create a
shell alias to hide the Docker command:
:::{code} bash :::{code} bash
alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine' alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine'
@@ -90,28 +104,42 @@ docker_ocrmypdf - - <input.pdf >output.pdf
Or in the wonderful [fish shell](https://fishshell.com/): Or in the wonderful [fish shell](https://fishshell.com/):
:::{code} fish :::{code} fish
alias docker_ocrmypdf 'docker run --rm jbarlow83/ocrmypdf-alpine' alias docker_ocrmypdf 'docker run --rm -i jbarlow83/ocrmypdf-alpine'
funcsave docker_ocrmypdf funcsave docker_ocrmypdf
::: :::
Alternately, you could mount the local current working directory as a {#docker-volumes}
Docker volume: ### Bind-mounted volumes
If you would rather mount a directory and pass file paths, you need to
consider which user owns the files OCRmyPDF writes back into that
directory. The image's default working directory is `/data`, so mounting
your files there lets you pass plain relative paths without an explicit
`--workdir`. Because the container runs as the non-root `app` user, the
right invocation otherwise depends on your container runtime.
**Rootless Docker (the assumed default).** Your own account runs the
daemon, so the container's `root` maps back to *your* unprivileged host
user, while every other container uid -- including the image's default
`app`/1000 -- maps to a *subordinate* uid. A directory you own on the
host therefore appears owned by `root` inside the container, so the
default `app` user usually **cannot write to it at all**. Run the job as
container-`root`, which under rootless Docker is still your ordinary host
user, so the write succeeds and the output is owned by you:
:::{code} bash :::{code} bash
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' alias docker_ocrmypdf='docker run --rm -i --user 0:0 -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
docker_ocrmypdf /data/input.pdf /data/output.pdf docker_ocrmypdf input.pdf output.pdf
::: :::
## Podman **Podman.** Podman provides `--userns keep-id`, which maps your host uid
straight through into the container. Combined with `--user`, you run as
Especially if you use [Podman](https://podman.io/) (or use Docker in your own uid and own the output directly, otherwise you may get access
rootless mode), you may need to add `--userns keep-id` there, errors because the user ID is not mapped to the same UID as on the host:
otherwise you may get access errors, because the user ID is otherwise not
mapped to the same UID as on the host:
:::{code} bash :::{code} bash
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
podman_ocrmypdf /data/input.pdf /data/output.pdf podman_ocrmypdf input.pdf output.pdf
::: :::
If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to
@@ -124,10 +152,27 @@ the end of the linked podman documentation for details. This results in
the following full command: the following full command:
:::{code} bash :::{code} bash
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine' alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine'
podman_ocrmypdf /data/input.pdf /data/output.pdf podman_ocrmypdf input.pdf output.pdf
::: :::
{#docker-rootful}
### Special case: rootful Docker
With a traditional root daemon, container uid *N* is the *same* uid *N*
on the host. Running the container as root would therefore fill your
mounted directory with root-owned files and -- more importantly -- a
container escape would run as real host root. Drop to your own uid so the
output is owned by you and the process stays unprivileged:
:::{code} bash
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
docker_ocrmypdf input.pdf output.pdf
:::
The non-root default and the `--user` override both reduce the risk here,
but rootless Docker or Podman remain the safer choice when available.
{#docker-lang-packs} {#docker-lang-packs}
## Adding languages to the Docker image ## Adding languages to the Docker image
@@ -139,8 +184,12 @@ creating a new Dockerfile based on the public one.
:::{code} dockerfile :::{code} dockerfile
FROM jbarlow83/ocrmypdf FROM jbarlow83/ocrmypdf
# The image runs as the non-root "app" user, so switch back to root for
# build steps that install packages, then drop back to "app".
USER root
# Example: add Italian # Example: add Italian
RUN apt install tesseract-ocr-ita RUN apt-get update && apt-get install -y tesseract-ocr-ita
USER app
::: :::
To install language packs (training data) such as the To install language packs (training data) such as the
@@ -179,7 +228,11 @@ Extending the Docker image
-------------------------- --------------------------
You can extend the Docker image with your own customizations, similar to You can extend the Docker image with your own customizations, similar to
the way it is extended to add language packs. the way it is extended to add language packs. Because the image runs as
the non-root `app` user, switch to `USER root` for any build steps that
require root (installing packages, writing to system directories) and
back to `USER app` afterwards, as shown in the language pack example
above.
Note that the Docker image is subject to change at any time. For Note that the Docker image is subject to change at any time. For
example, the base image may be updated to a newer version of Ubuntu or example, the base image may be updated to a newer version of Ubuntu or
@@ -196,7 +249,7 @@ Executing the test suite
The OCRmyPDF test suite is installed with image. To run it: The OCRmyPDF test suite is installed with image. To run it:
:::{code} bash :::{code} bash
docker run --rm --entrypoint python jbarlow83/ocrmypdf -m pytest docker run --rm --workdir /app --entrypoint python jbarlow83/ocrmypdf -m pytest
::: :::
Accessing the shell Accessing the shell
@@ -205,7 +258,15 @@ Accessing the shell
To use the shell in the Docker image: To use the shell in the Docker image:
:::{code} bash :::{code} bash
docker run -it --entrypoint sh jbarlow83/ocrmypdf docker run -it --entrypoint sh jbarlow83/ocrmypdf-alpine
:::
This shell runs as the non-root `app` user. If you need root inside the
container -- for example to install extra packages with `apk` or `apt` --
add `--user root`:
:::{code} bash
docker run -it --user root --entrypoint sh jbarlow83/ocrmypdf-alpine
::: :::
Using the OCRmyPDF web service wrapper Using the OCRmyPDF web service wrapper
@@ -215,7 +276,7 @@ The OCRmyPDF Docker image includes an example, barebones HTTP web
service. The webservice may be launched as follows: service. The webservice may be launched as follows:
:::{code} bash :::{code} bash
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf webservice.py docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf /app/webservice.py
::: :::
We omit the `--rm` parameter so that the container will not be We omit the `--rm` parameter so that the container will not be
+9 -2
View File
@@ -6,12 +6,19 @@ services:
ocrmypdf: ocrmypdf:
restart: always restart: always
container_name: ocrmypdf container_name: ocrmypdf
image: jbarlow83/ocrmypdf image: jbarlow83/ocrmypdf-alpine
volumes: volumes:
- "/media/scan:/input" - "/media/scan:/input"
- "/mnt/scan:/output" - "/mnt/scan:/output"
environment: environment:
- OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0 - OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0
# The image runs as the non-root "app" user (uid 1000) by default. The
# correct value here depends on your runtime, so that the watcher can write
# to the /output bind mount and the files end up owned by you:
# rootful Docker -> your host uid:gid
# rootless Docker -> "0:0" (container root maps to your host user)
# Podman -> your host uid:gid, plus `userns_mode: "keep-id"`
# See docs/docker.md ("Bind-mounted volumes") for the reasoning.
user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>" user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>"
entrypoint: python3 entrypoint: python3
command: watcher.py command: /app/watcher.py