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>.tgz and rd-server-<version>.tgz tarballs

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 node user, listens on 8080 and has a HEALTHCHECK against /health.
  • Both mounts are optional. This image is built from the npm packages, which carry no evaluation license: without LICENSE_KEY or LICENSE_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 to RD_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).

VariableDefaultDescription
PORT8080Listen port
HOST0.0.0.0Listen address
LOG_LEVELinfofatal, error, warn, info, debug, trace or silent. Logs are JSON lines on stdout
LICENSE_KEYnoneYour license key (the text that starts with RD2.). Wins over LICENSE_FILE (License)
LICENSE_FILEnonePath 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_IDderived from the hostnameFixed 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_DIRnoneFolder where the server keeps a random seed for the installation id (instance-id). Without it the server writes nothing
RD_BODY_LIMIT_MB20Maximum request size. Larger requests get 413
RD_MAX_PAGES2000Maximum pages per document. Larger documents get 422
RD_RENDER_TIMEOUT_MS60000Per-request deadline including queue time. The render's worker thread is terminated on timeout (504)
RD_MAX_CONCURRENCYCPU count (max 8)Parallel renders, one worker thread each
RD_WORKER_MAX_MEMORY_MB512Heap limit per render worker. Exceeding it fails the request with 422 render-memory-exceeded, and the worker is replaced
RD_MAX_QUEUE4 × concurrencyRequests that may wait for a worker. Beyond that: 503 with Retry-After
RD_CORS_ORIGINSnone (CORS off)Comma-separated origins allowed to call the API from browsers, or *
RD_IMAGE_URL_ALLOWLISTnone (remote images off)Comma-separated hostnames (cdn.example.com, *.example.com) that https images may be fetched from
RD_IMAGE_MAX_MB10Size cap per remote image
RD_IMAGE_TIMEOUT_MS10000Fetch timeout per remote image
RD_MAX_IMAGE_PIXELS25000000Images with more pixels (width × height) are skipped with a warning (decompression-bomb guard)
RD_MAX_IMAGE_BYTES52428800Total encoded image bytes per render. Images beyond it are skipped with a warning
RD_FONT_DIRnoneFolder of extra .ttf/.otf fonts (corporate fonts, more CJK fonts)
RD_SERVE_DOCStrueServe 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_DIRthe packaged portalServe the portal from another folder (development: RD_PORTAL_DIR=portal/dist)
RD_BUNDLED_FONT_DIRthe installed @rd/fonts (release executables: fonts/ next to the binary)Folder of the bundled fonts, with their manifest.json
RD_CJK_FONT_DIRthe installed @rd/fonts-cjk; with RD_BUNDLED_FONT_DIR set, that same folderFolder 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:

PathContent
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_ANCESTORS to 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:

HeaderValue
Content-Security-Policydefault-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-Optionsnosniff
Referrer-Policyno-referrer
Cache-ControlHTML 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#

ProbeUse
GET /health ({ status, version, license })Liveness
GET /ready ({ status, inFlight, capacity }, 503 while draining)Readiness and load-balancer health
GET /v1/infoLicense 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_CONCURRENCYRendering at onceWaiting (default RD_MAX_QUEUE)Accepted at once, in total
1145
22810
441620
8 (the default maximum)83240

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 onceBOLs per secondPages per secondMedian response95th percentile
1154662 ms76 ms
8 (8 workers)8124479 ms122 ms
16 (8 workers, 8 waiting)103309150 ms174 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_MB makes 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_MB fails with 422 render-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 memoryRD_MAX_CONCURRENCYRD_WORKER_MAX_MEMORY_MBPeak measuredLargest BOL-like document
1 GB1256about 600 MBabout 35–40 pages
2 GB2256about 820 MBabout 35–40 pages
2 GB, longer documents1512about 820 MBabout 70–75 pages
4 GB4512about 1.2 GB steady, up to about 2.2 GB (estimate: base + 4 × 512 MB)about 70–75 pages
8 GB and more8512 (or more for longer documents)about 2.3 GBabout 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.