§HOW-TO GUIDE · C# · .NET 8 & 10

Fix HTML to PDF on Alpine Linux in C# with the remote engine

The native engines need a glibc-based Linux, so on an Alpine (musl) image a conversion stops with an exception. With SelectPdf.Universal 26.5 an Alpine application converts through the remote engine: the selectpdf/engine container renders the page, and the application references only the managed SelectPdf.Universal package.

Compiled and run: macOS ARM64 · Windows x64

Video walkthrough · 4:04 · Watch on YouTube · Short · 1:08
§STEP BY STEP

Build it step by step.

  1. 1Install the NuGet packages

    SelectPdf.Universal is the library. This guide needs no native engine package, so the same install command works on every platform.

    terminal
    dotnet add package SelectPdf.Universal
  2. 2Create a console app

    Any .NET 8 or .NET 10 project works. To follow along, create a console app, run the install command above inside its folder, then replace Program.cs with the code below.

    terminal
    dotnet new console -n PdfSample
    cd PdfSample
  3. 3See which platform the app runs on

    Program.cs · lines 4–5
    // linux-musl-x64 on Alpine, where the native engines cannot run
    Console.WriteLine($".NET runtime: {RuntimeInformation.RuntimeIdentifier}");

    RuntimeInformation.RuntimeIdentifier prints linux-musl-x64 on the Alpine image. That is the case this guide fixes: the native engines need glibc, so they cannot run there.

  4. 4Send the conversions to the engine

    Program.cs · lines 7–10
    // convert in the selectpdf/engine container; EngineEndpoint and EngineAccessToken
    // start from the SELECTPDF_ENGINE_ENDPOINT and SELECTPDF_ENGINE_TOKEN variables
    GlobalProperties.RenderingEngine = RenderingEngine.ChromiumRemote;
    GlobalProperties.EngineTimeout = TimeSpan.FromSeconds(60);

    GlobalProperties.RenderingEngine = RenderingEngine.ChromiumRemote makes every converter send its work over HTTP to the selectpdf/engine container. EngineEndpoint and EngineAccessToken start from the SELECTPDF_ENGINE_ENDPOINT and SELECTPDF_ENGINE_TOKEN environment variables, so the URL and the token stay out of the code. EngineTimeout caps how long one request may take.

  5. 5Prepare the HTML

    Program.cs · lines 12–19
    string html = """
        <html>
          <body style="font-family: sans-serif">
            <h1>Shipping report</h1>
            <p>Converted to PDF by SelectPdf.Universal.</p>
          </body>
        </html>
        """;

    A C# raw string literal holds the page. Local images, stylesheets and fonts that an HTML string references are sent to the engine with it.

  6. 6Convert and save

    Program.cs · lines 21–25
    HtmlToPdf converter = new HtmlToPdf();
    PdfDocument doc = converter.ConvertHtmlString(html);
    doc.Save("report.pdf");
    doc.Close();
    Console.WriteLine("report.pdf saved");

    The conversion code is the same as with an engine on the same machine: ConvertHtmlString returns a PdfDocument, Save writes report.pdf and Close releases it. Building the document, the license check and saving stay in the application.

  7. 7Run it

    terminal
    export SELECTPDF_ENGINE_TOKEN=$(openssl rand -base64 32)
    docker compose run --rm app        # writes report.pdf to ./out
    docker compose down

    The program writes report.pdf to the working folder. Without a license key, every page carries a trial watermark; set GlobalProperties.LicenseKey before the first conversion to remove it.

§COMPLETE PROGRAM

The complete Program.cs.

This is the exact file our samples pipeline compiled and ran on macOS ARM64 and Windows x64 to produce the output on this page.

Program.cs
using System.Runtime.InteropServices;
using SelectPdf.Universal;

// linux-musl-x64 on Alpine, where the native engines cannot run
Console.WriteLine($".NET runtime: {RuntimeInformation.RuntimeIdentifier}");

// convert in the selectpdf/engine container; EngineEndpoint and EngineAccessToken
// start from the SELECTPDF_ENGINE_ENDPOINT and SELECTPDF_ENGINE_TOKEN variables
GlobalProperties.RenderingEngine = RenderingEngine.ChromiumRemote;
GlobalProperties.EngineTimeout = TimeSpan.FromSeconds(60);

string html = """
    <html>
      <body style="font-family: sans-serif">
        <h1>Shipping report</h1>
        <p>Converted to PDF by SelectPdf.Universal.</p>
      </body>
    </html>
    """;

HtmlToPdf converter = new HtmlToPdf();
PdfDocument doc = converter.ConvertHtmlString(html);
doc.Save("report.pdf");
doc.Close();
Console.WriteLine("report.pdf saved");

Open the project on GitHub → this program and the variations below, ready to dotnet run

§SUPPORTING FILES

Supporting files.

Dockerfile

The SDK image publishes the app and the Alpine runtime image runs it. Nothing else goes into the image: the application carries no engine and no fonts, because the engine container has them.

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /out

# Alpine: the app carries no engine, the selectpdf/engine container converts
FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine
WORKDIR /app
COPY --from=build /out ./
ENTRYPOINT ["dotnet", "/app/PdfSample.dll"]

compose.yaml

The engine and the app as two services on one Docker network. Named selectpdf-engine, the engine is at the library's default endpoint, so the app needs only the token. The app starts once the engine reports healthy.

compose.yaml
services:
  # the service name is the library's default endpoint: http://selectpdf-engine:8080
  selectpdf-engine:
    image: ghcr.io/selectpdf/engine:26.5
    read_only: true
    tmpfs:
      - /tmp
    environment:
      SELECTPDF_ENGINE_TOKEN: ${SELECTPDF_ENGINE_TOKEN}

  app:
    build: .
    volumes:
      - ./out:/out
    working_dir: /out
    environment:
      SELECTPDF_ENGINE_TOKEN: ${SELECTPDF_ENGINE_TOKEN}
    depends_on:
      selectpdf-engine:
        condition: service_healthy

.dockerignore

Keeps the host's build output out of the image's build context, so the publish inside the container starts clean.

.dockerignore
bin/
obj/
out/
§OUTPUT

The PDF it produces.

First page of report.pdf, the PDF this sample produces
report.pdf, page 1

Download report.pdf ↓ the file this sample produced, unedited

§TROUBLESHOOTING

Common problems and fixes.

"The native engines do not run on Alpine Linux or another musl-based system"

This HtmlToPdfException is what a conversion on Alpine throws when the engine is expected on the same machine. Run the selectpdf/engine container and set GlobalProperties.RenderingEngine to RenderingEngine.ChromiumRemote, or the SELECTPDF_RENDERING_ENGINE environment variable to ChromiumRemote.

The engine cannot be reached

The default endpoint, http://selectpdf-engine:8080, resolves only on the engine's Docker network or cluster. Anywhere else, set SELECTPDF_ENGINE_ENDPOINT or EngineEndpoint to the engine's URL. When the engine could not be reached or refused the request, the message says so and names the request id the engine logs.

Conversions fail before anything is sent

With a token set, the library sends it over plain HTTP only to a local endpoint: a service name without a dot, localhost, a loopback or a private address. For an engine on another machine, use an https URL and put TLS in front of the engine.

A file:// URL or a localhost page fails

The engine cannot see the application's files, so a file:// URL or base URL is refused: convert the HTML as a string, or serve the files over HTTP. In a URL, localhost means the engine's machine, so use an address the engine can reach, such as the application's service name.

§GOOD TO KNOW

Good to know.

  • Alpine (musl) images cannot run the native engines; an application on Alpine converts through the remote engine container instead. Source
  • An application that runs the engines itself needs a glibc-based image, such as the default Debian or Ubuntu based mcr.microsoft.com/dotnet tags; the engines require glibc 2.30 or newer. Source
  • With the remote engine the application references only the managed SelectPdf.Universal package: no native package and no fonts package. Source
  • The engine ships as the container image ghcr.io/selectpdf/engine for x64 and arm64. It listens on port 8080, runs as a non-root user, works with a read-only file system and a writable /tmp, and needs no license of its own. Source
  • EngineEndpoint defaults to http://selectpdf-engine:8080, an engine service named selectpdf-engine on the same Docker network or cluster. Source
?FAQ

Questions, answered.

Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.

The native engines need a glibc-based distribution with glibc 2.30 or newer, and Alpine images are musl based. An application on Alpine converts through the remote engine container instead.
No. The settings can come from environment variables with no code at all: SELECTPDF_RENDERING_ENGINE=ChromiumRemote, SELECTPDF_ENGINE_ENDPOINT and SELECTPDF_ENGINE_TOKEN. The conversion code stays the same.
No. The license check stays in the application, together with building the document, headers and footers, security and signatures.
Yes, on a glibc-based image. Use the default Debian or Ubuntu based mcr.microsoft.com/dotnet tags with the native package for the platform, as in the Docker guide; the application then runs the engines itself.