Click or drag to resize
Pdf Library for .NET

Remote Engine

The conversion engines normally run on the machine the application runs on, from the SelectPdf.Universal.Native.<rid> package. With ChromiumRemote the application sends that work over HTTP to the SelectPdf engine instead - a service that ships as the container image ghcr.io/selectpdf/engine and runs on another machine, as another container, or beside the application in the same pod.

Every native operation goes to the engine: HTML to PDF and HTML to image (the Chromium engine), and PDF to text and PDF to image (the PDF tool). Everything else stays in the application and works exactly as before: building the document, headers and footers, bookmarks, PDF/A and PDF/UA, security and signatures, Word to PDF, redaction, forms - and the license check. The engine itself needs no license.

Use the remote engine to:

  • Run on Alpine Linux and other musl-based systems, where the native engines cannot run. This is the supported way to use the library there.

  • Keep the application small: it references only the managed SelectPdf.Universal package - no native package and no fonts package.

  • Isolate rendering from the application, scale it separately, or share one engine between several applications.

Quick start

Start the engine with an access token:

docker run -d --name selectpdf-engine -p 8080:8080 \
  --read-only --tmpfs /tmp \
  -e SELECTPDF_ENGINE_TOKEN="$(openssl rand -base64 32)" \
  ghcr.io/selectpdf/engine:26.5

Then point the application at it, once at startup. The conversion code does not change:

GlobalProperties.RenderingEngine = RenderingEngine.ChromiumRemote;
GlobalProperties.EngineEndpoint = "http://localhost:8080";
GlobalProperties.EngineAccessToken = "the token the engine was started with";

HtmlToPdf converter = new HtmlToPdf();
PdfDocument doc = converter.ConvertUrl("https://selectpdf.com");
doc.Save("test.pdf");
doc.Close();

The same settings can come from environment variables instead, with no code at all - which is how a container deployment usually does it:

SELECTPDF_RENDERING_ENGINE=ChromiumRemote
SELECTPDF_ENGINE_ENDPOINT=http://selectpdf-engine:8080
SELECTPDF_ENGINE_TOKEN=the token the engine was started with

The samples include a complete Docker Compose example, docker/remote-engine: an application on mcr.microsoft.com/dotnet/aspnet:10.0-alpine and the engine as two services.

Application settings

Property

Environment variable

Meaning

RenderingEngine

SELECTPDF_RENDERING_ENGINE

Chromium (the default: the engines run on this machine) or ChromiumRemote. Every converter follows it unless its own RenderingEngine property is set, and the PDF tools always follow it.

EngineEndpoint

SELECTPDF_ENGINE_ENDPOINT

The engine's url. Default http://selectpdf-engine:8080: an engine service named selectpdf-engine on the same Docker network or cluster.

EngineAccessToken

SELECTPDF_ENGINE_TOKEN

One of the tokens the engine was started with, or none when the engine requires none.

EngineTimeout

SELECTPDF_ENGINE_TIMEOUT (seconds)

How long one request may take. By default it follows the conversion's own navigation and print timeouts, plus time to wait for a free slot on a busy engine.

A single converter can use the other engine than the global setting through its own RenderingEngine (and the RenderingEngine property of HtmlToImage, PdfHtmlElement, PdfHtmlSection and HtmlToImageElement).

Running the engine

The image is ghcr.io/selectpdf/engine (mirrored on Docker Hub as selectpdf/engine), for x64 and arm64. Use the tag of the library's version - 26.5.0, or 26.5 for its latest build; an engine that does not speak the library's protocol is reported as such. The engine listens on port 8080, runs as a non-root user, works with a read-only file system and a writable /tmp, and needs no extra capabilities and no --shm-size.

Health:GET /health answers 200 once the engine has done its first conversion (the image's own HEALTHCHECK uses it), and 503 before that or when it cannot convert. /healthz answers the same, for probes written with that name. GET /v1/info (with the token) reports the engine's version, its Chromium version and its limits.

Engine environment variable

Meaning

SELECTPDF_ENGINE_TOKEN

The access tokens the engine accepts, separated by commas - several at once let a token be rotated without downtime. Each must be at least 32 characters, or the engine does not start; generate one with openssl rand -base64 32. Without a token anyone who can reach the port can use the engine (it logs a warning).

SELECTPDF_ENGINE_TOKEN_FILE

A file holding the tokens, one per line - a Docker or Kubernetes secret. Instead of SELECTPDF_ENGINE_TOKEN.

SELECTPDF_MAX_CONCURRENCY

HTML conversions run at once. Default: the number of CPUs the container has.

SELECTPDF_MAX_TOOL_CONCURRENCY

PDF to text / image operations run at once. Default: the number of CPUs.

SELECTPDF_QUEUE_TIMEOUT

Seconds a request waits for a free slot (default 30). After that the engine answers 503 with Retry-After: 1 and closes the connection, so a retry can reach another replica; the library retries such an answer up to three times.

SELECTPDF_MAX_BODY_MB

The largest request accepted, in MB (default 100).

SELECTPDF_ENGINE_HTTP_CACHE

The most of the HTTP cache a conversion may keep: clear, thirdParty or all (the default - each conversion keeps what it asks for through HttpCache). Set clear on an engine shared by different callers.

SELECTPDF_ENGINE_PORT

The port to listen on (default 8080) - change it when the engine runs beside an application that uses 8080 in the same pod.

SELECTPDF_TEMP_DIR

Where conversions work (default /tmp/selectpdf-engine). Each request has a folder of its own, deleted when it ends.

Docker Compose: name the engine service selectpdf-engine and the application needs no endpoint setting at all:

services:
  selectpdf-engine:
    image: ghcr.io/selectpdf/engine:26.5
    read_only: true
    tmpfs:
      - /tmp
    environment:
      SELECTPDF_ENGINE_TOKEN: ${SELECTPDF_ENGINE_TOKEN}
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      SELECTPDF_RENDERING_ENGINE: ChromiumRemote
      SELECTPDF_ENGINE_TOKEN: ${SELECTPDF_ENGINE_TOKEN}
    depends_on:
      selectpdf-engine:
        condition: service_healthy

Kubernetes, Azure, AWS and Google Cloud: see Running the engine in the cloud below.

Running the engine in the cloud

Each route below keeps the engine off the internet and was tested end to end on its platform with the image and an application on aspnet:10.0-alpine. Pick the one where the application already runs. The token rule decides the shape of each: the library sends the token over plain HTTP only to a local endpoint (a name without a dot, localhost, a .svc.cluster.local name or a private address), and over HTTPS anywhere else.

The samples download has a step-by-step sample for Azure Container Apps, Amazon ECS, Google Cloud Run and Kubernetes in cloud/remote-engine, each deploying the application of docker/remote-engine beside the engine.

Kubernetes (Helm)

The chart runs the engine as a Deployment behind a Service, non-root with a read-only root file system, with /health as its startup, readiness and liveness probe and the token in a Secret. Install it as selectpdf-engine and an application in the same namespace needs no endpoint setting:

helm install selectpdf-engine \
  oci://ghcr.io/selectpdf/charts/selectpdf-engine --version 26.5.0 \
  --namespace pdf --create-namespace \
  --set auth.token="$(openssl rand -base64 32)"

From another namespace use http://selectpdf-engine.<namespace>.svc.cluster.local:8080. Other values: replicaCount (2), auth.existingSecret for a Secret you manage, engine.httpCache, engine.maxConcurrency and resources. Several tokens are separated by commas, which Helm's --set splits on: escape them (--set auth.token='a\,b') or put the value in a values file.

Azure Container Apps

Run the engine as an app with internal ingress named selectpdf-engine: apps in the same environment reach it as http://selectpdf-engine, a local name, and nothing outside the environment can.

az containerapp create -n selectpdf-engine -g my-group \
  --environment my-environment \
  --image ghcr.io/selectpdf/engine:26.5 \
  --ingress internal --target-port 8080 --transport http \
  --cpu 2 --memory 4Gi --min-replicas 1 --max-replicas 3 \
  --secrets "engine-token=$TOKEN" \
  --env-vars SELECTPDF_ENGINE_TOKEN=secretref:engine-token

and in the application's app:

--secrets "engine-token=$TOKEN" \
--env-vars SELECTPDF_RENDERING_ENGINE=ChromiumRemote \
  SELECTPDF_ENGINE_ENDPOINT=http://selectpdf-engine \
  SELECTPDF_ENGINE_TOKEN=secretref:engine-token

Keep --min-replicas 1: an engine scaled to zero starts Chromium on the first request after it.

Azure Container Instances

Run the engine as a second container in the application's container group. The containers share localhost, so the engine listens on another port and only the application's port is exposed:

containers:
  - name: selectpdf-engine
    properties:
      image: ghcr.io/selectpdf/engine:26.5
      resources:
        requests: { cpu: 2, memoryInGB: 4 }
      environmentVariables:
        - name: SELECTPDF_ENGINE_PORT
          value: '8081'
        - name: SELECTPDF_ENGINE_TOKEN
          secureValue: '<token>'
  - name: app
    properties:
      image: myregistry.azurecr.io/my-app:1.0
      ports:
        - port: 8080
      resources:
        requests: { cpu: 1, memoryInGB: 1.5 }
      environmentVariables:
        - name: SELECTPDF_RENDERING_ENGINE
          value: ChromiumRemote
        - name: SELECTPDF_ENGINE_ENDPOINT
          value: http://localhost:8081
        - name: SELECTPDF_ENGINE_TOKEN
          secureValue: '<token>'

Deploy the group with az container create --file group.yaml, with ipAddress publishing port 8080 only.

Amazon ECS on Fargate

As its own service, with Service Connect. Run the engine as a service that publishes port 8080 under the client alias selectpdf-engine - the library's default endpoint, so the application needs no endpoint setting - and turn Service Connect on in the application's service too. The engine's task definition:

"containerDefinitions": [{
  "name": "engine",
  "image": "ghcr.io/selectpdf/engine:26.5",
  "readonlyRootFilesystem": true,
  "portMappings": [{ "name": "engine", "containerPort": 8080,
                     "protocol": "tcp", "appProtocol": "http" }],
  "secrets": [{ "name": "SELECTPDF_ENGINE_TOKEN",
                "valueFrom": "<Secrets Manager secret ARN>" }],
  "mountPoints": [{ "sourceVolume": "tmp", "containerPath": "/tmp" }],
  "healthCheck": {
    "command": ["CMD", "dotnet", "/app/SelectPdf.Engine.Host.dll",
                "--healthcheck"],
    "interval": 30, "timeout": 10, "retries": 3, "startPeriod": 60 }
}],
"volumes": [{ "name": "tmp" }],
"cpu": "2048", "memory": "4096"

and its service:

aws ecs create-service --cluster my-cluster \
  --service-name selectpdf-engine --task-definition selectpdf-engine \
  --launch-type FARGATE --desired-count 2 \
  --network-configuration "awsvpcConfiguration={subnets=[...],
    securityGroups=[engine-sg]}" \
  --service-connect-configuration '{"enabled":true,
    "namespace":"my-namespace","services":[{"portName":"engine",
    "discoveryName":"selectpdf-engine","clientAliases":[
    {"port":8080,"dnsName":"selectpdf-engine"}]}]}'

Give the engine's security group port 8080 from the application's security group only. The image declares /tmp as a volume, which is what lets the non-root engine write to the task volume with a read-only root file system. Reading the token from Secrets Manager needs secretsmanager:GetSecretValue on the task execution role.

In the application's task. Add the engine as a second container with SELECTPDF_ENGINE_PORT set to 8081, give the application SELECTPDF_ENGINE_ENDPOINT=http://localhost:8081, and make it start after the engine with "dependsOn": [{"containerName": "engine", "condition": "HEALTHY"}]. Size the task for both (2 vCPU and 4 GB or more).

Google Cloud Run

Run the engine as its own service and give the application its HTTPS URL:

gcloud run deploy selectpdf-engine \
  --image ghcr.io/selectpdf/engine:26.5 \
  --region europe-north1 --port 8080 --cpu 2 --memory 4Gi \
  --concurrency 8 --timeout 600 --execution-environment gen2 \
  --no-invoker-iam-check \
  --set-secrets SELECTPDF_ENGINE_TOKEN=engine-token:latest
SELECTPDF_RENDERING_ENGINE=ChromiumRemote
SELECTPDF_ENGINE_ENDPOINT=https://selectpdf-engine-xxxxxxxxxx-lz.a.run.app
SELECTPDF_ENGINE_TOKEN=<the same token>

--no-invoker-iam-check is required: without it Cloud Run checks the Authorization header of a request coming from another Cloud Run service as a Google identity token, and refuses the engine's token before it reaches the engine. The engine's own token check still applies. Check the engine's health from outside at /health: Cloud Run serves no external path ending in z, so the older /healthz answers 404 there. Cloud Run pulls the public image from ghcr.io directly; Google recommends Artifact Registry, which can mirror it through a remote repository.

Security
  • Use a token and keep the engine off the public internet - on the Docker network, the cluster network or a private subnet of the application.

  • The token is never sent in clear text to another network. With a token set, the library requires an https endpoint unless the endpoint is a local one: a service name without a dot, localhost, a loopback or a private address. The engine itself serves plain HTTP; for an engine on another machine, put TLS in front of it (an ingress, a load balancer or a reverse proxy).

  • A page cannot read the engine's files: local file access is always off in the engine, whatever a conversion asks for.

  • The engine fetches what the pages reference, from its own network. When it converts html you do not control, limit where the engine's network can reach (internal services, cloud metadata addresses).

  • A shared engine: set SELECTPDF_ENGINE_HTTP_CACHE=clear, so no conversion is served a response another caller's conversion fetched. Cookies and storage are cleared after every conversion in any case.

What is different from a local engine
  • Local files: the engine cannot see the application's files, so a file:// url or base url is refused before anything is sent. Convert the html as a string, or serve the files over http. Local files an html string references - images, stylesheets, fonts and media in src, href, srcset or a CSS url() - are sent with it as data: urls, so they are drawn as a local engine draws them (up to 48 MB in one conversion; not when local file access is denied, as locally). Relative urls inside an inlined stylesheet do not resolve.

  • localhost in a url means the engine's own machine, not the application's - except when the engine runs in the application's pod or on its host network. A conversion of a localhost or loopback url that fails says so in its message; use an address the engine can reach, such as the application's service name.

  • Fonts: the engine has fonts for every language (the Noto family) and renders with them, as a Linux application with the SelectPdf.Universal.Fonts package does. Fonts in the application's own fonts folder are sent to the engine with each conversion. See Fonts and Languages.

  • Per-conversion settings that a local in-process engine fixes for the whole process - WebGlEnabled, a proxy, insecure content - are honoured per conversion: the engine runs such a conversion in a process of its own. A proxy must be reachable from the engine.

  • Errors are the same exception types as locally (HtmlToPdfException, PdfToTextException, ...). When the engine itself could not be reached or refused the request, the message says so and names the request id the engine logs its runs under.

  • Logging: with a log file set (LFN), the engine's own log of the conversion is appended to it.

  • Cancellation: cancelling an awaited conversion (ConvertUrlAsync and the other asynchronous methods) cancels it in the engine.

Sizing

One engine converts several pages at once - as many as it has CPUs by default - and queues the rest. Measured with a light document of one or two pages, one engine container limited to N CPUs:

CPUs

One conversion

Conversions per second, fully loaded

1

about 100 ms

about 7

2

about 60 ms

about 10

4

about 50 ms

about 18

8

about 50 ms

about 24

Real pages take longer, mostly in fetching them. Throughput grows little beyond 4 to 8 CPUs per engine; for more, run more engine replicas behind one service. Memory stayed under 500 MB with eight light conversions at once; heavy pages (news sites, long reports) need more - give each engine 2 GB or more.

See Also