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.
| Variable | Default | Meaning |
|---|---|---|
JSONPRESS_API_KEYS | — | comma-separated name:secret keys; required unless anonymous mode |
JSONPRESS_BIND | 0.0.0.0:5000 | listen address; in Docker, keep 0.0.0.0 so the port mapping works |
JSONPRESS_MAX_BODY_MB | 10 | request body cap in MiB |
JSONPRESS_MAX_OUTPUT_MB | 50 | maximum serialized output in MiB |
JSONPRESS_MAX_ROWS | 50000 | rows per render |
JSONPRESS_MAX_COLUMNS | 512 | detail columns per render |
JSONPRESS_MAX_PAGES | 1000 | pages per PDF |
JSONPRESS_TIMEOUT_SECS | 30 | render deadline |
JSONPRESS_UPLOAD_TIMEOUT_SECS | 10 | deadline for receiving a complete request body; a stalled upload receives 408 |
JSONPRESS_MAX_CONCURRENT | max(1, cores - 1) | simultaneous admitted uploads, renders and response transmissions; a full server answers 503 |
JSONPRESS_RATE_PER_MINUTE | 60 | requests per key per fixed one-minute window |
JSONPRESS_CORS_ORIGINS | empty | comma-separated exact origins; empty means same-origin only and * is rejected |
JSONPRESS_SERVE_DESIGNER | true | serve the SPA at /; set 0 for an API-only deployment |
JSONPRESS_DESIGNER_DIR | ./designer/dist | directory containing the built index.html and assets/ |
JSONPRESS_ALLOW_ANONYMOUS | false | disable authentication when no keys are configured; forces a loopback bind |
JSONPRESS_FONT | unset | path to a TTF/OTF to embed in PDFs; otherwise built-in Helvetica is used. Read per render, not only at startup |
JSONPRESS_FONTS | unset outside Docker; Noto Sans TC in the Docker image | JSON catalog mapping family names to objects with regular and optional bold font paths |
RUST_LOG | jsonpress_server=info,tower_http=info | tracing filter |
TZ | container 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
- No database. Templates and rows arrive in each request; nothing is persisted. The container filesystem can be read-only apart from whatever your logging driver needs.
- No outbound network. There are no external services, license checks or telemetry. Font files configured on the server must be mounted into the container.
- Per-instance limits. Concurrency permits and rate-limit buckets live in
the process. Running two replicas gives each its own
JSONPRESS_RATE_PER_MINUTEallowance and concurrency cap; enforce global quotas at the proxy if you need them. - Memory. A render holds its rows, intermediate document and output bytes in memory. The limits bound amplification and output size, not total process RSS; size the container for the number of concurrent renders you allow. Slow downloads retain their admission slot until the output buffers are released.
- Logs. Structured
tracinglines go to stdout. A render logs its key name and output format; key secrets are never logged.