JSON PressBack to site

Deploying

JSON Press is a single stateless process. It needs no database, writes no application state, and makes no outbound network calls. Rendering is CPU-bound; scale by running more instances behind a proxy.

Docker

docker build -t jsonpress:dev .
docker run --rm \
  -e JSONPRESS_API_KEYS=local:dev \
  -p 5000:5000 \
  jsonpress:dev

docker-compose.yml in the repository uses the same image and requires the key to be supplied rather than defaulting to a published credential:

JSONPRESS_API_KEYS=local:dev docker compose up --build

The runtime image is debian:bookworm-slim with only the jsonpress-server binary and the built designer at /srv/designer. It runs as the unprivileged report user (uid 10001), and JSONPRESS_DESIGNER_DIR already points at the built assets. The process handles SIGTERM and drains in-flight requests.

Environment variables

Configuration is read from environment variables. An unparsable value aborts startup rather than falling back silently.

VariableDefaultMeaning
JSONPRESS_API_KEYS—comma-separated name:secret keys; required unless anonymous mode
JSONPRESS_BIND0.0.0.0:5000listen address; in Docker, keep 0.0.0.0 so the port mapping works
JSONPRESS_MAX_BODY_MB10request body cap in MiB
JSONPRESS_MAX_OUTPUT_MB50maximum serialized output in MiB
JSONPRESS_MAX_ROWS50000rows per render
JSONPRESS_MAX_COLUMNS512detail columns per render
JSONPRESS_MAX_PAGES1000pages per PDF
JSONPRESS_TIMEOUT_SECS30render deadline
JSONPRESS_UPLOAD_TIMEOUT_SECS10deadline for receiving a complete request body; a stalled upload receives 408
JSONPRESS_MAX_CONCURRENTmax(1, cores - 1)simultaneous admitted uploads, renders and response transmissions; a full server answers 503
JSONPRESS_RATE_PER_MINUTE60requests per key per fixed one-minute window
JSONPRESS_CORS_ORIGINSemptycomma-separated exact origins; empty means same-origin only and * is rejected
JSONPRESS_SERVE_DESIGNERtrueserve the SPA at /; set 0 for an API-only deployment
JSONPRESS_DESIGNER_DIR./designer/distdirectory containing the built index.html and assets/
JSONPRESS_ALLOW_ANONYMOUSfalsedisable authentication when no keys are configured; forces a loopback bind
JSONPRESS_FONTunsetpath to a TTF/OTF to embed in PDFs; otherwise built-in Helvetica is used. Read per render, not only at startup
JSONPRESS_FONTSunset outside Docker; Noto Sans TC in the Docker imageJSON catalog mapping family names to objects with regular and optional bold font paths
RUST_LOGjsonpress_server=info,tower_http=infotracing filter
TZcontainer default (UTC)timezone used by RenderClock and date()/time() expressions

JSONPRESS_MAX_CONCURRENT=0 is clamped to one render and JSONPRESS_RATE_PER_MINUTE=0 to one request per minute, so a zero fails safe rather than disabling the guard.

Failed authentication is separately limited to 60 attempts per socket peer in a fixed one-minute window. Once exhausted, further API requests from that peer receive 429 with Retry-After: 60, including requests with valid credentials, until the window expires. Successful authentication does not spend this budget. The process retains at most 4,096 peer buckets; additional peers share an overflow bucket. These limits are per instance, not a distributed defense. /healthz is unaffected.

The peer is the TCP connection address, not X-Forwarded-For or other client-supplied headers. Behind a proxy, all clients using the same proxy address share a budget, so one client's failures can temporarily block others. For per-client protection, enforce authentication-attempt limits at a trusted edge proxy that identifies clients securely, and restrict direct access to the server. Embedders serving the router must supply Axum ConnectInfo<SocketAddr>; without it all requests share one fallback bucket.

The Docker image includes Noto Sans TC under the SIL Open Font License and offers it in the designer's font selector. Select it for Traditional Chinese PDFs; Helvetica remains the default when no family is selected. For a local server, set JSONPRESS_FONTS to {"Noto Sans TC":{"regular":"/absolute/path/to/fonts/NotoSansTC-Regular.ttf","bold":"/absolute/path/to/fonts/NotoSansTC-Bold.ttf"}}. DOCX names the selected family but does not embed it, so install the font on the computer opening the document. Overriding JSONPRESS_FONTS replaces the image's catalog; include Noto Sans TC in the override if you still need it.

To let report authors choose among server-approved fonts, mount the font files and configure a catalog:

export JSONPRESS_FONTS='{"Noto Sans":{"regular":"/fonts/NotoSans-Regular.ttf","bold":"/fonts/NotoSans-Bold.ttf"},"Noto Serif":{"regular":"/fonts/NotoSerif-Regular.ttf"}}'
docker run --rm -p 5000:5000 \
  -e JSONPRESS_API_KEYS=local:dev -e JSONPRESS_FONTS \
  -v "$PWD/fonts:/fonts:ro" jsonpress:dev

The designer lists these family names along with built-in Helvetica. A catalog entry without bold uses its regular face for bold text. Paths must be absolute; Helvetica is reserved for the built-in family. Family names are case-sensitive. A template may select a configured name or Helvetica, not a path. If font_family is omitted, the server's JSONPRESS_FONT remains the default; without it, Helvetica is used. Unknown names or unreadable fonts fail PDF rendering.

JSONPRESS_FONT is read when a PDF is rendered, so a mounted default font takes effect without a restart. Configured fonts must be readable and valid. Built-in Helvetica supports WinAnsi text only; unsupported characters fail the render instead of being replaced. Choose a font covering your report's characters for multilingual output.

RenderClock uses the process-local timezone (chrono::Local), and the slim container defaults to UTC. Set TZ (and install or mount the matching tzdata) if today()/time()/page-style output must follow a local calendar.

Docker's default stop timeout is 10 seconds, which is shorter than the 30-second render deadline. The checked-in docker-compose.yml sets stop_grace_period: 45s; use the same value (at least JSONPRESS_TIMEOUT_SECS) when running the container elsewhere so in-flight renders can drain on SIGTERM.

JSONPRESS_ALLOW_ANONYMOUS=1 is intended for a local trial only: the server forces loopback even if keys are also present. Do not use it behind a proxy.

Reverse proxy

Terminate TLS and authentication at your proxy, then forward to the server. Pass the Authorization header through unchanged, and keep the proxy body and timeout limits at least as large as the server's:

server {
    listen 443 ssl;
    server_name reports.internal.example;

    client_max_body_size 10m;          # >= JSONPRESS_MAX_BODY_MB
    client_body_timeout 60s;          # reject stalled uploads

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Authorization $http_authorization;

        proxy_read_timeout 60s;        # > JSONPRESS_TIMEOUT_SECS
        proxy_send_timeout 60s;
    }
}

The server sets an exact-origin CORS allowlist only when JSONPRESS_CORS_ORIGINS is configured; it never emits a wildcard. Prefer same-origin deployment (the designer is served by the same process) or a reverse proxy on the same origin. Do not expose the service directly to end-user browsers; see integrating.md.

Health checks

GET /healthz is public, unauthenticated and not rate-limited. It returns 200 with:

{"status":"ok","version":"0.1.0"}

Use it for container and load-balancer probes. It proves the process is accepting requests; it does not render a document. For a deeper check, render the fixture from a trusted backend on a schedule, or rely on your own synthetic render.

Because the process is stateless, a failed probe can be replaced freely. There is no warm-up step and no database migration.

Operational notes