DEV Community

Cover image for HTML to PDF in a .NET Docker container, with no apt-get
Florentin Badea
Florentin Badea

Posted on

HTML to PDF in a .NET Docker container, with no apt-get

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 /pdf with 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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

4. Build and run

docker build -t pdfservice .
docker run --rm -p 8080:8080 pdfservice
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.LicenseKey at 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.Universal instead of SelectPdf.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.

If you try it and hit something this article doesn't cover, let me know in the comments.

Top comments (0)