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
Build it step by step.
-
1Install the NuGet packages
SelectPdf.Universalis the library. This guide needs no native engine package, so the same install command works on every platform.dotnet add package SelectPdf.Universal
-
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.cswith the code below.dotnet new console -n PdfSample cd PdfSample
-
3See which platform the app runs on
// linux-musl-x64 on Alpine, where the native engines cannot run Console.WriteLine($".NET runtime: {RuntimeInformation.RuntimeIdentifier}");RuntimeInformation.RuntimeIdentifierprintslinux-musl-x64on the Alpine image. That is the case this guide fixes: the native engines need glibc, so they cannot run there. -
4Send the conversions to the engine
// 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.ChromiumRemotemakes every converter send its work over HTTP to the selectpdf/engine container.EngineEndpointandEngineAccessTokenstart from theSELECTPDF_ENGINE_ENDPOINTandSELECTPDF_ENGINE_TOKENenvironment variables, so the URL and the token stay out of the code.EngineTimeoutcaps how long one request may take. -
5Prepare the HTML
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.
-
6Convert and save
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:
ConvertHtmlStringreturns aPdfDocument,Savewritesreport.pdfandClosereleases it. Building the document, the license check and saving stay in the application. -
7Run it
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.pdfto the working folder. Without a license key, every page carries a trial watermark; setGlobalProperties.LicenseKeybefore the first conversion to remove it.
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.
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.
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.
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.
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.
bin/ obj/ out/
The PDF it produces.
Download report.pdf ↓ the file this sample produced, unedited
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.
- 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
Questions, answered.
Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.