If your Java service draws text anywhere (a chart in a PDF report, a label on a generated thumbnail, a watermark on an uploaded image), it depends on whatever fonts are installed on the machine it runs on. Most developers never think of fonts as a dependency. Their laptop has a few hundred of them. The production container has a handful, or none, and nobody wrote that down.
I’ve spent a lot of time on Java code that puts text into images and documents, and font problems are some of the most annoying bugs I know of. They rarely crash anything important. They just make output slightly wrong in a way that only shows up on one machine.
The container with no fonts at all
Slim base images leave out fontconfig and freetype because almost nothing on a server needs them. AWT does, even in headless mode. Setting -Djava.awt.headless=true only tells the JVM there’s no display. When you call drawString on a Graphics2D from a BufferedImage, the JDK still has to find a font file and rasterize glyphs, and on Linux it asks fontconfig where the fonts are.
If the native libraries are missing, you’ll typically get something like:
java.lang.UnsatisfiedLinkError: .../libfontmanager.so: libfreetype.so.6: cannot open shared object file If the libraries are there but there are no fonts, recent JDKs tend to fail with a NullPointerException from inside sun.awt.FontConfiguration. Neither message tells you to install a package. And because font initialization happens on first use, the service starts cleanly and fails on the first request that renders text, which might be hours after deploy.
The fix is to install them explicitly, if your base image doesn’t already:
FROM eclipse-temurin:17-jre RUN apt-get update \ && apt-get install -y --no-install-recommends fontconfig fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* Some JDK images include fontconfig and some don’t, and package names differ on Alpine, so check what your image actually contains rather than trusting this snippet.
The font you asked for isn’t the font you got
This one is worse because nothing fails.
Font f = new Font("Arial", Font.PLAIN, 12); System.out.println(f.getFamily()); // "Dialog" if Arial isn't installed The Font constructor doesn’t throw when the family doesn’t exist. It falls back to Dialog and carries on. Java’s five logical fonts (Dialog, DialogInput, Monospaced, SansSerif and Serif) are mapped to different physical fonts on every platform, so “SansSerif” on your Mac and “SansSerif” on a Debian server are different typefaces with different metrics.
So text that fit inside a box on your machine overflows it in production. Line breaks move. Golden-image tests pass locally and fail in CI, and I’ve watched teams respond by loosening the pixel tolerance until the test stopped meaning anything.
If you need to know what’s available, ask:
String[] families = GraphicsEnvironment .getLocalGraphicsEnvironment() .getAvailableFontFamilyNames(); Logging that list once at startup has saved me more time than I’d like to admit.
Bundle the font you actually mean
If the output has to look the same everywhere, stop relying on the host and ship the font with your application:
try (InputStream in = Renderer.class.getResourceAsStream("/fonts/DejaVuSans.ttf")) { Font base = Font.createFont(Font.TRUETYPE_FONT, in); GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(base); Font body = base.deriveFont(14f); // use body for rendering } createFont throws FontFormatException and IOException, and both are worth handling loudly rather than falling back to Dialog without telling anyone.
Bundling a font is redistributing it, so the license matters. DejaVu, Liberation and Noto all allow this. Copying Arial out of C:\Windows\Fonts into src/main/resources almost certainly doesn’t, and I’d rather someone in legal heard about that from you than from an audit.
The characters your font doesn’t have
DejaVu covers Latin, Greek and Cyrillic well. It doesn’t cover Chinese, Japanese or Korean, and it won’t help with most emoji. If a user’s name is in a script your font lacks, you get empty rectangles where the text should be.
Logical fonts are composite fonts, so they can fall back across several physical fonts per character. A font you load with createFont is a single font with no fallback. You can check before rendering:
int firstMissing = body.canDisplayUpTo(text); if (firstMissing != -1) { // body can't render text from index firstMissing onward } What you do then depends on your product. Some teams switch to a Noto CJK font for that string, some reject the input, and some just accept the boxes for a while because it’s rare. I don’t think there’s one right answer, but there’s definitely a wrong one, which is finding out from a customer screenshot.
Before it ships
The questions I ask now are short. Which fonts does the production image contain, and does the build install them on purpose? Is any code requesting a font family by name that only exists on developer machines? Do the rendering tests run in the same image as production? What happens when the input contains a script nobody tested?
None of these take long to answer. They tend to get answered after the first bug report, though, usually by whoever happens to be on call.

