Containerized PDF operations for workflow systems (e.g. Argo Workflows): exactly one operation per container run - merge multiple PDFs into one, or extract the attachments embedded in a PDF - configured entirely through environment variables.
The design - architecture, library tradeoffs, security posture, limitations - is
summarized in docs/DESIGN.md; the working notes behind it are
docs/DESIGN_NOTES.md, and individual choices, with their
alternatives and status, live in the decision register at
docs/DECISIONS.md.
docker build -t pdf-ops .
# The container runs as UID 10001 - the output dir must be writable by it
mkdir -p in out && chmod 777 out
# Merge two PDFs from a mounted input dir into a mounted output dir
docker run --rm \
-v "$PWD/in:/in:ro" -v "$PWD/out:/out" \
-e PDFOPS_OPERATION=merge \
-e PDFOPS_INPUTS=/in/a.pdf:/in/b.pdf \
-e PDFOPS_OUTPUT=/out/merged.pdf \
pdf-ops
# Extract the attachments embedded in a PDF
docker run --rm \
-v "$PWD/in:/in:ro" -v "$PWD/out:/out" \
-e PDFOPS_OPERATION=extract \
-e PDFOPS_INPUT=/in/report.pdf \
-e PDFOPS_OUTPUT_DIR=/out \
pdf-ops
# Merge an encrypted PDF, re-encrypting the output with the same password
docker run --rm \
-v "$PWD/in:/in:ro" -v "$PWD/out:/out" -v "$PWD/secret:/secret:ro" \
-e PDFOPS_OPERATION=merge \
-e PDFOPS_INPUTS=/in/locked.pdf:/in/plain.pdf \
-e PDFOPS_OUTPUT=/out/merged.pdf \
-e PDFOPS_PASSWORD_FILE=/secret/pw \
-e PDFOPS_OUTPUT_ENCRYPTION=inherit \
pdf-ops
# Invalid configuration fails fast (exit 2, machine-readable error event)
docker run --rm -e PDFOPS_OPERATION=bogus pdf-opsThe container needs no arguments and no interactive input: behavior comes entirely
from PDFOPS_* variables, and the mounted volumes provide inputs and receive outputs.
| Variable | Operation | Required | Accepted values | Default |
|---|---|---|---|---|
PDFOPS_OPERATION |
- | yes | merge, extract |
- |
PDFOPS_INPUTS |
merge | yes | ordered list of file paths, :-separated (explicit order - no globs) |
- |
PDFOPS_OUTPUT |
merge | yes | path of the output PDF; its directory must exist | - |
PDFOPS_INPUT |
extract | yes | the PDF to extract attachments from | - |
PDFOPS_OUTPUT_DIR |
extract | yes | existing directory receiving the attachment files | - |
PDFOPS_FAIL_ON_NO_ATTACHMENTS |
extract | no | true, false (case-insensitive) - fail (exit 3) when the PDF has no attachments |
false |
PDFOPS_PASSWORD_FILE |
both | no | path to a mounted secret file holding the password (preferred channel; one trailing newline stripped) | - |
PDFOPS_PASSWORD |
both | no | the password itself - discouraged: env values leak via kubectl describe, /proc/<pid>/environ, crash tooling |
- |
PDFOPS_OUTPUT_ENCRYPTION |
merge | no | never, inherit, always (case-insensitive) - see below |
never |
PDFOPS_OUTPUT_PASSWORD_FILE |
merge | no | secret file holding the password for the merged output | - |
PDFOPS_OUTPUT_PASSWORD |
merge | no | output password as a direct value (same caveats as PDFOPS_PASSWORD) |
- |
PDFOPS_ON_EXISTS |
both | no | fail, overwrite, skip (case-insensitive) - see Retries |
fail |
PDFOPS_LOG_LEVEL |
- | no | debug, info, warning, error (case-insensitive) |
info |
Strictness rules, all exit 2: any other PDFOPS_* variable is rejected as a probable
typo (UNKNOWN_VAR); a variable belonging to the other operation is rejected
(INAPPLICABLE_VAR); duplicate merge inputs are rejected (DUPLICATE_INPUTS) - a
repeated path is almost always a templating bug that would silently duplicate content.
The full behavior contract - mounts and permissions, password semantics, output
encryption, atomic writes, the existing-output policy, and attachment-name safety -
lives in docs/OPERATIONS.md. The short version: everything is
mounted volumes with absolute in-container paths, the container runs as non-root UID
10001, outputs are written atomically (a complete file or nothing), and
PDFOPS_ON_EXISTS decides what a retry does.
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | unexpected internal error (traceback in the log) |
| 2 | invalid configuration |
| 3 | input missing/unreadable |
| 4 | invalid or corrupt PDF |
| 5 | password required/wrong/unsupported |
| 6 | output conflict or output location unusable |
Output is JSON lines on stdout - one event per line, stderr stays empty. Lifecycle
events narrate progress and respect PDFOPS_LOG_LEVEL; passwords are echoed as
presence only (unset / set(env) / set(file)), never as values. Every run ends
with exactly one terminal event - operation_complete or operation_failed (with a
machine-readable error_code) - which no log level suppresses, so a workflow engine
can always branch on the last line:
{"ts": "2026-09-02T17:55:15.661+00:00", "level": "info", "event": "operation_complete", "operation": "merge", "exit_code": 0, "duration_s": 0.001, "inputs_merged": 2, "pages": 3, "bytes_written": 728, "output_path": "/out/merged.pdf", "output_encrypted": false}The event vocabulary and per-event fields are documented in
docs/OPERATIONS.md.
Workflow engines retry at-least-once, and most failures here are deterministic - retrying a wrong password or a corrupt PDF is pure waste. The retryability of each exit code:
| Code | Retry? | Why |
|---|---|---|
| 1 | yes | unexpected internal error - the only class where a retry might see different behavior |
| 2 | no | configuration is immutable for a given pod spec |
| 3 | usually no | missing/unreadable input - permanent unless an upstream mount races |
| 4 | no | the PDF itself is bad; it will be bad again |
| 5 | no | the password will still be wrong |
| 6 | usually no | output conflict/location - DISK_FULL is the judgment call (space may free up) |
Argo example - retry only on unexpected errors, with skip making any retry after a
lost-but-successful pod a free no-op:
retryStrategy:
limit: "3"
expression: "asInt(lastRetry.exitCode) == 1"
# and in the container env:
# PDFOPS_ON_EXISTS: skipuv sync # deps + venv
uv run pytest # unit + integration tests
uv run pytest -m container # container-contract tests (needs Docker)
uv run ruff check . # lint
uv run pyright # strict type check
uv run pre-commit run -a # full hook chain