đź’ˇ Full working example available on GitHub: sign-documents-in-docker-fonts-java

The Contract Signing Service That Worked for Nine Months

Container font provisioning is the step that decides whether a Java signing service works in production or only in the tests you happened to write. It matters because the failure is scheduled: a JRE image gives you enough font coverage to look correct, then withholds the rest until a specific document arrives.

Consider the shape of it. A document workflow signs contracts, deployed on eclipse-temurin:17-jre, and it works. Nine months in, the company signs its first customer in Japan, the name goes into the signature text, and the job fails with Specified font file was not found. Nothing changed in the service. The image never had CJK coverage; no document had asked for it.

The technical cause is short. eclipse-temurin:17-jre bundles 8 DejaVu font files for AWT, which covers Latin, Greek and Cyrillic. GroupDocs.Signature does not substitute a missing family, so a request for a Japanese-capable font fails instead of degrading, and leaving the font unset does not help because the library then asks for Times New Roman, which is also absent.

Why This Is Worse Than a Fontless Image

The .NET and Python base images ship zero fonts. That is a better failure: the very first signature fails, in the first test run, and somebody fixes it before the service ships.

A JVM image fails partially, which is the expensive version. The bug lives in code that is already in production, it is triggered by customer data rather than by anything in the deployment, and the person on call sees a font error from a service nobody has touched in months. The incident cost is not the fix - the fix is one Dockerfile layer - it is the hour before anyone believes fonts are involved.

That asymmetry is the argument for treating font coverage as something you assert at startup rather than something you discover.

It also changes who pays. A fontless image costs a developer twenty minutes during setup. A partially covered image costs an on-call engineer an hour at an unhelpful time, plus whatever the delayed contract was worth, plus the review that follows an incident nobody can attribute to a change. The technical difference between the two is four packages in a Dockerfile.

What Provisioning Actually Costs

Four Debian packages in the runtime stage:

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/*

Image size is the usual objection, and it is worth being specific: the CJK package is the large one, the other three are small, and none of them are optional if your documents can carry non-Latin names. Install what your document set actually needs and verify with a read-back rather than trimming on instinct.

fontconfig is the resolver plus fc-list for debugging. fonts-dejavu-core duplicates what the JRE already bundles, which is deliberate: it keeps the image honest if the base image changes. fonts-liberation matters because documents authored on Windows reference Arial and Times New Roman by name and expect metric-compatible rendering. fonts-noto-cjk is the one the incident above needed.

Resolving a Family Instead of Naming One

Provisioning alone is not enough, because the code still has to name a family that exists. The portable way is to ask the library: attempt a throwaway signature per candidate, keep the first that does not throw.

for (String candidate : candidates) {
    if (tryFamily(sourcePath, candidate) == null) {
        return candidate;
    }
}
return null;

The probe itself is an ordinary sign call into the temp directory, with the failure converted into a value rather than an exception:

SignatureFont font = new SignatureFont();
font.setFamilyName(familyName);
font.setSize(10);
options.setFont(font);
signature.sign(scratch.getAbsolutePath(), options);
return null;

Filename detection is the shortcut that looks equivalent and is not. Debian’s fonts-noto-cjk installs NotoSansCJK-Regular.ttc, whose family name is Noto Sans CJK JP, so matching filenames both misses fonts and reports families that will not resolve.

Degrading Honestly

With resolution in place, the two failure classes separate cleanly. No Latin family means the image cannot sign at all, which should stop the container. No CJK family means one signature is skipped and the run continues with a warning:

List<SignOptions> options = new ArrayList<>();
options.add(buildTextOptions(LATIN_TEXT, latinFamily, 50));

if (cjkFamily != null) {
    options.add(buildTextOptions(CJK_TEXT, cjkFamily, 120));
}

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

The distinction matters operationally. A container that exits at startup with “no usable font family” is a deploy problem, caught by whoever deployed it. A signature silently missing from a delivered document is a compliance problem, caught by the recipient. Wiring the fatal case to a non-zero exit keeps failures in the first category.

Then read the result back, because a CJK signature written without CJK coverage can render as empty boxes without raising anything:

TextSearchOptions options = new TextSearchOptions();
options.setAllPages(true);
List<TextSignature> found = signature.search(TextSignature.class, options);

Where does this leave a team that already shipped?

Add the font layer, add resolution at startup, and log both results on the first line of the service, where the next engineer will actually see them. The change is a Dockerfile edit plus roughly thirty lines, and it converts a customer-triggered incident into a container that either starts with known coverage or refuses to start. Existing signed documents are unaffected; only new ones gain the CJK path.

Checking an Image You Already Run

Before changing anything, it is worth knowing what your current image has. Two commands answer it from the outside:

docker run --rm your-image sh -c "ls -R /usr/share/fonts | head"
docker run --rm your-image sh -c "fc-list : family | sort -u | head -20"

The first lists font files, the second lists the family names a resolver would return, and the gap between them is the reason filename matching fails. If fc-list is missing, that is its own answer: fontconfig is not installed, and any family lookup is running blind.

Inside the service, the equivalent check belongs in the startup log next to the resolved families. A line reading fonts on disk: 8, latin: DejaVu Sans, cjk: (none) tells the next person exactly what this container can and cannot sign, which is more useful than any exception they would otherwise read at three in the morning.

The JVM Detail Nobody Expects

One more thing that bites specifically on Java, and it is not about fonts. The GroupDocs Maven artifact is a signed fat jar. Repackaging it into a shaded jar produces NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions, and the usual remedy of deleting META-INF/*.SF|RSA|DSA is insufficient: MANIFEST.MF carries around 19 MB of per-entry digests and must also be truncated to its main section. The sample avoids the problem by running against a plain classpath with a dependency/ directory rather than shading anything.

I mention it because both of these - the partial font coverage and the signed jar - share a shape: the JVM path fails in a way that looks like your code and is not. Both are also cheap to defend against once named: pin the classpath layout you know works, and assert font coverage at startup instead of trusting the base image. Neither costs a redesign, and both remove a class of incident that is otherwise indistinguishable from an application bug.

Conclusion

A Java signing service in a container is one Dockerfile layer and one startup check away from predictable. Install fontconfig, DejaVu, Liberation and Noto CJK; resolve the family by probing rather than assuming; skip what cannot be embedded; verify by reading back. The sample repository ships both images, so the difference between coverage and no coverage takes two builds to see rather than one incident to learn.

Additional Resources