The goal
Carving out just the report-PDF-generation component into a Linux container, callable from existing C# or Node.js applications, is an increasingly common need — usually driven by wanting to cut Windows server licensing/ops costs, or to autoscale on Kubernetes.
This article walks through that setup using ACR (Across Report), a Rust-based report engine I develop: running a native renderer in a Docker + Linux environment and calling it from existing C# / Node.js code with minimal changes. The key idea is to ship neither a browser engine nor a .NET runtime in the container — just a single native binary.
Overall architecture
┌─────────────────────────┐ │ C# app / Node.js app │ └───────────┬─────────────┘ │ FFI (axr_layout.so) / N-API (acr_node_native.so) ┌───────────▼─────────────┐ │ Docker container (Linux) │ │ ├ acr_core │ ← JSON template → RenderCommand │ ├ acr_renderer_skia │ ← RenderCommand → PDF/PNG (Skia) │ └ fonts-vlgothic │ ← Japanese fonts └─────────────────────────┘ What you bundle into the container depends slightly on the calling language:
- From C#: call
libaxr_layout.sovia P/Invoke (the same FFI interface as Windows'axr_layout.dll) - From Node.js:
requiretheacr_node_nativenative addon (built with napi-rs) directly
Either path ultimately generates the PDF/PNG through the same underlying Rust binary.
Build details that matter
1. Building the Rust core
cargo build --release This builds every crate, including acr_core / acr_renderer_skia, for Linux. None of the Windows-ARM64-specific workarounds apply here — skia-safe's prebuilt binaries just work on Linux x86_64.
2. Calling from Node.js (napi-rs)
The Node.js-facing native addon lives in the acr_node_native crate. It's a cdylib built with N-API (napi / napi-derive), exposing functions you can call directly from JS:
// index.d.ts (excerpt) export function renderPdf(templateJson: string, dataJson: string, outputPath: string): number export function renderPreview(templateJson: string, dataJson: string): Array<PageImage> To build it:
cd acr_node_native npm install npx napi build --platform --release If your workspace customizes target-dir (e.g., via .cargo/config.toml), napi build's post-processing step can fail to locate the .node file. In that case, fall back to:
cargo build --release -p acr_node_native # then manually copy/rename the built .so to index.<platform-triple>.node / index.node Baking this manual copy step directly into your Dockerfile's multi-stage build avoids this trap.
3. Example Dockerfile (Node.js consumer)
# ---- build stage ---- FROM rust:1.78-bookworm AS builder WORKDIR /app COPY . . RUN cargo build --release -p acr_node_native # ---- runtime stage ---- FROM node:20-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ fonts-vlgothic \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/target/release/libacr_node_native.so ./index.node COPY acr_node_native/index.js acr_node_native/index.d.ts ./ COPY acr_node_native/package.json ./ # COPY your app code here, and call it via require('./index.node') Two things matter here:
- Separate the build stage from the runtime stage so the final image doesn't carry the Rust toolchain, keeping it lean
- Only install Japanese fonts in the runtime stage (
apt install fonts-vlgothic) — the build stage doesn't need them
4. Calling from C
If your C# app is already on .NET (Core) and cross-platform-ready, referencing libaxr_layout.so via P/Invoke just works on Linux, with the exact same calling code as on Windows.
char* render_html(const char* template_json, const char* data_json); void free_string(char* ptr); If you're still on .NET Framework, migrating to .NET (Core) / .NET 8+ is a prerequisite — but since the report-rendering call interface is identical to the Windows version, you can "Linux-ify and validate the report engine first, and tackle the rest of the app's .NET migration separately, later."
Why this setup ends up lighter
Compared to Chrome-based PDF generation (Puppeteer, etc.), this architecture wins on a few fronts:
- No need to ship a full Chromium install (hundreds of MB) in the runtime image
- No "renderer process" concept means none of the process-management/memory-monitoring complexity that scales with page count
- A single self-contained Rust binary means faster container startup and shorter cold starts under autoscaling
Summary
- Carving out just report-PDF generation into a Linux container, callable natively from C# or Node.js, is achievable via a multi-stage build plus bundling Japanese fonts only in the runtime stage
- With two call paths available — a napi-rs addon for Node.js, and a shared
.so/.dllFFI interface for C# — the same engine can serve callers regardless of language - Not depending on a browser engine gives you an edge on both image size and startup speed
Note / consultation: I offer consulting on migrating existing report systems (e.g., moving off ActiveReports) using ACR (Across Report), as well as reducing server costs by moving off Puppeteer-based setups.
If you're dealing with rising license renewal costs, or want to port your system cheaply to Linux / cloud environments, feel free to reach out via Across Systems contact.