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.
Top comments (0)