CLI & configuration
PDF Server is configured only by command-line flags and environment variables (no config file). Every flag has an environment-variable equivalent, so this page doubles as the environment reference.
This table is maintained by hand for readability. A machine-generated version (
app.ToMarkdown()) can be produced at build time to guarantee it never drifts — see Build & CI.
Commands
| Command | Purpose |
|---|---|
server | Run the long-running HTTP server. |
validate [filters…] | Parse all templates and render every example; exit non-zero on failure. Filters are <template>/<example>. A run that rendered nothing — an empty templates directory, or a filter naming no example that exists — is a failure too, and names the directory it looked in. It also rejects a params.json key this build does not read, which a running server only warns about. It reports no diagnostics and runs no layout rules: debug render is the command that does. |
debug render --template <name> [flags] | Render once, write a bundle of artifacts and a terse problem report; exit code reflects the worst problem found. |
help, --version | Help / version. |
debug render flags
Renders one template and writes <out-dir>/<template>/<example\|data-hash>/
containing the PDF, the problems found, and whatever else was requested, plus a
manifest.json listing every artifact — including any that was requested but
could not be produced. With --format tar the same bundle is written as a
single archive, <out-dir>/<template>/<example\|data-hash>.tar, holding the
artifacts followed by manifest.json — the same archive the /_debug HTTP
routes return.
Each render replaces its own bundle. The files an earlier render of the
same target left behind are removed before the new ones are written, so a
narrower --artifacts selection discards the page images the last run wrote
rather than keeping them beside a manifest.json that calls them unavailable.
Nothing else under --out-dir is touched — other templates and other data sets
keep their bundles. Ask for every kind you want in one run.
manifest.json also carries a render object: what the browser was told to
do, so a bundle answers "which lifecycle event did this render wait for" on
its own rather than sending a reader back to the params.json that is under
suspicion whenever the question comes up. wait_lifecycle_event is always
present — the server always has a default for it — and the print parameters
appear only where the template set them, because the rest were filled in by
Chromium and this render chose none of them.
{"render": {"wait_lifecycle_event": "callback", "paper_width": 8.27, "print_background": true}}
Under --templates-recursive a template name is several segments deep
(recursive/level-1); its bundle nests to match, under
<out-dir>/recursive/level-1/<data-set>/.
The data hash keeps two data sets from overwriting each other, at the price of
an output path you cannot predict from the command that wrote it. --label
trades back: sweeping one template over many generated data files, name each
bundle after the input and the reports read back in the order you swept them.
for items in 15 20 25 30; do
pdf-server --templates-dir ./templates debug render \
--template invoice --data "sweep/items-$items.json" --label "items-$items" \
--problems-format jsonl --fail-on never
done
| Flag | Default | Controls |
|---|---|---|
--template NAME | — | Template to render (required) |
--data FILE | — | Render with data read from this JSON file |
--example NAME | — | Render this committed example |
--all-examples | false | Render every committed example, one bundle each, one summary line per example; a template that ships no examples is reported and exits 2, not a silent 0 |
--label NAME | — | Name this --data render's bundle instead of addressing it by data hash. One path component, and only with --data: an example already names its own bundle, and --all-examples writes one per example |
--out-dir DIR | .pdf-debug/ | Bundle root; nothing is ever written outside it, and each render replaces its own bundle (above) |
--format | dir | Bundle layout: dir (a directory per data set) or tar (one archive per data set) |
--artifacts | every kind | Comma-separated kinds: pdf, html, snapshot, png (one image per page) — controls only what is written to the bundle; layout rules are always evaluated, regardless of which kinds are selected |
--problems-format | text | How a single render's problems are printed to stdout: text or jsonl |
--fail-on | error | Exit 1 when a problem at or above this severity is found: error, warn, never |
png rasterizes the same PDF the pdf artifact would have been written from,
so asking for png alone still renders one; document.pdf is kept only when
pdf is asked for too. One page-NNN.png is written per page, and each is a
real raster of the printed page — the way to look at the output rather than
infer it from an exit code, and the thing to reach for when the report is clean
but the document still looks wrong.
Every bundle carries the problems the render found as problems.txt (aligned,
one line per finding) and problems.jsonl (one JSON object per line), whatever
--artifacts asked for — Reading a render's
problems explains the format and every kind.
A single render prints the problems it found, in the --problems-format asked
for. --all-examples prints one line per data set instead — name, verdict,
page count, and where the problems were written when there were any — because
a batch is read for which template needs attention, not for the detail of each.
The detail is in every bundle either way. A summary line's page count reads -
when no PDF was rendered, which is not the same as a PDF whose pages could not
be counted; manifest.json likewise omits pages rather than reporting zero.
Exit codes: 0 clean, 1 a problem reached --fail-on, 2 the tool could
not judge — the template is missing, the data is invalid, or the render failed.
The distinction is the point in a pipeline: 1 means the template is wrong, 2
means the check did not run. --all-examples reports the worst outcome of the
batch.
Stdout agrees with the exit code. Every exit-2 path prints a report
headed failed (<class>) and carrying the reason — never ok, which is
reserved for a render that was actually judged. The class names which kind of
thing went wrong:
| Class | Means |
|---|---|
usage | the command was called wrongly — no render target, or more than one |
startup | the tool never became usable: no browser, no template store |
template | the named template or example does not exist, or its name cannot be used as a bundle path |
data | the data was rejected by the template's schema |
render | the document could not be produced from data that was accepted |
output | the document had nowhere to go — the bundle could not be written |
Under --problems-format jsonl the same failure is the stream's last record, a
render-failed problem carrying detail.class and detail.error, so a
consumer parsing one problem per line reads it with the parser it already has.
An --all-examples line names the class too: invoice/overdue failed (data) pages=-.
A render that fails still writes its bundle and still prints its report,
because the collectors have already recorded why the page never printed — a
lifecycle event that never arrived, the asset that stalled it. The artifacts the
browser would have produced are listed in manifest.json as unavailable with
the render error as their reason, the HTML parts are written anyway (they come
from the template engine, not the browser), and the exit code is 2: there is
no document to judge the template by. In an --all-examples batch that data set
reads failed (render) rather than a problem count.
debug logs at error unless --log-level (or LOG_LEVEL) says
otherwise: it is a one-shot tool whose answer is the report it prints, and the
server's default of info would put twenty lines of internal routing in front
of it. Pass --log-level info when the routing is what you are debugging.
A one-shot invocation starts a browser and tears it down (roughly 0.5–2s per
render), where a running server answers from a warm pool in roughly 50–300ms.
Use this command for CI and single checks; for an edit-render loop, run
pdf-server --templates-reload-mode=watch server and drive that instead — the
reload mode is a global flag, so it goes before the subcommand.
Global flags
| Flag | Env | Default | Controls |
|---|---|---|---|
--log-level | LOG_LEVEL | info (error under debug) | trace/debug/info/warning/error/fatal |
--log-format | LOG_FORMAT | text | text or json |
--templates-dir, -t | TEMPLATES_DIR | templates/ | Templates directory |
--compositions-dir | COMPOSITIONS_DIR | compositions/ | Compositions directory |
--templates-reload-mode | TEMPLATES_RELOAD_MODE | none | none, always (reload per request), or watch (watch disk and push live updates to the tester UI over SSE) |
--templates-reload-debounce | TEMPLATES_RELOAD_DEBOUNCE | 250ms | In watch mode, wait for filesystem activity to settle this long before rebuilding |
--sse-subscriber-buffer | SSE_SUBSCRIBER_BUFFER | 16 | Events each SSE client may queue before the hub drops events for it |
--templates-recursive | TEMPLATES_RECURSIVE | false | Load nested templates |
--composition-js-planner-timeout | COMPOSITION_JS_PLANNER_TIMEOUT | 30s | Bound one execution of a composition's planner JavaScript — the module body at load, and each buildPlan call |
--composition-jq-condition-timeout | COMPOSITION_JQ_CONDITION_TIMEOUT | 5s | Bound one jq condition evaluation in a YAML composition planner |
--wait-lifecycle-event | WAIT_LIFECYCLE_EVENT | load | Default event awaited before printing |
--render-timeout | RENDER_TIMEOUT | 30s | Max duration per render |
--render-retry-attempts | RENDER_RETRY_ATTEMPTS | 3 | Render attempts |
Diagnostics
These bound what one debug render collects and what collecting it may cost.
They apply only where diagnostics are asked for — the /_debug routes and
debug render; an ordinary render subscribes to nothing and pays none of it.
A bound that fires is reported in the problem list as diagnostics-capped,
never truncated silently.
| Flag | Env | Default | Controls |
|---|---|---|---|
--diagnostics-per-kind-problems | DIAGNOSTICS_PER_KIND_PROBLEMS | 50 | Max problems of one kind per render |
--diagnostics-total-problems | DIAGNOSTICS_TOTAL_PROBLEMS | 200 | Max problems per render |
--diagnostics-message-chars | DIAGNOSTICS_MESSAGE_CHARS | 500 | Max characters kept from one reported message |
--diagnostics-stack-frames | DIAGNOSTICS_STACK_FRAMES | 5 | Max stack frames kept from one reported exception |
--diagnostics-tracked-requests | DIAGNOSTICS_TRACKED_REQUESTS | 2000 | Max in-flight browser requests tracked per render |
--diagnostics-font-sample-calls | DIAGNOSTICS_FONT_SAMPLE_CALLS | 21 | Max distinct font families sampled per render |
--diagnostics-stream-events | DIAGNOSTICS_STREAM_EVENTS | 10000 | Max browser events consumed from one subscription before it is dropped |
--diagnostics-drain-deadline | DIAGNOSTICS_DRAIN_DEADLINE | 2s | Max time a finished render waits for pending browser events |
--diagnostics-accepted-font-substitutions | DIAGNOSTICS_ACCEPTED_FONT_SUBSTITUTIONS | none | Stand-ins not worth reporting, as requested=used pairs |
A font-substituted problem says the browser rendered a family the page did
not ask for. Whether that changed the layout depends on which fonts this image
has — a stand-in with the same advances breaks lines identically, one without
them does not — so the server reports every substitution and names no font of
its own.
One thing it does read structurally, rather than by naming a font: a family
that is the requested one plus a weight or slope suffix — Open Sans SemiBold
for Open Sans at font-weight: 600, which is how a package that ships each
variant as its own family resolves — is the requested face, not a stand-in for
it, and is not reported. A width suffix (Condensed, Narrow) is reported,
because that is the case where the advances actually changed. A deployment that installed stand-ins deliberately can say so:
--diagnostics-accepted-font-substitutions "Arial=Liberation Sans,Times New Roman=Liberation Serif"
Families a page never named are never reported: the browser's own default is measured in the page, and whatever stands in for it is a browser choice no template author made.
Diagnostics policy
These decide what is reported, where the flags above decide what is collected. Every problem kind has a default severity — the table in Reading a render's problems — and two rules grade a measurement rather than firing on one threshold.
| Flag | Env | Default | Controls |
|---|---|---|---|
--diagnostics-policy | DIAGNOSTICS_POLICY | none | Path to a JSON policy document (below) |
--diagnostics-severity | DIAGNOSTICS_SEVERITY | none | Report one kind at another severity, as kind=severity. Repeatable |
--diagnostics-template-policy | DIAGNOSTICS_TEMPLATE_POLICY | clamp | What a template's own policy may do: clamp, allow or deny |
--layout-max-elements | LAYOUT_MAX_ELEMENTS | 400 | Max elements one snapshot lists |
--layout-min-size-px | LAYOUT_MIN_SIZE_PX | 4 | Smallest box, in CSS px, a snapshot keeps |
The one-line case has a flag of its own: --diagnostics-severity console-warn=off quiets a kind a deployment does not act on, and
--diagnostics-severity empty-page=error makes one fail a build. off is a
severity value, so there is one thing to reason about; a built-in rule
resolved to off is never run at all.
Everything else lives in a policy document:
{
"kinds": {
"console-warn": { "severity": "off" },
"empty-page": { "severity": "error" },
"sparse-final-page": {
"bands": [
{ "under": 0.25, "severity": "info" },
{ "under": 0.10, "severity": "warn" }
]
}
},
"capture": { "max_elements": 1200, "min_size_px": 4 },
"budgets": { "rule_timeout": "10s", "max_problems_per_rule": 50 }
}
bands is the ladder for a kind whose rule grades a measurement —
sparse-final-page grades the share of the last page its content covers,
tiny-font grades a font size in CSS px. The rule reports at the severity of
the tightest band its measurement falls under, and reports nothing when it
falls under none. One band is the ordinary case: a threshold and a severity.
A key this build does not know, a kind it does not report, a severity it does not use, or bands on a rule that measures nothing are all errors at startup. A misspelled setting that quietly does nothing is the one outcome worth failing on.
Per template
A template can carry the same document under a diagnostics key in its
params.json, beside pdf — the layer that knows what the document is
actually like is the one that should say so.
{
"pdf": { "paperWidth": 8.27 },
"diagnostics": {
"capture": { "max_elements": 1200 },
"kinds": { "empty-page": { "severity": "error" } }
}
}
The two blocks are spelled differently, and the difference is load-bearing:
pdf is camelCase (it names Chromium's own print parameters), diagnostics is
snake_case (it is the policy document, the same one --diagnostics-policy
loads). A key spelled the other way is not the key: the policy refuses to load
and says so, while a misspelled pdf key is ignored and its default used — run
pdf-server validate, which rejects both.
What it is allowed to change is the operator's call, because a layout rule exists to fail this template's own gate:
| Mode | A template may |
|---|---|
clamp (default) | only tighten: raise a severity, widen a measured rule's reach, lower a budget |
allow | set anything, as written |
deny | nothing; its diagnostics key is ignored |
Under clamp, a template asking to switch a rule off, or for a budget above
the operator's, is refused — and the refusal is reported as a
policy-clamped line rather than passed over, so an author does not read
their own file as the truth about a render it did not govern.
The resolved policy is written into snapshot.meta.json as policy. A
report without it is not reproducible: an absent finding means either the
document was fine or the rule was switched off, and those read identically.
Browser
| Flag | Env | Default | Controls |
|---|---|---|---|
--browser-command (--chromium-command) | BROWSER_COMMAND, CHROMIUM_COMMAND | chromium | Browser executable |
--browser-args (--chromium-args) | BROWSER_ARGS, CHROMIUM_ARGS | headless flag set | Comma-separated args (%{TMP_DIR}, %{TMP_ID} substitutions) |
--browser-process-group-wait-timeout | BROWSER_PROCESS_GROUP_WAIT_TIMEOUT | 10s | Wait before SIGKILLing the browser process group |
Render pool
| Flag | Env | Default | Controls |
|---|---|---|---|
--render-pool-max-total | RENDER_POOL_MAX_TOTAL | 1 | Max pooled browsers (and render workers) |
--render-pool-min-idle | RENDER_POOL_MIN_IDLE | 1 | Min idle browsers |
--render-pool-max-idle | RENDER_POOL_MAX_IDLE | 1 | Max idle browsers |
--render-pool-time-between-eviction | RENDER_POOL_TIME_BETWEEN_EVICTION | 1h | Eviction interval |
--render-pool-borrow-timeout | RENDER_POOL_BORROW_TIMEOUT | 1m | Max wait to borrow |
--render-pool-repay-timeout | RENDER_POOL_REPAY_TIMEOUT | 5s | Max wait to return |
Factory
| Flag | Env | Default | Controls |
|---|---|---|---|
--factory-type | FACTORY_TYPE | runner | runner (browser per object) or page (tabs in one browser, Chromium only) |
--factory-max-usage-count | FACTORY_MAX_USAGE_COUNT | 1000 | Reuse before recycle |
--factory-max-get-page-attempts | FACTORY_MAX_GET_PAGE_ATTEMPTS | 20 | Attempts to get the initial page |
--factory-get-page-retry-delay | FACTORY_GET_PAGE_RETRY_DELAY | 10µs | Delay between attempts |
--factory-rpc-buffer-size | FACTORY_RPC_BUFFER_SIZE | 65536 | CDP RPC write buffer; must exceed the largest request the build issues |
Tracing (OpenTelemetry)
| Flag | Env | Default | Controls |
|---|---|---|---|
--tracing-enabled | TRACING_ENABLED | false | Enable tracing |
--tracing-collector | TRACING_COLLECTOR | otel | Collector type |
--tracing-service-name | TRACING_SERVICE_NAME | pdf-server | Reported service name |
--tracing-propagators | TRACING_PROPAGATORS, OTEL_PROPAGATORS | — | tracecontext,baggage,b3,… |
--otel-collector-protocol | OTEL_COLLECTOR_PROTOCOL | grpc | grpc or http |
--otel-collector-grpc-addr | OTEL_COLLECTOR_GRPC_ADDR | localhost:4317 | OTLP/gRPC endpoint |
--otel-collector-http-addr | OTEL_COLLECTOR_HTTP_ADDR | localhost:4318 | OTLP/HTTP endpoint |
--tracing-sample-rate | — | 1.0 | Sampling ratio |
--tracing-keep-status-trace | — | false | Keep /_status spans |
--tracing-dial-timeout | TRACING_DIAL_TIMEOUT | 15s | Collector dial timeout |
server flags
| Flag | Env | Default | Controls |
|---|---|---|---|
--addr | SERVER_ADDR | :9999 | HTTP bind address |
--stop-timeout | SERVER_STOP_TIMEOUT | 10s | Graceful shutdown timeout |
--server-reuse-addr | SERVER_REUSE_ADDR | false | SO_REUSEADDR |
--static-dir-enabled | STATIC_DIR_ENABLED | false | Serve /static/* from disk |
--static-dir-path | STATIC_DIR_PATH | ./app/server/static/ | Static dir path |
--debug-enabled | DEBUG_ENABLED | false | Mount the /_debug template-debugging routes (and list them in the spec) |
See Debugging templates for the workflow
debug render supports — which artifact to read first, and when a page image is
worth its cost.