Disclosure: I work on SelectPdf, the library used in this article. It's a commercial library with a free Community Edition; both are covered at the end.
If you've ever generated PDFs from HTML in a Linux container, you probably know the drill: a headless browser needs a long list of shared libraries, so the Dockerfile grows an apt-get install line with twenty-odd packages, then fonts, then sometimes --no-sandbox or a bigger --shm-size. Every base-image update is a chance for that list to break.
This article builds a small ASP.NET Core service that converts web pages and HTML to PDF in a stock .NET runtime container, with none of that. It uses SelectPdf.Universal, which renders with Chromium and ships the engine, with everything it needs, inside a NuGet package.
What we're building
A minimal API with two endpoints:
-
GET /pdf?url=…returns any web page as a PDF -
POST /pdfwith an HTML body returns that HTML as a PDF
1. The project
Create a web project and add two packages: the library, and the native engine for the platform the container runs on.
dotnet new web -n PdfService
cd PdfService
dotnet add package SelectPdf.Universal
dotnet add package SelectPdf.Universal.Native.linux-x64
The native package is the part that makes the container story simple: it carries the complete Chromium shared-library closure, including the modules Chromium loads at run time for HTTPS. dotnet publish copies it into the output, so the image needs nothing else.
2. The code
Replace Program.cs:
using SelectPdf.Universal;
var app = WebApplication.CreateBuilder(args).Build();
// GET /pdf?url=https://example.com -> the page as a PDF download
app.MapGet("/pdf", async (string url, CancellationToken ct) =>
{
var converter = new HtmlToPdf();
PdfDocument doc = await converter.ConvertUrlAsync(url, ct);
byte[] pdf = doc.Save();
doc.Close();
return Results.File(pdf, "application/pdf", "page.pdf");
});
// POST /pdf with an HTML body -> that HTML as a PDF
app.MapPost("/pdf", async (HttpRequest request, CancellationToken ct) =>
{
string html = await new StreamReader(request.Body).ReadToEndAsync(ct);
var converter = new HtmlToPdf();
PdfDocument doc = await converter.ConvertHtmlStringAsync(html, ct);
byte[] pdf = doc.Save();
doc.Close();
return Results.File(pdf, "application/pdf", "document.pdf");
});
app.Run();
The async methods take a CancellationToken, so a client that disconnects cancels its conversion. doc.Save() with no arguments returns the PDF as a byte array; doc.Save("file.pdf") writes it to disk instead.
3. The Dockerfile
A standard multi-stage build. Note what's not in it:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish PdfService.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "PdfService.dll"]
No apt-get, no fonts step, no Xvfb. The engine renders headless, so there's no display server. It also runs as the image's built-in non-root app user: when the app is published on Linux, as it is inside this build, the native package makes the engine files readable and executable for every user.
Add a .dockerignore so local build output stays out of the image:
bin/
obj/
4. Build and run
docker build -t pdfservice .
docker run --rm -p 8080:8080 pdfservice
A plain docker run: no --no-sandbox, no added capabilities, no privileged mode, no GPU and no --shm-size. Docker's default 64 MB of shared memory is enough.
Try it:
curl -o page.pdf "http://localhost:8080/pdf?url=https://example.com"
curl -X POST -H "Content-Type: text/html" \
--data-binary "<h1>Hello from Docker</h1>" \
-o hello.pdf http://localhost:8080/pdf
5. Fonts for every language
Base images ship with no fonts at all. The library carries the Liberation fonts, so Latin, Greek and Cyrillic text renders correctly out of the box. For anything else (Chinese, Japanese, Korean, Arabic, Hebrew, Hindi, Thai and more, plus color emoji), add one package:
dotnet add package SelectPdf.Universal.Fonts
It copies the Noto fonts into your publish output at build time, so nothing gets installed in the image. With it referenced, this renders correctly in the same container:
curl -X POST -H "Content-Type: text/html; charset=utf-8" \
--data-binary "<p>中文 日本語 العربية 😀</p>" \
-o languages.pdf http://localhost:8080/pdf
6. ARM64, and building on a Mac
For ARM64 containers (AWS Graviton, Ampere, or Docker on an Apple Silicon Mac), reference SelectPdf.Universal.Native.linux-arm64 instead of linux-x64. Nothing else changes: the mcr.microsoft.com/dotnet tags are multi-architecture, so the same FROM lines pick the right image.
One thing that catches people on a Mac: Docker Desktop runs Linux containers, so the image needs linux-arm64, not osx-arm64. The macOS package is for apps running natively on macOS.
What works, and what doesn't
Base images. Any glibc-based .NET image. The engines need glibc 2.30 or newer, which covers Ubuntu 20.04+, Debian 11+ and RHEL 9+. Verified end to end, with a real HTTPS page and no packages installed: Debian 12, Ubuntu 22.04, Ubuntu 24.04, and Amazon Linux 2023 through a self-contained publish.
Not supported: Alpine. The native engines need glibc, and Alpine uses musl. Stay on the default Debian or Ubuntu based tags.
Resources. Chromium-based rendering likes CPU and memory. Plan for at least 1 core and 2 GB of RAM per container, and 2+ cores if you convert a lot under load.
Windows containers work too, on Windows Server Core, but they need a step to install the core Windows fonts. If Windows isn't a requirement, Linux containers are smaller and simpler.
Licensing
-
SelectPdf.Universal is commercial. Without a license the output carries a trial watermark; with one, set
GlobalProperties.LicenseKeyat startup. Licenses are perpetual and per developer, from $499, and one license covers both SelectPdf.Universal and the classic Windows library. -
SelectPdf.HtmlToPdf.Universal is the free Community Edition: HTML to PDF only, free for commercial use, no watermark, up to 5 pages per document. The code in this article runs unchanged with it: reference
SelectPdf.HtmlToPdf.Universalinstead ofSelectPdf.Universal, keep the same native package, and longer documents stop at five pages.
Wrapping up
The whole container story comes down to two dotnet add package lines and an ordinary Dockerfile. The documentation has more: a Docker deployment guide with Windows containers and batch jobs, and step-by-step pages for Azure Container Apps, AWS Lambda and Google Cloud Run.
- Cross-platform library: https://selectpdf.com/pdf-library-cross-platform/
- Launch post (what's new in 26.4): https://selectpdf.com/selectpdf-universal-our-new-flagship-net-pdf-library-on-linux-macos-and-docker/
If you try it and hit something this article doesn't cover, let me know in the comments.
Top comments (0)