Skip to content
Registry StackDocsDevelopment (unreleased)

Deploy Registry Render

View as Markdown

You have a package built with registry-render package and callers that need PDFs. When you are done, one registry-render serve process serves that package on a private address behind your TLS proxy, under API-key authentication, with an audit file you ship to append-only storage. Render your first document builds a package.

Registry Render is pre-1.0 and an experimental surface, outside the compatibility promise.

  • One package directory, mounted read-only. Serve verifies it at startup and in the worker on every request, and refuses a package that changes while it runs.
  • One API key in an owner-only file. At least 32 bytes of ASCII. Serve trims one trailing line ending and refuses any other whitespace, because a mis-read key would reject every caller while health stays green.
  • A writable audit directory, or a log collector on standard output. Serve refuses a directory that group or others can write.
  • A TLS proxy in front. The listener serves plain HTTP, reads no forwarded header, and accepts only a loopback, private, or link-local address.
  • A host for one process and its render workers. Each render runs in a worker that is killed at the timeout and, on Linux, capped in address space.

Releases from v0.38.0 attach a Linux amd64 binary, registry-render-<tag>-linux-amd64. Choose <tag> from the latest release, verify the asset with that release’s release/VERIFY.md, then install it. This replaces any existing ~/.local/bin/registry-render, so keep a copy first when you need a rollback:

Terminal window
tag="${TAG:?set TAG to a published tag that includes Registry Render}"
mkdir -p ~/.local/bin
install -m 0755 "registry-render-${tag}-linux-amd64" ~/.local/bin/registry-render
export PATH="$HOME/.local/bin:$PATH"
registry-render --version
registry-render <version> (typst 0.15.1)

Record that line: every audit entry carries the same version, and a stored PDF’s provenance is this binary plus the package digest. On another platform, build from a checkout as the tutorial does; the workspace lockfile is part of Render’s byte contract, so always build with --locked.

The same release publishes the image ghcr.io/registrystack/registry-render:<tag>. It runs registry-render serve with /etc/registry-render/runtime.yaml from Distroless cc-debian13 as the nonroot user (UID 65532), with /var/lib/registry-render as its working directory, and holds no package. Mount the runtime file and the package read-only. Keep the API key owned by UID 65532 with mode 0400 or 0600, and the audit directory owned by UID 65532 with mode 0700. Deploy by the digest release/VERIFY.md authenticates. A proxy in another container reaches Render on its private container address, so set listener.bind to that address; 0.0.0.0 is refused.

Serve reads one YAML file. Unknown keys are refused, and the configuration reference lists every key with its range and default:

apiVersion: id.registrystack.org/formats/render/runtime/v1alpha1
kind: RenderRuntimeConfig
listener:
bind: 127.0.0.1:8080
shutdownGraceMilliseconds: 30000
package:
root: /var/lib/registry-render/package
expectedDigest: sha256:<package-digest>
secretProviders:
file:
root: /run/secrets/registry-render
authentication:
apiKeyRef: secret:file/render-api-key
limits:
renderTimeoutSeconds: 20
maximumConcurrentRenders: 4
audit:
path: /var/lib/registry-render/audit/render.jsonl

These rules are not in the tables:

  • package.root is an absolute path with no . or .. segment; audit.path and secretProviders.file.root are absolute paths.
  • In manifest.yaml, each document’s entryFile and schemaFile is relative to the bundle root and contains no ...
  • package.expectedDigest is optional; when set, serve refuses any other package.
  • secret:file/<name> resolves under secretProviders.file.root. secret:env/<NAME> works only when secretProviders.environment: {} is declared. A reference to an undeclared provider is refused.
  • A file a secret:file/ reference names (the API key, an OIDC key set, a seal key, a CA bundle) is a regular file owned by the user serve runs as, with mode 0400 or 0600 and a single link. Serve follows no symbolic link to one, and refuses any other file at startup with runtime-invalid and the message the referenced secret file is unsafe.
  • A string value may come from the environment as ${VAR}, ${VAR:-default}, or ${VAR:?message}, except in apiKeyRef and under secretProviders.
  • audit.destination: stdout hands entries to your collector and takes no path.
  • shutdownGraceMilliseconds is how long in-flight renders and seals get to finish on SIGTERM; open connections then get the same again. Align it with your supervisor’s stop timeout, and with a seal’s attemptTimeoutMilliseconds, which a seal waiting on its authority may need.
  • The server installs its SIGTERM and Ctrl-C handlers before it accepts requests. If either cannot be installed, startup fails with “cannot install the stop signal handlers” instead of serving with no way to stop cleanly.

Check the file before you deploy it. The check runs offline and reads no secret, so the package, key, and audit directory need not exist yet. Pass the absolute path serve will use, because serve refuses a relative one that check accepts:

Terminal window
registry-render check --runtime-config /etc/registry-render/runtime.yaml
0 errors, 0 warnings in 1 file

A refused file lists every finding with its position and fix, and exits 1:

registry-render check refused the input.
error[render.runtime.public-bind] /etc/registry-render/runtime.yaml:4:9 /listener/bind
listener.bind is not a loopback, private, or link-local address; Render never listens publicly or on all interfaces
next: Bind the private address your proxy forwards to; the proxy terminates TLS.
1 error, 0 warnings in 1 file

A ${VAR} value is left for the deployment unless you add --environment, which substitutes it from the current environment first. In a container preflight, add --require-audit-under <directory> to prove the audit file lands on persistent storage:

audit file resolves under /var/lib/registry-render/audit
0 errors, 0 warnings in 1 file

It refuses a stdout destination, and a path outside the directory with error[render.runtime.audit-outside-root].

Terminal window
registry-render serve --runtime-config /etc/registry-render/runtime.yaml

Serve logs JSON lines to standard error, starting with registry-render serve starting and the package and configuration digests. If it refuses the file, it prints the same findings as check, prefixed registry-render: runtime-invalid: the runtime configuration was refused, and exits 20.

Probe both endpoints before you route traffic:

Terminal window
curl -s http://127.0.0.1:8080/health
curl -s http://127.0.0.1:8080/ready
{"bundleHash":"<package-digest>","bundleVersion":1,"rendererVersion":"<version> (typst 0.15.1)","status":"ok","typstPin":"0.15.1"}
{"status":"ready"}

bundleHash is the package digest without sha256:; compare it with what you deployed. Readiness includes the audit writer, so it fails when the writer stopped or a stdout collector stalled. A stalled collector recovers when it reads again; a stopped writer needs a restart, as audit retention describes. A supervisor can run the same probe:

Terminal window
registry-render healthcheck --runtime-config /etc/registry-render/runtime.yaml
healthy at 127.0.0.1:8080

One process writes one audit file. A second process pointed at the same path does not start:

registry-render: audit-failed: cannot open audit destination: audit sink single-writer lock is already held by another writer: /var/lib/registry-render/audit/render.jsonl.lock

Give each replica its own audit path.

Callers POST one JSON body to /v1/render/<document-type> with the API key. Keep the key off the command line: write the header to a file only the caller reads, as the tutorial does, and pass the file.

Terminal window
curl -s -X POST http://127.0.0.1:8080/v1/render/<document-type> \
-H @auth.header \
-H "Content-Type: application/json" \
-d '{"issuedAt":"<issuance-time>","data":<document-data>}' \
-o document.pdf -D headers.txt

The answer is the PDF with x-registry-pdf-digest, x-registry-data-digest, x-registry-warning-count, and x-registry-document-version headers; each digest is labelled sha256:<hex>. With Accept: application/json it is a JSON object with pdfDigest, dataDigest, documentVersion, warnings, and the PDF as pdfBase64. The bare hex pdfSha256 and dataSha256, and their x-registry-pdf-sha256 and x-registry-data-sha256 headers, are still sent and are deprecated.

Requests must carry Content-Length; a chunked body is refused with invalid-argument, because one that passes the limit mid-stream could not be answered. Idempotency-Key is echoed and recorded as an opaque correlation id; rendering is deterministic, so nothing is deduplicated. registry-render validate --bundle <package> --type <document-type> --data <file> checks data against the document’s schema without rendering.

Failures are RFC 9457 problem documents from a closed vocabulary, such as data-invalid (400), unknown-document (400), unauthorized (401), body-too-large (413), render-timeout (504), and audit-failed (503).

Sealing is optional. Without a signing block, Render loads no key, makes no timestamp call, and serves no seal route. With one, POST /v1/seal/<document-type> renders the document and returns it with a PAdES signature made with your institution’s key. Render seals only what it just rendered from the verified package, never bytes a caller sends. A production seal’s private key stays in Transit, which signs for Render; a test seal’s key file is loaded in the serving process. Neither is ever sent to a render worker. The seal is PAdES baseline B-B or B-T. Render makes no claim that it is a qualified seal under eIDAS or any other scheme: that depends on your certificate, its issuer, and how you hold the key.

You need an OIDC deployment (a seal never works with the legacy API key), a seal certificate chain in a PEM file with the seal certificate first, and the seal key in Vault or OpenBao Transit: an ecdsa-p256 key created non-exportable, reached through a Transit signing proxy on a Unix socket on the Render host. The proxy holds the Transit token; Render sends none. Create the key and its least-privilege policy as Configure Transit signing for Registry Evidence does, and run the proxy with a forced token, its socket group admitting the Render service. Render makes the same calls, a read of the key and a prehashed sha2-256 sign with the pinned key_version, so that policy works with your key name and version in place of Evidence’s.

With use_auto_auth_token = "force", the proxy puts its own token on every request that reaches its socket, so the socket’s owner, group, and mode are the only check on who can sign with the seal key. Keep the tutorial’s proxy settings, with Render’s values. socket_mode = "0660", with socket_group set to Render’s service group, lets only the proxy’s user and that group connect. require_request_header = true makes the proxy refuse a request without the X-Vault-Request: true header, which Render sends on every call. num_retries = -1 makes the proxy fail fast instead of retrying a stalled Vault or OpenBao, so one Render call is one provider request and attemptTimeoutMilliseconds stays the bound on it. After the proxy starts, check the socket with ls -l: its mode must read srw-rw---- and its group must be Render’s. If they do not, correct them before you start serve.

With the proxy running, registry-render keygen --csr <file> --common-name <seal name> --transit-unix-socket-path <socket> --transit-mount <mount> --transit-key-name <key> --transit-key-version <version> reads the key version’s public key and has Transit sign the certificate request your certificate authority issues the seal certificate from. The key never leaves Transit, and keygen writes only the request. Name the seal in the document type’s manifest.yaml entry with seal: ministry, then bind it in the runtime file:

authentication:
oidc:
issuer: https://issuer.example.invalid
audience: urn:example:render
allowedClients: [portal, records-service]
assuranceProfile: production
signing:
seals:
ministry:
signer:
type: transit
unixSocketPath: /run/registry-render/transit.sock
mount: transit
keyName: render-seal
keyVersion: 1
attemptTimeoutMilliseconds: 2000
certificateChainFile: /etc/registry-render/seal/chain.pem
timestampAuthority:
url: https://tsa.example.invalid/tsr
attemptTimeoutMilliseconds: 10000
allowedClients: [records-service]

Render reads the issuer’s signing keys from its OpenID Connect discovery document, or from authentication.oidc.jwksSource: a key set URL (type: uri) or a key set held as a secret (type: static with documentRef, such as secret:file/jwks.json). Connect an OIDC issuer covers each choice.

  • assuranceProfile is required at the top of the runtime file once it declares a seal, with the same member and values Evidence uses. production means every seal signs through transit: Render refuses a production runtime with a key file (render.runtime.signer-assurance-mismatch). local means every seal is a test seal signed with a key file; see Use a test seal in development.
  • signer.unixSocketPath is the proxy’s socket, an absolute path. mount, keyName, and keyVersion name one Transit key version: pinning the version means a rotation in Transit never changes the key behind your certificate unnoticed. attemptTimeoutMilliseconds (1 to 30000) bounds each call to the proxy.
  • allowedClients is required and names each client allowed to seal. When authentication.oidc.allowedClients lists clients, each seal client must also be in that list, or Render refuses the file. Keep these separate from any citizen-facing client. unrestricted and wildcards are refused.
  • Callers need the render:seal scope. render:render does not imply it.
  • timestampAuthority timestamps each seal (B-T), so it keeps validating after the certificate expires. Declare withoutTimestamp: seal-stops-validating-when-certificate-expires instead to seal without a timestamp (B-B), and accept that the seal stops validating at expiry. Declare exactly one.
  • Seal one test document through your timestamp authority before you rely on it for B-T. Render accepts only a DER answer whose token is signed with RSA PKCS#1 v1.5 or ECDSA and names the authority’s certificate in an ESS signing-certificate attribute; the configuration reference lists the limits.
  • Render reaches the timestamp authority over TLS with the serving host’s root certificates. If the authority’s TLS certificate chains to a national or other private root, set timestampAuthority.caBundleRef to a secret reference to that root as PEM, such as secret:file/tsa-ca.pem. The bundle then replaces the host’s roots for that authority alone.
  • A failing timestamp authority fails the request with timestamp-failed (502). Render does not retry and never returns an untimestamped file in its place. The audit’s response entry and one seal refused log warning name the failing check as reason, such as timed-out or nonce-mismatch; the configuration reference lists them.
  • A seal releases its render slot before it signs and waits on the timestamp authority. Seals in flight are bounded by limits.maximumConcurrentRenders on their own: one more is refused with rate-limited (429) rather than queued behind a slow authority.

Run registry-render check --bundle <package> --runtime-config runtime.yaml --environment. It reads each chain file, never the key, and it fails on an unreadable or malformed chain, a seal certificate without a signing key usage, or a chain too large for the signature space. It warns about a certificate that is expired, not yet valid, or expires within 30 days, and about one whose extended key usage names no document-signing purpose (render.runtime.seal-certificate-purpose): validators such as Acrobat flag its seals, so serve also logs a seal certificate purpose excludes document signing warning for it at startup. When serve starts, it also checks that the key matches the certificate, and it refuses to start if it does not. For a Transit seal, serve reads the key version’s public key from Transit and signs once to test it. It refuses to start if the proxy does not answer or refuses the key read, if the key is not a non-exportable, non-derived ecdsa-p256 key without plaintext backup, or if keyVersion is outside the key’s live versions (render.runtime.seal-transit-unavailable). A seal that cannot load, for any of these reasons or an unreadable key or CA bundle, stops serve with exit code 20 (runtime-invalid) and an error at the member’s line in the runtime file. If the runtime names a seal the package does not use, or the package names a seal the runtime does not bind, check reports it, and serve refuses the second case. If the package names seals and the runtime has no signing block at all, check and serve warn (render.runtime.seals-not-served): sealing is off, those document types render unsealed on the render route, and the seal route does not exist.

Then call the route with a token that carries render:seal. The request body is the render route’s. The PDF answer carries X-Registry-Document-Digest, X-Registry-Signed-File-Digest, X-Registry-Seal-Id, X-Registry-Seal-Level (B-T or B-B), and X-Registry-Certificate-Digest, plus X-Registry-Timestamped-At for B-T. It also carries X-Registry-Package-Digest (packageDigest in JSON), the form package.expectedDigest takes, so a client that holds only render:seal can compare the package with what you deployed without reading /v1/documents. A document type that names no seal is refused with document-not-sealable (400); /v1/documents lists the seal of each document type the route takes as seal, so a client can tell them apart first. The configuration reference lists the keys, problems, and response shapes.

Issue a test certificate from your own test authority and set assuranceProfile: local in the runtime file. Under that profile every seal keeps its key in a file instead of Transit: signer.type: local-jwk with privateKeyRef naming one ES256, ES384, RS256, or RS384 private JWK under the file secret provider, such as secret:file/seal.jwk; an environment reference is refused (render.runtime.seal-key-requires-file-provider). registry-render keygen --key <file> --csr <file> --common-name <seal name> writes a new key owner-only with its certificate request; add --from-pem <file> to convert a key you hold as unencrypted PKCS #8 instead. The template then receives signing.testOnly: true in its payload on the seal route, and can print a visible test marking (the payload contract is products/render/PAYLOAD.md in the repository); the registry-render init scaffold prints it. Render also says so where a template cannot hide it: testOnly: true in the JSON answer’s seal, in /ready and in the audit, an X-Registry-Seal-Test-Only: true header on the PDF answer, and one test-only seal warning per seal when serve starts. Use a loopback timestamp authority (http://127.0.0.1:<port>/... is accepted on loopback only) or withoutTimestamp while you develop. Never copy a test seal’s runtime into production.

Render refuses a CA certificate as the seal certificate, so a self-signed root alone cannot seal. This makes a test root and a seal key, and has the root issue the seal certificate: a leaf with basicConstraints CA:FALSE. It needs OpenSSL and registry-render. Run it in an empty directory:

Terminal window
mkdir -m 700 secrets
cat > test-root.cnf <<'EOF'
[req]
distinguished_name = subject
x509_extensions = root
prompt = no
[subject]
CN = Test seal root (development only)
[root]
basicConstraints = critical,CA:TRUE
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
EOF
openssl req -x509 -config test-root.cnf -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-nodes -days 365 -keyout test-root.key -out test-root.pem
registry-render keygen --key secrets/seal.jwk --csr seal.csr \
--common-name "Test seal" --organization "Example Ministry (test)" --country FR
cat > seal.ext <<'EOF'
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,nonRepudiation
extendedKeyUsage = 1.3.6.1.5.5.7.3.36
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
EOF
openssl x509 -req -in seal.csr -CA test-root.pem -CAkey test-root.key -CAcreateserial \
-days 90 -sha256 -extfile seal.ext -out seal.pem
cat seal.pem test-root.pem > chain.pem

1.3.6.1.5.5.7.3.36 is the document-signing extended key usage (RFC 9336). Set secretProviders.file.root to the absolute path of secrets, and bind the seal as a test seal without a timestamp:

assuranceProfile: local
signing:
seals:
ministry:
signer:
type: local-jwk
privateKeyRef: secret:file/seal.jwk
certificateChainFile: /absolute/path/to/chain.pem
withoutTimestamp: seal-stops-validating-when-certificate-expires
allowedClients: [records-service]

Put assuranceProfile and this signing block in a runtime file that has the authentication block from Enable a seal. Name the seal in the document type’s manifest.yaml entry with seal: ministry, and run registry-render package again. Then run check --environment and serve as above. Callers need a token from your issuer that carries render:seal, issued to a client the seal’s allowedClients names. test-root.key can issue certificates: keep it out of every deployment, and never trust the test root outside development.

Run check --environment on a schedule. Thirty days before expiry it warns with render.runtime.seal-certificate-expiring. serve logs the same window when it starts, as a seal certificate expiring warning with the seal_id, and a seal that cannot sign then as a seal unavailable warning with the seal_id and the /ready reason. It does not log again while running, so restart or /ready is how a later expiry shows. To renew, write the new chain file with the seal certificate first, point certificateChainFile at it (or replace the file), and restart serve: Render reads the chain at startup. To move a Transit seal to a new key version, rotate the key in Transit, run keygen with the new --transit-key-version, have the new certificate issued, then change keyVersion and the chain file together and restart serve. After the old certificate expires the seal is unavailable and plain rendering is unaffected. A file sealed earlier with a timestamp is built to keep validating after the certificate expires, subject to the validator’s trust decisions; a file sealed without one stops validating at expiry.

When seals are configured, /ready adds a signing member with one entry per seal id. Each is {"status": "ready"} or {"status": "not-ready", "reason": ...}, where the reason is certificate-expired, certificate-not-yet-valid, or signer-not-ready. A seal never changes the status code: Render stays ready for plain rendering while a seal cannot sign, and a seal request answers signing-unavailable or seal-certificate-invalid (503) until it can. Readiness reads the certificate validity and the signer’s last known state; it never calls the timestamp authority, so a ready seal can still fail with timestamp-failed. A Transit seal turns signer-not-ready only after a sign call fails, so after its proxy stops, /ready reports it ready until a seal request fails.

A Transit seal whose proxy stops answering fails the seal request in progress with signing-unavailable and turns signer-not-ready. /ready calls and seal requests then start probes, each of which has Transit sign one probe message, and the seal can sign again once a probe succeeds. /ready starts the probe and never waits for it: it answers at once with the state the last finished probe left, so the seal shows ready on a later call. A seal request waits for the probe it starts. Each seal has at most one probe in flight, and after a probe fails the next starts no sooner than one second after it ended, however many calls arrive. You do not restart serve after a Transit or proxy outage.

A seal call writes a request entry and a response entry that share correlation, like any render. Both carry sealId. The response entry adds documentDigest (the first revision, the document Render rendered), signedFileDigest (the whole signed file), certificateDigest, and, for B-T, timestampTokenDigest, each labelled sha256:<hex>. Store the two document digests with the record. To check a stored file against the audit:

Terminal window
sha256sum signed.pdf

The digest, prefixed with sha256:, must equal signedFileDigest in the response entry. A sealed file has no pdfDigest; two seals of the same request differ in their signed bytes, because each carries its signing time, but their first revision is the same. The audit never holds a certificate subject, a signer name, a key, or a token.

A PDF viewer decides trust from its own trust list. When your seal’s issuing root is not in a viewer’s list, the viewer may show the signature as “validity unknown”. That is the viewer’s verdict on the issuer, not a failure of the file’s integrity.

  • The signature field is invisible. A template prints any visible seal block from the payload’s signing member. registry-render compile --preview-seal test-only (or production) renders that block offline, with --signer-name as the certificate’s display name, and signs nothing.
  • Render embeds no revocation data (no B-LT or B-LTA) and does not pin the timestamp authority’s identity; validators decide trust in both.
  • The seal key comes from a secret reference to a local JWK, and no HSM or managed-key custody exists yet. A render worker that gained code execution runs as the serving user and can read a secret:file key, so keep a production seal key off such a host until that custody lands.
  • A render worker starts from an empty environment but is not a sandbox. A worker that gained code execution could get arbitrary content sealed, because it renders the content, and could rewrite a file audit destination. For a deployment that seals, use audit.destination: stdout and ship it to storage the serving user cannot write.
  • On macOS, such a worker can also read the serving process’s environment, including every ${VAR} and secret:env value. Seal on Linux, where serve closes that.
  • veraPDF has passed a test template at every PDF/A level Render accepts, and at PDF/UA-1, both as rendered and once sealed. Your own template is not checked against its level: validate a sealed sample of each document type with a PDF/A validator before you issue it.

Each line is one entry with schema (render.registrystack.org/audit/v1), eventId, time, phase, correlation, and a value-free record: hashes, versions, the caller’s key fingerprint, and the outcome. To list outcomes:

Terminal window
jq -c '{correlation, phase, outcome: .record.outcome, problem: .record.problem}' \
/var/lib/registry-render/audit/render.jsonl

A call that reaches the renderer writes a request entry before the render starts and a response entry before the answer leaves; both share correlation. Data the schema rejects is found inside the render, so it also writes both. A refusal before the render, such as an unknown document type or a chunked body, writes one response entry. If any of these writes fails, the call fails closed with audit-failed. A wrong key or an oversized body also writes one entry, but if that write fails the refusal is still answered and the failure is logged. A call whose caller disconnects is recorded as unfinished.

When the active file would pass rotateBytes, it is renamed to <path>.<sequence> and a new file opens. At startup and at each rotation, sealed files older than retentionDays are deleted. The file is not chained or signed, so ship sealed files, or the stdout stream, to append-only storage before retention deletes them. A render worker runs as the serving user, so one that gained code execution could rewrite the file; a deployment that seals should use stdout.

A Typst, compression-library, or template change can change output bytes. Build every template change into a new package directory and point package.root at it; registry-render package refuses an existing directory, so a published digest never gains different bytes. Before you switch to a new binary, render a stored request with it and compare pdfDigest with the stored value. A changed hash means documents rendered after the switch differ from earlier ones.

Every file Render reads has a command that checks it offline. Each prints the diagnostics, JSON output, and exit codes that Check a file before you deploy it describes.

Format and filesCheck command
Render bundle manifest
  • manifest.yaml
registry-render check --bundle <project>
Render label catalogue
  • labels/*.yaml
registry-render check --bundle <project>
Render runtime configuration
  • runtime.yaml
registry-render check --runtime-config <file>
Render document data schema
  • schemas/*.schema.json
None