💡 Full working example available on GitHub: sign-pdf-in-linux-container-fonts-dotnet

The Old Way Was Painful

The service signs invoices. It runs on a laptop with three hundred fonts installed, passes review, and gets containerised on a Friday. On Monday the first job in the cluster exits non-zero with Sign document error: Font Arial was not found, and somebody spends the morning reading stack traces before anyone thinks to ask what fonts a mcr.microsoft.com/dotnet/runtime:8.0 image actually contains.

The answer is none. Zero font files, measured on the image this article’s sample runs in.

It is worth knowing how the other runtimes compare, because the failure looks different on each. eclipse-temurin:17-jre bundles 8 DejaVu files and node:18-bookworm bundles 6, both for AWT, which is why JVM and Node images sign Latin text quietly and only fall over when a Japanese or Chinese string arrives. python:3.11-slim ships zero, like the .NET runtime image, so it fails on the first signature instead. Nobody gets CJK for free on any of them.

Container font provisioning is the step that makes text signing work in a Linux image with GroupDocs.Signature for .NET. It matters because the library does not substitute a missing family: naming a font that is not installed raises an error and writes no document. This article puts the fontless image next to the fixed one, shows what changed, and covers the run-time resolution that keeps the same code working on a developer machine.

There Is a Better Way

Two things have to be true. The image needs at least one font, and the code needs to stop assuming which one.

The first is a Dockerfile layer. The second is a resolution step: instead of hard-coding Arial, ask the library which of several candidate families it can actually use, and keep the first that works. The result runs unchanged in a slim container, on Windows, and in CI, because it never asserts anything about the environment it has not checked.

One thing that does not work, and it is worth stating plainly because it is the first thing people try: leaving the font unset. Without SignatureFont, GroupDocs.Signature asks for its own default, Times New Roman, which the fontless image also lacks. The call fails identically.

The New Way: Two Images, One Difference

Step 1 - Look at what the image has

Before signing anything, list the font files. The count turns a vague exception into a diagnosis, because zero fonts and a wrong family name need different fixes:

string[] roots =
{
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    Path.Combine(home, ".fonts"),
    Path.Combine(home, ".local/share/fonts"),
    Environment.GetFolderPath(Environment.SpecialFolder.Fonts),
    "/System/Library/Fonts",
    "/Library/Fonts",
};

Note what is absent: System.Drawing. System.Drawing.Common is Windows-only from .NET 7 onward and throws on Linux, so font code built on it fails in the container for a second, unrelated reason.

Step 2 - Add the font layer

Four packages, one RUN, and the failure disappears:

RUN apt-get update && apt-get install -y --no-install-recommends \
        fontconfig \
        fonts-dejavu-core \
        fonts-liberation \
        fonts-noto-cjk \
    && fc-cache -f \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

fontconfig is the resolver and gives you fc-list for debugging. fonts-dejavu-core is the Latin, Greek and Cyrillic minimum. fonts-liberation supplies metric-compatible stand-ins for Arial, Times New Roman and Courier New, which is what documents authored on Windows actually reference. fonts-noto-cjk covers Chinese, Japanese and Korean.

Step 3 - Resolve a family instead of naming one

The portable way to pick a font is to attempt a throwaway signature per candidate and keep the first that does not throw:

foreach (string candidate in candidates)
{
    if (TryFamily(sourcePath, candidate).Ok)
    {
        return candidate;
    }
}

return null;

Filename detection is the tempting shortcut and it is wrong. Debian’s fonts-noto-cjk installs NotoSansCJK-Regular.ttc, whose family name is Noto Sans CJK JP. A filename match misses fonts that are present and claims families that will not resolve when passed to SignatureFont.

Step 4 - Sign what resolved, verify what you signed

A resolved Latin family is required; a resolved CJK family is optional and its absence is a skip, not a crash:

var options = new List<SignOptions>
{
    BuildTextOptions(LatinText, latinFamily, top: 50),
};

if (cjkFamily is not null)
{
    options.Add(BuildTextOptions(CjkText, cjkFamily, top: 120));
}

SignResult result = signature.Sign(outputPath, options);

Then read the file back, because CJK without a CJK font can render as empty boxes without raising anything at all:

var options = new TextSearchOptions { AllPages = true };
List<TextSignature> found = signature.Search<TextSignature>(options);

Side-by-Side: Before vs. After

Dockerfile.nofonts Dockerfile
Font files in the image 0 DejaVu, Liberation, Noto CJK
Latin text signature fails, exit 3 written and recovered on read-back
CJK text signature fails written and recovered
Error surfaced Font <name> was not found none
Code difference none - same binary none - same binary

The last row is the point. Nothing in the application changed between the two runs. The sample repository ships both files so the comparison takes two docker build commands rather than trust. Keep the fontless variant in the repository afterwards, too: it is the fastest way to reproduce the failure when somebody switches base images six months from now and the signatures quietly stop appearing.

Why not just install every font?

Because image size is a real constraint and the four packages above already cover the scripts most documents use. fonts-dejavu-core alone is enough for Latin, Greek and Cyrillic signing; Liberation matters when documents reference the Windows families by name; Noto CJK is the one that is genuinely large and only pays for itself if you sign East Asian text. Install what your documents need, then verify with a read-back.

Real-World Example: The Batch Signing Worker

A queue worker signs a few thousand PDFs a night. With resolution at startup, it logs one line naming the families it will use, and if nothing resolves it exits before touching the queue rather than failing per message. That startup check is what turns a font problem from a stream of failed jobs into a container that refuses to start with a one-line reason.

The probing cost is small enough to ignore at startup and too large to repeat per document. Each probe is a real signature written to a temporary file, so the Latin list costs up to four of them and the CJK list up to eight, all against a one-page PDF. Resolve once, cache the two family names, and the per-document path is exactly what it was before: build the options, call Sign, read the result count.

I lost an afternoon to the version of this that guessed. It scanned the font directory, found NotoSansCJK-Regular.ttc, reported CJK as available, and then failed on every family name I derived from that filename. Probing with a real signature was both simpler and correct.

What Else Bites in a Container?

One more, and it is unrelated to fonts: InvariantGlobalization=true. It is standard advice for trimming ICU out of a .NET image, and with GroupDocs.Signature it makes the very first new Signature(...) throw CultureNotFoundException: ... en-US is an invalid culture identifier, because SignatureSettings builds a CultureInfo("en-US"). Keep globalization enabled and let ICU stay in the image. The system requirements page is the place to check platform support before committing to a base image.

Conclusion

A signing service that works locally and fails in Docker is almost always missing fonts, and the fix is a four-package layer plus code that resolves a family rather than assuming one. Build both images from the sample, run them side by side, and read the [fonts] lines: the whole argument fits in that one comparison.

Additional Resources