A blazingly fast standalone microservice that converts HTML to PDF. Built with Rust.
2.1K
HTML + Minijinja templates → PDF. Rust, rendered in-process by Fulgur (no browser, no external binaries). Returns the PDF or uploads it to S3.
~1,000 invoices/s on 10 cores · ~2.5 ms per render · amd64 + arm64 image.
docker run -d -p 6767:6767 \
-e API_BEARER_TOKEN=secret \
-e STORAGE_URL=http://minio:9000 -e STORAGE_BUCKET=pdfs \
-e STORAGE_ACCESS_KEY=... -e STORAGE_SECRET_KEY=... \
zalven88/pdf-engine:1.1.0
STORAGE_* is required even if you only use /api/to-bytes. Inside Docker Compose, use the
service name (e.g. http://minio:9000), not localhost.
| Endpoint | Auth | Does |
|---|---|---|
POST /api/to-bytes | Bearer | Returns the PDF |
POST /api/to-s3 | Bearer | Uploads, returns JSON with a presigned URL |
POST /api/fonts/reload | Bearer | Reloads fonts without a restart |
GET /health | — | {"status":"ok","version":"…"} |
GET /metrics | METRICS_TOKEN if set | Prometheus metrics (only when enabled) |
Form fields (multipart/form-data):
| Field | Default | |
|---|---|---|
template | required | HTML/Minijinja template |
data | null | JSON for the template |
width / height | 8.5in / 11in | Length in in, mm, cm, pt, pc, px |
orientation | portrait | or landscape |
filename | document.pdf | /api/to-bytes download name |
password | — | Encrypts the PDF |
curl -H "Authorization: Bearer secret" \
--form-string 'template=<h1>Invoice {{ no }}</h1>' -F 'data={"no": 42}' \
http://localhost:6767/api/to-bytes -o invoice.pdf
Responses are gzip/brotli/zstd compressed (level 6) when the client asks.
| Variable | Default | |
|---|---|---|
API_BEARER_TOKEN | required | API token |
STORAGE_URL, STORAGE_BUCKET, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY | required | S3 / MinIO |
STORAGE_REGION | us-east-1 | |
API_PORT | 6767 | |
RENDER_TIMEOUT_SECS | 45 | Per request; 408 when exceeded |
ASSET_ALLOWED_HOSTS | off | Hosts <img> may load from, e.g. cdn.example.com,*.assets.io |
ASSET_MAX_BYTES / ASSET_TIMEOUT_MS / ASSET_MAX_IMAGES | 5242880 / 5000 / 20 | Per-image limits |
ASSET_ALLOW_HTTP / ASSET_ALLOW_PRIVATE_IPS | false | Only for trusted internal networks |
FONT_WATCH / FONT_WATCH_DIR | false / /usr/share/fonts/pdf-engine | Reload fonts when the directory changes |
METRICS_ENABLED / METRICS_TOKEN | false / — | Enable /metrics, optionally protected |
OTEL_EXPORTER_OTLP_ENDPOINT | off | Export traces over OTLP/HTTP (OTEL_SERVICE_NAME, other OTEL_* honoured) |
RUST_LOG | info | Log level (stderr) |
Everything optional is off by default; set it in .env or the container environment.
<img src="data:image/..."> always works. Remote https:// images load only from
ASSET_ALLOWED_HOSTS: no redirects, no private/loopback addresses (checked after DNS), images
only, size and time limits. Blocked or broken images are skipped; the PDF still renders.
The image ships 29 Noto fonts (Latin, Serif, Mono, CJK, Arabic, Hebrew, Thai, emoji) via
fonts/fonts.conf. sans-serif/Arial → Noto Sans, serif → Noto Serif,
monospace → Noto Sans Mono. To add one: copy it into /usr/share/fonts/pdf-engine, then
POST /api/fonts/reload (or run with FONT_WATCH=true).
Most document CSS works. Known gaps (e.g. repeating <thead>, float, z-index) and
workarounds: docs/css.md.
| 20-line invoices, sustained | ~970 PDFs/s (100k in ~1.7 min) |
| Invoice render | ~2.5 ms |
| Memory | ~450 MB steady; plan ~1.5 GB for large CJK documents |
Full results and sizing: docs/performance.md.
make test # functional test suites
make check # fmt + tests (CI)
make bench # micro-benchmarks
make stress # load test (MODE=matrix|sweep|soak)
make help # everything else
No Rust locally? Use the docker- variants (make docker-test, ...).
Bump version in Cargo.toml, add a CHANGELOG.md entry, commit, then
make release. It tags vX.Y.Z and pushes zalven88/pdf-engine:X.Y.Z, X.Y, X and
latest for amd64 and arm64.
MIT. Rendering by Fulgur (MIT/Apache-2.0); fonts by Noto (OFL).
| Command | What it measures | Takes |
|---|---|---|
make stress | Load test: a release server under concurrent HTTP traffic. Throughput (PDFs/s), latency percentiles and peak memory per scenario, a concurrency sweep, and a 20,000-PDF soak. Use it for capacity planning. | ~10 min |
make bench | Micro-benchmarks: single-threaded cost of each step (render per document type, templating, encryption, gzip), measured with Criterion. Use it to compare code or Fulgur versions. | ~3 min |
Results: one machine, 10 CPUs (Linux aarch64, Docker Desktop), via make docker-stress / make docker-bench. Rerun on your own hardware before capacity planning.
| Workload | PDFs/s | 100k PDFs |
|---|---|---|
| 20-line invoices, sustained over 20,000 | ~970 | ~1.7 min |
| Production-like mix (60% invoices / 30% two-page invoices / 10% CJK) | ~640 | ~2.6 min |
| 10-page reports | ~295 | ~5.7 min |
| 200-line (multi-page) invoices | ~200 | ~8.3 min |
| Scenario | PDFs/s | p50 | p99 | Peak memory |
|---|---|---|---|---|
| Small document | 2,841 | 8.8 ms | 31 ms | 332 MB |
| 20-line invoice | 1,269 | 22 ms | 47 ms | 423 MB |
| 200-line invoice (multi-page) | 202 | 149 ms | 212 ms | 498 MB |
| 10-page report (grid, page breaks) | 295 | 98 ms | 132 ms | 407 MB |
| Password-encrypted PDF | 997 | 24 ms | 82 ms | 599 MB |
| 500-row table full of CJK + emoji | 55 | 533 ms | 912 ms | 1.1 GB |
| Mixed traffic | 642 | 43 ms | 90 ms | 838 MB |
All 2,100 requests in the matrix returned 200.
| In-flight requests | PDFs/s | p50 | p99 | Peak memory |
|---|---|---|---|---|
| 1 | 223 | 4.4 ms | 6.9 ms | 78 MB |
| 2 | 396 | 4.0 ms | 13.8 ms | 93 MB |
| 5 | 1,094 | 4.4 ms | 7.8 ms | 132 MB |
| 10 | 1,193 | 7.3 ms | 18.7 ms | 231 MB |
| 20 | 1,401 | 13.2 ms | 26.6 ms | 337 MB |
| 40 | 1,424 | 26.1 ms | 45.7 ms | 371 MB |
| 80 | 1,236 | 54.9 ms | 89.4 ms | 540 MB |
Throughput peaks at two to four in-flight requests per core. Beyond that, requests queue on the render semaphore (one permit per core), so latency rises while memory stays bounded.
| PDFs so far | PDFs/s | p99 | Server memory |
|---|---|---|---|
| 0 (idle) | 73 MB | ||
| 2,000 | 1,007 | 78 ms | 428 MB |
| 6,000 | 852 | 99 ms | 463 MB |
| 10,000 | 938 | 111 ms | 444 MB |
| 14,000 | 866 | 71 ms | 453 MB |
| 20,000 | 957 | 68 ms | 427 MB |
Memory plateaus around 450 MB with no sustained growth (mimalloc keeps freed memory for reuse), and every request succeeded.
| Step | Time | Notes |
|---|---|---|
| Render a tiny document | 1.4 ms | heading + paragraph |
| Render a flexbox layout | 1.8 ms | tests/fixtures/css/05_flexbox.html |
| Render a grid layout | 1.7 ms | 06_grid.html |
| Render an invoice | 3.3 ms | 08_invoice.html |
| Render Chinese, Japanese and Korean text | 6.9 ms | font fallback across scripts |
| Render a 300-row table | 24.7 ms | multi-page |
| Fill a template with 500 rows | 0.44 ms | Minijinja |
Encrypt a PDF (password) | 0.60 ms | lopdf, RC4 128-bit |
| Hand the PDF to the response without a password | < 1 µs | zero-copy |
| gzip level 1 / 6 / 9 a small PDF | 74 / 160 / 164 µs | 168 / 78 / 76 MiB/s |
Rendering is where the time goes. Templating, encryption and compression together add well under 1 ms. Level 6 compresses almost as well as level 9 at the same cost.
vendor/fulgur, see PATCHES.md) builds it once per process, which made renders 4–6× faster. Fonts added after startup need a restart.RENDER_TIMEOUT_SECS above your p99 for the largest documents you generate. A render can't be interrupted once started; a timed-out render keeps its concurrency slot until it finishes.FONTCONFIG_FILE pointed at the curated set; every extra font file adds per-render cost.Accept-Encoding get the raw PDF.make stress # load test: matrix + concurrency sweep + 20k soak
make stress MODE=matrix # one part only: matrix | sweep | soak
make bench # micro-benchmarks (local runs also write target/criterion/report/index.html)
# Load-test a deployed instance (no memory numbers):
PDF_URL=https://pdf.example.com API_BEARER_TOKEN=... make stress MODE=matrix
make stress runs scripts/loadtest.sh. Without PDF_URL it builds a release binary and starts a fresh server for every scenario, measuring that server's peak memory. Use the production font set (FONTCONFIG_FILE, or make docker-stress) for representative numbers. Tunables: REQUESTS, CONCURRENCY, SWEEP, SOAK_ROUNDS, SOAK_BATCH.
Each scenario is an ignored test in tests/stress_tests.rs and can run on its own:
STRESS_URL=http://localhost:6767 API_BEARER_TOKEN=... STRESS_REQUESTS=2000 STRESS_CONCURRENCY=32 \
cargo test --release --test stress_tests -- --ignored --nocapture stress_to_bytes_invoice
Set STRESS_MAX_P99_MS to make a scenario fail when its p99 latency exceeds the limit; that works as a performance gate in CI. Micro-benchmark sources are in benches/render.rs.
Content type
Image
Digest
sha256:b62005d67…
Size
91.5 MB
Last updated
8 days ago
docker pull zalven88/pdf-engine