Deployment & Self-Hosting
DynDyn.Pdf is a self-contained .NET 10 codebase with no external services, no database, and no telemetry — everything (extraction, font synthesis, HTML composition) runs in-process, and the native rendering library is restored from NuGet for the target RID. That makes it straightforward to run on-prem, in a locked-down container, or air-gapped.
There are two deployable surfaces:
| Surface | Project | Use it for |
|---|---|---|
CLI (dyndyn-pdf) |
src/DynDyn.Pdf.Cli |
Batch/one-shot conversion, cron/pipeline jobs, scripting |
| Web service | src/DynDyn.Pdf.Web |
An HTTP upload + convert + download API (browser front-end included) |
Both link the same DynDyn.Pdf.Core engine, so output is identical regardless of how you invoke it.
1. Run the CLI
Build & publish
# Framework-dependent build (needs the .NET 10 runtime on the host)
dotnet build -c Release
# Self-contained, single-file publish for a specific RID (no runtime install needed on the host)
dotnet publish src/DynDyn.Pdf.Cli -c Release -r linux-x64 --self-contained \
-p:PublishSingleFile=true -o ./publish/linux-x64
dotnet publish src/DynDyn.Pdf.Cli -c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true -o ./publish/win-x64
The correct native rendering library for the publish RID is auto-restored from NuGet per RID and
copied automatically (no manual install). Supported RIDs: win-x64/arm64/x86,
linux-x64/arm64 (incl. musl/Alpine), osx-x64/arm64. No apt install step.
Invoke
# One document → one self-contained HTML file
dyndyn-pdf convert report.pdf -o out/
# A whole folder, 8 documents at a time, per-page output
dyndyn-pdf convert reports/ -o out/ --format pages --jobs 8
# A glob, selected pages, viewer app packaging
dyndyn-pdf convert "reports/**/*.pdf" -o out/ --format viewer --pages 1-20
# Sanity-check the native stack / inspect a document without converting
dyndyn-pdf info report.pdf
Exit codes (script against these): 0 success, 1 conversion failed, 2 invalid arguments,
3 input not found, 4 a native-only feature was requested but the native rendering library is unavailable
for this RID (use --engine managed with an SVG background, the default, which needs no native lib),
5 the conversion completed but tripped --fail-on, 6 the PDF is password-protected and no
password (or the wrong one) was supplied via --password, 7 the conversion was aborted by
--timeout, 8 the PDF is encrypted with a security handler this engine does not implement (e.g.
/Adobe.PubSec public-key encryption) — no password can unlock it. Full table in
configuration.md.
See configuration.md for every flag and api.md for the library API.
2. Run the Web service
The service is a minimal-API ASP.NET Core app. It accepts a PDF upload, converts it on a background
task, streams progress over Server-Sent Events, and serves the result for download/preview. A simple
upload page ships in wwwroot/index.html.
# Development
dotnet run --project src/DynDyn.Pdf.Web
# Production publish
dotnet publish src/DynDyn.Pdf.Web -c Release -r linux-x64 --self-contained -o ./publish/web
./publish/web/DynDyn.Pdf.Web --urls http://0.0.0.0:8080
Endpoints (full contract in api.md):
| Method | Route | Purpose |
|---|---|---|
POST |
/api/jobs |
Upload a PDF (multipart/form-data) + options → { jobId, token } |
GET |
/api/jobs/{id}/events?token=… |
SSE stream: progress events, then done/error |
DELETE |
/api/jobs/{id}?token=… |
Cancel an in-flight/queued job and free its concurrency slot early |
GET |
/api/jobs/{id}/download?token=… |
Download the result (HTML, or a .zip for per-page output) |
GET |
/api/jobs/{id}/preview?token=… |
Inline HTML preview (single/viewer formats) |
GET |
/ |
Bundled upload UI (static files from wwwroot) |
Every route above except POST /api/jobs requires the per-job ownership token returned by
POST /api/jobs — it is returned exactly once, and every other request for that job answers 403
if the token is missing or doesn't match. See api.md for why this
exists: it is what stops one caller from reading, downloading, or cancelling another caller's job by
guessing or observing a jobId, which matters most for open/anonymous deployments where many callers
share one API key or none at all.
The service enables Server GC and concurrent GC (DynDyn.Pdf.Web.csproj) so parallel page
extraction isn't serialized by GC contention, and RollForward=Major so it runs on a newer shared
framework when the exact 10.0 runtime isn't present.
Operational notes for the current build
- Job storage is on local disk and in-memory. Uploads and results live under
%TEMP%/dyndyn-pdf-web/<jobId>and the job registry is a process-localConcurrentDictionary. Jobs do not survive a restart and are not shared across instances — see Scaling below. - Upload cap is 1 GB by default (
DynDynPdf:MaxUploadBytes, KestrelMaxRequestBodySize+ multipart limit). Lower it in config, or (preferably) also cap it at your reverse proxy. - Uploads are validated by magic header only.
POST /api/jobsrejects anything whose first 5 bytes are not%PDF-with400; this is a cheap sniff, not a parse — the conversion pipeline is still the thing that must survive a hostile-but-well-headed PDF. - API-key auth is wired in (
ApiKeyAuthMiddleware), off by default in the sense that no keys are configured — see Authentication in api.md for the exact loopback / anonymous / keyed decision table. SetDynDynPdf:ApiKeysbefore exposing the service beyond loopback, orDynDynPdf:AllowAnonymous=trueonly if a gateway in front of it already enforces auth. - DPI is clamped 18–600 server-side regardless of the requested value.
- Page count per job can be capped with
DynDynPdf:MaxPagesPerJob(0 = unlimited, the default); set it on any deployment where the page count itself — not just DPI/size — must be bounded regardless of what the request asks for (e.g. a public demo capped at 10 pages).
3. Container guidance
The native rendering library ships in the NuGet packages, so the runtime image needs no extra apt packages — the
stock mcr.microsoft.com/dotnet/aspnet:10.0 image already carries its dependencies (glibc,
libstdc++, ICU via the base image). For Alpine, publish with a -musl RID and use the
aspnet:10.0-alpine base; the musl native build is restored automatically.
Sample Dockerfile (Web service)
# ---- build ----
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore src/DynDyn.Pdf.Web/DynDyn.Pdf.Web.csproj
RUN dotnet publish src/DynDyn.Pdf.Web/DynDyn.Pdf.Web.csproj \
-c Release -o /app --no-restore
# ---- runtime ----
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# The native rendering library is delivered via NuGet and published under runtimes/<rid>/native —
# no apt-get install is required.
COPY --from=build /app .
# Run as a non-root user; the app only writes to its work dir (see below).
RUN useradd -u 10001 -m appuser
USER appuser
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_gcServer=1
EXPOSE 8080
# Job scratch space. Mount an emptyDir/tmpfs here in orchestration; it is transient.
VOLUME ["/tmp"]
ENTRYPOINT ["dotnet", "DynDyn.Pdf.Web.dll"]
For the CLI in a container, swap the entrypoint for ["dotnet", "DynDyn.Pdf.Cli.dll"] (or the
self-contained single-file binary) and mount input/output volumes. Because conversion is CPU-bound
and stateless, CLI containers are ideal as short-lived batch jobs.
Hardening tips (details in security.md):
- Run read-only-root with a writable
/tmp(the Web service only writes job scratch there). - Set CPU/memory limits — conversion is CPU-bound and memory scales with page complexity/DPI.
- Drop all Linux capabilities; the app needs none.
4. Configuration reference
The CLI takes all configuration as command-line flags — there is no config file. See configuration.md for the complete flag table with defaults.
The Web service reads standard ASP.NET Core host configuration plus its own DynDynPdf section
(appsettings.json, environment variables as DynDynPdf__Setting, or the command line):
| Setting | Source | Default | Notes |
|---|---|---|---|
| Listen URL(s) | ASPNETCORE_URLS env / --urls |
http://localhost:5000 |
Standard Kestrel binding |
| Environment | ASPNETCORE_ENVIRONMENT env |
Production |
Standard |
| Server GC | DOTNET_gcServer env / csproj |
on (csproj) | Enabled in-project; keep on |
| Job scratch root | Path.GetTempPath() |
OS temp | %TEMP%/dyndyn-pdf-web |
| Native library override | DYNDYN_PDF_NATIVE_LIB env |
unset | Point at a pinned/patched native rendering library file or directory |
DynDynPdf:ApiKeys |
config | empty | Comma-separated accepted keys; empty = loopback-only unless AllowAnonymous |
DynDynPdf:AllowAnonymous |
config | false |
Open /api/* to non-loopback callers when no keys are configured |
DynDynPdf:MaxConcurrentConversions |
config | 0 (auto) |
0/negative derives from cores + available memory |
DynDynPdf:MaxLiveJobs |
config | 200 |
New uploads past this get 503 |
DynDynPdf:JobTtlMinutes |
config | 30 |
Idle job (and its temp dir) eviction |
DynDynPdf:CleanupIntervalSeconds |
config | 60 |
Reaper scan interval |
DynDynPdf:RateLimitRequestsPerMinute |
config | 120 |
Per-key/per-IP fixed-window budget on /api/* |
DynDynPdf:MaxUploadBytes |
config | 1 GB |
Kestrel + multipart body cap |
DynDynPdf:ConversionTimeoutSeconds |
config | 600 |
Hard per-job timeout; 0/negative disables |
DynDynPdf:MaxPagesPerJob |
config | 0 (unlimited) |
Hard cap on pages converted per job; cannot be exceeded from the request |
Full field docs (defaults, "why") live on DynDynPdfWebOptions itself; see
api.md for the exact auth decision table. OpenTelemetry export is opt-in
and unrelated to this section — see AddDynDynPdfObservability / standard OTEL_* env vars; until
configured, the app logs to stdout (AddSimpleConsole), which your platform can scrape.
Wire any of these through environment variables in your container/orchestrator so no secrets land in the image.
5. Scaling
Within one document
--parallelism N (CLI) / ConversionOptions.MaxDegreeOfParallelism caps the managed
post-processing threads (font build, deflate, SVG/HTML composition). Native extraction is inherently
single-threaded (the native renderer isn't thread-safe; managed extraction is the bottleneck), so this scales the
second half of the pipeline.
Across documents (batch)
--jobs N (CLI) / BatchOptions.MaxConcurrentDocuments converts N PDFs at once, each fully
isolated (its own engine/reader/font registry). This is the ~1.35x throughput lever for the
managed engine and depends on Server GC being enabled (it is, in the Web project and via
DOTNET_gcServer=1). When --parallelism is unset in batch mode, per-document parallelism is
derived as max(1, cores / jobs) so jobs × per-doc threads never oversubscribes the CPU.
Horizontal
The Web service today keeps jobs in-process and on local disk, so it does not natively load-balance across replicas: a client must talk to the same instance for the whole upload → events → download lifecycle. To scale out now:
- Sticky sessions — pin a client to one replica for the job's lifetime (jobId-based routing or cookie affinity at the load balancer).
- Sharded / job-per-pod — front with a queue and run one conversion per short-lived pod
(natural fit for the stateless CLI as a Kubernetes
Job). - Scale vertically first — conversion is CPU-bound; more cores + Server GC is the cheapest win.
A future revision that externalizes job state (shared object storage + a distributed registry) will make the Web service horizontally scalable without affinity; until then prefer the CLI-as-batch-job pattern for high-volume, multi-node throughput.
6. On-prem & data sovereignty
DynDyn.Pdf is designed to be run entirely inside your own trust boundary:
- No network egress. Conversion makes no outbound calls. Documents never leave the host. The only network you introduce is the Web service's own inbound HTTP (which you control), and NuGet restore at build time (do this once, then ship a self-contained artifact to air-gapped hosts).
- No third-party processing. There is no cloud API, no license server, no phone-home. Fonts are synthesized locally from the PDF's own glyph outlines; images are re-encoded locally.
- Self-contained output. A
single/viewerHTML file embeds its fonts, images, and SVG as data URIs — no external references, so the artifact itself is portable and offline-viewable and leaks nothing about your infrastructure. - Deterministic, auditable builds.
ConversionOptions.Deterministic = truefixes font timestamps and ordering for byte-reproducible output, which supports golden-file review and regulated archival (e.g. ESEF filings). - Data minimization. The Web service writes only the upload and its result under a per-job temp directory; wipe that directory (or mount it as tmpfs) to guarantee nothing persists. The CLI writes only the output you asked for.
For regulated / sovereign deployments, run the CLI or Web service in your own datacenter or private cloud, restrict egress at the network layer, and rely on the self-contained output for downstream distribution. See security.md for the output-safety posture and a hardening checklist.