Skip to main content

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

CommandPurpose
serverRun 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, --versionHelp / 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
FlagDefaultControls
--template NAMETemplate to render (required)
--data FILERender with data read from this JSON file
--example NAMERender this committed example
--all-examplesfalseRender 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 NAMEName 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)
--formatdirBundle layout: dir (a directory per data set) or tar (one archive per data set)
--artifactsevery kindComma-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-formattextHow a single render's problems are printed to stdout: text or jsonl
--fail-onerrorExit 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:

ClassMeans
usagethe command was called wrongly — no render target, or more than one
startupthe tool never became usable: no browser, no template store
templatethe named template or example does not exist, or its name cannot be used as a bundle path
datathe data was rejected by the template's schema
renderthe document could not be produced from data that was accepted
outputthe 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

FlagEnvDefaultControls
--log-levelLOG_LEVELinfo (error under debug)trace/debug/info/warning/error/fatal
--log-formatLOG_FORMATtexttext or json
--templates-dir, -tTEMPLATES_DIRtemplates/Templates directory
--compositions-dirCOMPOSITIONS_DIRcompositions/Compositions directory
--templates-reload-modeTEMPLATES_RELOAD_MODEnonenone, always (reload per request), or watch (watch disk and push live updates to the tester UI over SSE)
--templates-reload-debounceTEMPLATES_RELOAD_DEBOUNCE250msIn watch mode, wait for filesystem activity to settle this long before rebuilding
--sse-subscriber-bufferSSE_SUBSCRIBER_BUFFER16Events each SSE client may queue before the hub drops events for it
--templates-recursiveTEMPLATES_RECURSIVEfalseLoad nested templates
--composition-js-planner-timeoutCOMPOSITION_JS_PLANNER_TIMEOUT30sBound one execution of a composition's planner JavaScript — the module body at load, and each buildPlan call
--composition-jq-condition-timeoutCOMPOSITION_JQ_CONDITION_TIMEOUT5sBound one jq condition evaluation in a YAML composition planner
--wait-lifecycle-eventWAIT_LIFECYCLE_EVENTloadDefault event awaited before printing
--render-timeoutRENDER_TIMEOUT30sMax duration per render
--render-retry-attemptsRENDER_RETRY_ATTEMPTS3Render 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.

FlagEnvDefaultControls
--diagnostics-per-kind-problemsDIAGNOSTICS_PER_KIND_PROBLEMS50Max problems of one kind per render
--diagnostics-total-problemsDIAGNOSTICS_TOTAL_PROBLEMS200Max problems per render
--diagnostics-message-charsDIAGNOSTICS_MESSAGE_CHARS500Max characters kept from one reported message
--diagnostics-stack-framesDIAGNOSTICS_STACK_FRAMES5Max stack frames kept from one reported exception
--diagnostics-tracked-requestsDIAGNOSTICS_TRACKED_REQUESTS2000Max in-flight browser requests tracked per render
--diagnostics-font-sample-callsDIAGNOSTICS_FONT_SAMPLE_CALLS21Max distinct font families sampled per render
--diagnostics-stream-eventsDIAGNOSTICS_STREAM_EVENTS10000Max browser events consumed from one subscription before it is dropped
--diagnostics-drain-deadlineDIAGNOSTICS_DRAIN_DEADLINE2sMax time a finished render waits for pending browser events
--diagnostics-accepted-font-substitutionsDIAGNOSTICS_ACCEPTED_FONT_SUBSTITUTIONSnoneStand-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.

FlagEnvDefaultControls
--diagnostics-policyDIAGNOSTICS_POLICYnonePath to a JSON policy document (below)
--diagnostics-severityDIAGNOSTICS_SEVERITYnoneReport one kind at another severity, as kind=severity. Repeatable
--diagnostics-template-policyDIAGNOSTICS_TEMPLATE_POLICYclampWhat a template's own policy may do: clamp, allow or deny
--layout-max-elementsLAYOUT_MAX_ELEMENTS400Max elements one snapshot lists
--layout-min-size-pxLAYOUT_MIN_SIZE_PX4Smallest 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:

ModeA template may
clamp (default)only tighten: raise a severity, widen a measured rule's reach, lower a budget
allowset anything, as written
denynothing; 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

FlagEnvDefaultControls
--browser-command (--chromium-command)BROWSER_COMMAND, CHROMIUM_COMMANDchromiumBrowser executable
--browser-args (--chromium-args)BROWSER_ARGS, CHROMIUM_ARGSheadless flag setComma-separated args (%{TMP_DIR}, %{TMP_ID} substitutions)
--browser-process-group-wait-timeoutBROWSER_PROCESS_GROUP_WAIT_TIMEOUT10sWait before SIGKILLing the browser process group

Render pool

FlagEnvDefaultControls
--render-pool-max-totalRENDER_POOL_MAX_TOTAL1Max pooled browsers (and render workers)
--render-pool-min-idleRENDER_POOL_MIN_IDLE1Min idle browsers
--render-pool-max-idleRENDER_POOL_MAX_IDLE1Max idle browsers
--render-pool-time-between-evictionRENDER_POOL_TIME_BETWEEN_EVICTION1hEviction interval
--render-pool-borrow-timeoutRENDER_POOL_BORROW_TIMEOUT1mMax wait to borrow
--render-pool-repay-timeoutRENDER_POOL_REPAY_TIMEOUT5sMax wait to return

Factory

FlagEnvDefaultControls
--factory-typeFACTORY_TYPErunnerrunner (browser per object) or page (tabs in one browser, Chromium only)
--factory-max-usage-countFACTORY_MAX_USAGE_COUNT1000Reuse before recycle
--factory-max-get-page-attemptsFACTORY_MAX_GET_PAGE_ATTEMPTS20Attempts to get the initial page
--factory-get-page-retry-delayFACTORY_GET_PAGE_RETRY_DELAY10µsDelay between attempts
--factory-rpc-buffer-sizeFACTORY_RPC_BUFFER_SIZE65536CDP RPC write buffer; must exceed the largest request the build issues

Tracing (OpenTelemetry)

FlagEnvDefaultControls
--tracing-enabledTRACING_ENABLEDfalseEnable tracing
--tracing-collectorTRACING_COLLECTORotelCollector type
--tracing-service-nameTRACING_SERVICE_NAMEpdf-serverReported service name
--tracing-propagatorsTRACING_PROPAGATORS, OTEL_PROPAGATORStracecontext,baggage,b3,…
--otel-collector-protocolOTEL_COLLECTOR_PROTOCOLgrpcgrpc or http
--otel-collector-grpc-addrOTEL_COLLECTOR_GRPC_ADDRlocalhost:4317OTLP/gRPC endpoint
--otel-collector-http-addrOTEL_COLLECTOR_HTTP_ADDRlocalhost:4318OTLP/HTTP endpoint
--tracing-sample-rate1.0Sampling ratio
--tracing-keep-status-tracefalseKeep /_status spans
--tracing-dial-timeoutTRACING_DIAL_TIMEOUT15sCollector dial timeout

server flags

FlagEnvDefaultControls
--addrSERVER_ADDR:9999HTTP bind address
--stop-timeoutSERVER_STOP_TIMEOUT10sGraceful shutdown timeout
--server-reuse-addrSERVER_REUSE_ADDRfalseSO_REUSEADDR
--static-dir-enabledSTATIC_DIR_ENABLEDfalseServe /static/* from disk
--static-dir-pathSTATIC_DIR_PATH./app/server/static/Static dir path
--debug-enabledDEBUG_ENABLEDfalseMount 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.