đź’ˇ Full working example available on GitHub: python-linux-container-pdf-signing
Introduction
The script works locally. You containerise it on python:3.11-slim, and it fails at import groupdocs.signature. You fix that, and it fails again at the first signature. Neither error mentions what is actually missing.
Container signing with Python is a GroupDocs.Signature workflow that needs two provisioning layers rather than one: the .NET runtime libraries the binding is built on, and the fonts every text signature has to render with. This tutorial builds both, then the script that resolves a font family at run time instead of hard-coding one, so the same code works in the container and on the machine you wrote it on.
Why Both Layers Matter
GroupDocs.Signature for Python is a .NET binding, so libicu and an OpenSSL 1.1 compatible library have to exist before any import succeeds. That is layer one, and it is well documented in Running in Docker.
The reason the two layers get conflated is that both fail at import-adjacent moments and neither error names its cause. A missing libssl1.1 gives you a loader error about a shared object; a missing font gives you a signing error wrapped in a proxy exception. Neither says “your base image is too small”, which is what both actually mean.
Layer two is fonts, and it is the one that surprises people. python:3.11-slim contains zero font files. GroupDocs.Signature does not substitute a missing family - naming one that is not installed raises, and nothing is written - and clearing the font is not a workaround either, because the library then asks for its own default and fails identically. On a fontless image, a text signature is simply impossible.
Prerequisites
Python 3.11 (the wheel caps below CPython 3.14) and groupdocs-signature-net==26.1. Docker if you want to see both failures on purpose, which is worth ten minutes.
Installation
pip install groupdocs-signature-net==26.1
Step 1 - Build the .NET layer
libssl1.1 is not in bookworm, so it comes from a pinned Debian snapshot:
ENV SNAPSHOT_DATE=20220328T000000Z
RUN echo "deb [trusted=yes] http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} bullseye main" \
> /etc/apt/sources.list.d/debian-archive.list \
&& apt-get -o Acquire::Check-Valid-Until=false update \
&& apt-get install -y --no-install-recommends \
libicu67 \
libssl1.1 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
Key points:
- This layer only makes the import work; it says nothing about fonts.
- Pinning the snapshot date keeps the build reproducible when the archive moves on.
Step 2 - Build the font layer
Four packages, kept as their own layer so it can be commented out to reproduce the failure:
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. fonts-dejavu-core is the Latin, Greek and Cyrillic minimum. fonts-liberation covers documents that reference Arial or Times New Roman by name. fonts-noto-cjk covers Chinese, Japanese and Korean.
Step 3 - Ask the library which family it can use
Scanning /usr/share/fonts for a filename looks equivalent and is not: fonts-noto-cjk installs NotoSansCJK-Regular.ttc, whose family name is Noto Sans CJK JP. The portable answer is a probe - a real signature into a temp file - with the failure converted into a value:
with signature.Signature(source_path) as sign:
options = TextSignOptions()
options.text = "probe"
options.left = 10
options.top = 10
options.width = 60
options.height = 20
font = SignatureFont()
font.family_name = family_name
font.size = 10.0
options.font = font
sign.sign(scratch, [options])
return None
Look closely at font.size = 10.0. The binding maps size to a .NET float and rejects an int with numeric argument expected, got 'int'. Because that happens inside the probe, every candidate family fails and the output looks exactly like a fontless image. I added three font packages to an image that already had all of them before spotting the literal.
Resolution is then a loop:
for candidate in candidates:
if try_family(source_path, candidate) is None:
return candidate
return None
Step 4 - Sign what resolved, verify what you signed
The Latin family is required, the CJK one optional:
with signature.Signature(source_path) as sign:
options = [build_text_options(LATIN_TEXT, latin_family, 50)]
if cjk_family:
options.append(build_text_options(CJK_TEXT, cjk_family, 120))
result = sign.sign(output_path, options)
return len(result.succeeded)
Then verify, because CJK rendered as empty boxes raises nothing:
options = TextVerifyOptions()
options.text = expected_text
options.match_type = gsd.TextMatchType.CONTAINS
options.all_pages = True
result = sign.verify(options)
CONTAINS is deliberate: in evaluation mode the library adds trial text to the page, and an exact match would report a perfectly good document as failed.
What about the docs saying Python has limited Linux support?
The Running in Docker page lists Linux-ready Python packages and leaves Signature out. On groupdocs-signature-net==26.1 this sample signed and verified inside python:3.11-slim, CJK included, with both layers installed. Treat the list as stale rather than as a blocker, and confirm with your own version before committing to a deployment.
Real-World Applications
An invoicing service that stamps an approval line onto generated PDFs needs exactly this: the .NET layer, one Latin font, and a startup resolution check. The check is what turns a bad deployment into a container that refuses to start, rather than a queue of invoices that silently fail one at a time. A document portal that accepts customer names in any script needs the CJK package as well, plus the verification step, because that is the only thing standing between a rendered box and a signed name.
Where the resolution check belongs
Put it in whatever runs once per process: a module-level call, a FastAPI lifespan handler, a Django AppConfig.ready, or the
first lines of a worker’s main. Two values come out of it, the Latin family and the CJK family, and both belong in the startup
log next to the font count.
That placement does more than save probe time. It moves the failure from request handling, where it is one customer’s problem and a stack trace nobody reads, to startup, where it is a deployment that did not come up and somebody is already watching. A container that exits with “no usable font family, install fonts-dejavu-core” needs no debugging at all.
Troubleshooting Common Issues
import groupdocs.signature fails
The .NET layer is missing or the snapshot repository was unreachable during build. This is layer one, and it has nothing to do with fonts. Check the build log for the apt step before touching any signing code, because a failed snapshot fetch does not stop the image from building.
Every candidate font fails, but fc-list shows fonts
Check font.size for an int before adding more packages.
The signature is there but the CJK text is boxes
fonts-noto-cjk is missing. The signature was written with a family that has no glyphs for those code points, which is why the verification step exists: it fails on exactly this case, where signing reported success.
What the two images actually print
Run both and read the first four lines. The fontless image reports font files on disk: 0, both resolution lines as (none),
the deliberate missing-font error, and then exits 3 with the minimum fix printed. The provisioned image reports a non-zero
font count, DejaVu Sans for Latin and Noto Sans CJK JP for CJK, two signatures applied, and both texts verified.
That pair of outputs is the artifact worth keeping. Paste it into your deployment notes and the next person who changes the base image has a reference for what a healthy container looks like, without needing to understand fontconfig at all.
Conclusion
Two layers and one probe. Install the .NET dependencies, install at least fontconfig and DejaVu, resolve the family by asking rather than assuming, and verify the output before calling the job done. None of it is much code, and all of it is the kind of thing that is obvious in hindsight and invisible in a traceback. The sample repository ships both Dockerfiles, so the difference between a working image and a broken one is one build apart.