Deployment and operations
The render service (@rd/server) is a stateless HTTP service. It takes a template and JSON data and returns a PDF. It stores nothing, needs no database and makes
no outbound network calls unless you allow remote images. It also serves this developer portal at /, the user guide at /help/, and the standalone designer bundle at /sdk/.
Two ways to install:
- Docker (recommended):
packages/server/Dockerfile - Plain Node.js 20+: the
rd-core-<version>.tgzandrd-server-<version>.tgztarballs
Docker#
Build the image from the product's root folder:
docker build -f packages/server/Dockerfile -t reportdesigner-server:1.0.0 .
Run it:
docker run -d --name reportdesigner -p 8080:8080 \
-v /opt/reportdesigner/license.txt:/run/secrets/reportdesigner-license:ro \
-e LICENSE_FILE=/run/secrets/reportdesigner-license \
-v /opt/reportdesigner/fonts:/fonts:ro -e RD_FONT_DIR=/fonts \
reportdesigner-server:1.0.0
- The image runs as the unprivileged
nodeuser, listens on 8080 and has aHEALTHCHECKagainst/health. - Both mounts are optional. This image is built from the npm packages, which carry no evaluation license: without
LICENSE_KEYorLICENSE_FILE, every page is watermarked UNLICENSED (License). The release executable and its image include an evaluation license instead. - On
SIGTERM, the server stops accepting work and answers new requests with 503. It lets in-flight renders finish for up toRD_RENDER_TIMEOUT_MS+ 5 s, then exits. Set your orchestrator's termination grace period to at least that long.
Kubernetes#
apiVersion: apps/v1
kind: Deployment
metadata: { name: reportdesigner }
spec:
replicas: 2
selector: { matchLabels: { app: reportdesigner } }
template:
metadata: { labels: { app: reportdesigner } }
spec:
terminationGracePeriodSeconds: 70
containers:
- name: server
image: registry.internal.example.com/reportdesigner-server:1.0.0
ports: [{ containerPort: 8080 }]
env:
- { name: RD_MAX_CONCURRENCY, value: "2" }
- name: LICENSE_KEY
valueFrom: { secretKeyRef: { name: reportdesigner-license, key: license } }
- { name: RD_INSTANCE_ID, valueFrom: { fieldRef: { fieldPath: metadata.name } } }
resources:
requests: { cpu: "2", memory: 1Gi }
limits: { memory: 1536Mi }
readinessProbe: { httpGet: { path: /ready, port: 8080 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 15 }
Plain Node.js#
mkdir -p /opt/reportdesigner && cd /opt/reportdesigner
npm init -y
npm install --omit=dev ./rd-fonts-1.0.0.tgz ./rd-fonts-cjk-1.0.0.tgz ./rd-core-1.0.0.tgz ./rd-server-1.0.0.tgz
PORT=8080 LICENSE_FILE=/opt/reportdesigner/license.txt node node_modules/@rd/server/dist/main.js
Run it under systemd, pm2 or your platform's process manager with automatic restart. A systemd unit:
[Unit]
Description=Report Designer render service
After=network.target
[Service]
WorkingDirectory=/opt/reportdesigner
ExecStart=/usr/bin/node node_modules/@rd/server/dist/main.js
Environment=PORT=8080 LICENSE_FILE=/opt/reportdesigner/license.txt
User=reportdesigner
Restart=always
TimeoutStopSec=70
[Install]
WantedBy=multi-user.target
Configuration#
All settings are environment variables. Invalid values stop the server at startup with a clear message (exit code 2).
| Variable | Default | Description |
|---|---|---|
PORT | 8080 | Listen port |
HOST | 0.0.0.0 | Listen address |
LOG_LEVEL | info | fatal, error, warn, info, debug, trace or silent. Logs are JSON lines on stdout |
LICENSE_KEY | none | Your license key (the text that starts with RD2.). Wins over LICENSE_FILE (License) |
LICENSE_FILE | none | Path of a file that holds the license key, for example a mounted secret. Without either variable the release executable uses its bundled evaluation license; the npm packages have none (PDFs are watermarked UNLICENSED) |
RD_INSTANCE_ID | derived from the hostname | Fixed id for this installation, shown on the Status page and in GET /v1/info (1–128 of A-Z a-z 0-9 . _ : -) (License) |
RD_DATA_DIR | none | Folder where the server keeps a random seed for the installation id (instance-id). Without it the server writes nothing |
RD_BODY_LIMIT_MB | 20 | Maximum request size. Larger requests get 413 |
RD_MAX_PAGES | 2000 | Maximum pages per document. Larger documents get 422 |
RD_RENDER_TIMEOUT_MS | 60000 | Per-request deadline including queue time. The render's worker thread is terminated on timeout (504) |
RD_MAX_CONCURRENCY | CPU count (max 8) | Parallel renders, one worker thread each |
RD_WORKER_MAX_MEMORY_MB | 512 | Heap limit per render worker. Exceeding it fails the request with 422 render-memory-exceeded, and the worker is replaced |
RD_MAX_QUEUE | 4 × concurrency | Requests that may wait for a worker. Beyond that: 503 with Retry-After |
RD_CORS_ORIGINS | none (CORS off) | Comma-separated origins allowed to call the API from browsers, or * |
RD_IMAGE_URL_ALLOWLIST | none (remote images off) | Comma-separated hostnames (cdn.example.com, *.example.com) that https images may be fetched from |
RD_IMAGE_MAX_MB | 10 | Size cap per remote image |
RD_IMAGE_TIMEOUT_MS | 10000 | Fetch timeout per remote image |
RD_MAX_IMAGE_PIXELS | 25000000 | Images with more pixels (width × height) are skipped with a warning (decompression-bomb guard) |
RD_MAX_IMAGE_BYTES | 52428800 | Total encoded image bytes per render. Images beyond it are skipped with a warning |
RD_FONT_DIR | none | Folder of extra .ttf/.otf fonts (corporate fonts, more CJK fonts) |
RD_SERVE_DOCS | true | Serve the developer portal at / and the user guide at /help/. With false (API-only deployments), / returns a small JSON index of the service and /help/ answers 404. /sdk/ stays on |
RD_PORTAL_DIR | the packaged portal | Serve the portal from another folder (development: RD_PORTAL_DIR=portal/dist) |
RD_BUNDLED_FONT_DIR | the installed @rd/fonts (release executables: fonts/ next to the binary) | Folder of the bundled fonts, with their manifest.json |
RD_CJK_FONT_DIR | the installed @rd/fonts-cjk; with RD_BUNDLED_FONT_DIR set, that same folder | Folder of the Chinese, Japanese and Korean fonts, with their manifest-cjk.json. If it has none, CJK text renders as blank boxes with one warning |
RD_HELP_FRAME_ANCESTORS | * | CSP frame-ancestors for the guide pages: who may show them in an iframe (space- or comma-separated: origins such as https://app.example.com, 'self', 'none', *). Values with ; or line breaks stop the server at startup. Set it explicitly in production |
Extra fonts#
Name the files <Family>-<Regular|Bold|Italic|BoldItalic>.ttf, for example Acme Sans-Bold.ttf. A file without a suffix is the regular face. Templates can
then use fontFamily: "Acme Sans". GET /api/v1/fonts lists the bundled and server fonts. To show text in these fonts in the designer, add the same files to the
designer's fonts folder (Install), or upload them into the template from the designer's Report panel.
Guides served by the service#
Besides the API, the service serves static pages, bundled into the package and the Docker image at build time:
| Path | Content |
|---|---|
GET / | This developer portal: docs, the API reference, the Status page and the Playground |
GET /help/ | The User Guide, the same content as the guide built into the designer. /help redirects (301) to /help/ |
GET /sdk/<version>/…, GET /sdk/latest/… | The standalone designer bundle (report-designer.js and its sibling files) and its fonts, for script-tag hosts. Only the files listed in /sdk/<version>/manifest.json (with their sha384 integrity values) are served, plus fonts/…: the files the service's own font manifests list (manifest.json, manifest-cjk.json), read from the same font folders it renders with (not in the SRI list: fonts are fetched, not loaded as scripts). Versioned URLs are immutable (cached for a year); latest is revalidated. CORS is open (Access-Control-Allow-Origin: *, Cross-Origin-Resource-Policy: cross-origin) |
GET /docs/developer/… | Redirects (301) to /: the developer guide moved into this portal |
Portal and guide pages are never under /api/, are not part of the API, and are not listed in openapi.json. If a guide is missing from the installation, its path answers 404 and the
server logs a warning at startup.
In-app help doesn't need it. The designer has the user guide built in. The server's /help/ is for reading the guide in a browser or linking to it from your
own help pages. You can also make the designer show it instead of its built-in copy, with the optional override
<report-designer help-base-url="https://reports.internal.example.com/help/">. The drawer then embeds the pages in an iframe, so:
- set
RD_HELP_FRAME_ANCESTORSto your app's origin(s), for example'self' https://app.customer.com. The default*allows any site to frame the pages; the pages are read-only, without forms, authentication or third-party scripts, so the risk is low, but production should list its origins (or'none'if nothing should frame them); - allow the service's origin in your app's CSP
frame-src.
Response headers on guide pages:
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors <RD_HELP_FRAME_ANCESTORS> |
X-Content-Type-Options | nosniff |
Referrer-Policy | no-referrer |
Cache-Control | HTML 5 minutes, CSS and JS 1 hour, images and fonts 1 day |
API-only deployments. Set RD_SERVE_DOCS=false to disable both paths (they then answer 404 like any unknown route).
Reverse proxy and TLS#
Terminate TLS at your proxy or gateway. An nginx example:
location /reportdesigner/ {
proxy_pass http://127.0.0.1:8080/;
client_max_body_size 20m; # keep in line with RD_BODY_LIMIT_MB
proxy_read_timeout 75s; # above RD_RENDER_TIMEOUT_MS
proxy_set_header X-Request-Id $request_id;
}
The server echoes a well-formed X-Request-Id (at most 64 characters, [A-Za-z0-9._-]) or generates one. It appears in the logs and on every response.
Health checks and monitoring#
| Probe | Use |
|---|---|
GET /health ({ status, version, license }) | Liveness |
GET /ready ({ status, inFlight, capacity }, 503 while draining) | Readiness and load-balancer health |
GET /v1/info | License state: alert when license.daysLeft is below your renewal lead time, or license.watermark isn't null |
Useful alerts: the rate of 503 responses (capacity), 504s (timeouts), 422 render-memory-exceeded, a non-zero X-Render-Warnings rate for production templates,
and p95 render latency.
Logging#
Logs are JSON lines on stdout (pino format). Each request logs its request id, method, path, status and duration, and each render logs the page count, warning count,
bytes, renderMs and the watermark (null for none). The license is logged once at startup (license valid (LICENSE_KEY / LICENSE_FILE): Licensed to …)
and again only when its status changes, for example on the day after it expires. Request bodies are never logged, because templates and data can contain personal data.
Sizing#
How many API calls are served at once#
Each render runs in its own worker thread. At any moment the service renders up to RD_MAX_CONCURRENCY documents in parallel, and lets up to
RD_MAX_QUEUE more requests wait for a free worker (default: 4 × concurrency). A request beyond that is answered at once with 503 and a
Retry-After header, so callers should retry 503s with a short backoff. GET /ready reports inFlight and capacity.
RD_MAX_CONCURRENCY | Rendering at once | Waiting (default RD_MAX_QUEUE) | Accepted at once, in total |
|---|---|---|---|
| 1 | 1 | 4 | 5 |
| 2 | 2 | 8 | 10 |
| 4 | 4 | 16 | 20 |
| 8 (the default maximum) | 8 | 32 | 40 |
Throughput#
Measured with the release executable on one machine (Apple M5 Max, 2026-10-22), rendering the example 3-page BOL with 60 line items through
POST /api/v1/render. Cloud vCPUs are often 2–3× slower per render: measure with your own templates and data.
| Requests at once | BOLs per second | Pages per second | Median response | 95th percentile |
|---|---|---|---|---|
| 1 | 15 | 46 | 62 ms | 76 ms |
| 8 (8 workers) | 81 | 244 | 79 ms | 122 ms |
| 16 (8 workers, 8 waiting) | 103 | 309 | 150 ms | 174 ms |
- Time grows with the page count, about 17–18 ms per page: a 1-page label takes about 11 ms, a 16-page document about 0.3 s, a 144-page one about 2.6 s.
- Throughput grows with workers up to the number of cores. More workers than cores only adds waiting time and memory.
- The first request after a start takes about 300 ms, while the workers load the fonts.
Memory#
- Base: about 130 MB for the process.
- Per worker: about 130 MB after its first render, settling at about 200–250 MB under steady load. The live data of one render is much smaller;
the rest is heap the worker keeps between renders. A lower
RD_WORKER_MAX_MEMORY_MBmakes it collect sooner. - Per page: a document is laid out completely in memory before it is written, about 4 MB of heap per page while it renders. A document that
needs more than
RD_WORKER_MAX_MEMORY_MBfails with 422render-memory-exceeded; the worker is replaced and other requests are unaffected. With the default 512 MB, documents up to about 70–75 pages like the example BOL fit; with 256 MB, about 35–40 pages. - Total: plan for base + workers ×
RD_WORKER_MAX_MEMORY_MB, plus room for the operating system.
The defaults are for large hosts. On a host with several cores and little memory (for example 4 vCPUs and 2 GB), the default 4 workers × 512 MB can exceed the machine's memory under load or with one large document. The operating system then stops the whole service, and every render in flight fails until it restarts. Set both values on small hosts:
| Host memory | RD_MAX_CONCURRENCY | RD_WORKER_MAX_MEMORY_MB | Peak measured | Largest BOL-like document |
|---|---|---|---|---|
| 1 GB | 1 | 256 | about 600 MB | about 35–40 pages |
| 2 GB | 2 | 256 | about 820 MB | about 35–40 pages |
| 2 GB, longer documents | 1 | 512 | about 820 MB | about 70–75 pages |
| 4 GB | 4 | 512 | about 1.2 GB steady, up to about 2.2 GB (estimate: base + 4 × 512 MB) | about 70–75 pages |
| 8 GB and more | 8 | 512 (or more for longer documents) | about 2.3 GB | about 70–75 pages at 512 MB |
In containers, set the memory limit at least to the total above (see the Kubernetes example). The service is stateless: scale horizontally behind a load balancer for more throughput, with no sticky sessions.
Upgrades#
Releases are drop-in: stop, replace the image or tarballs, start. During a rolling upgrade, old and new instances can serve side by side, because templates are backwards compatible (Upgrading). Upgrade the designer (front end) and the render service together, so that previews and production output use the same engine version.