Frame Workflow Lab

Remotion Missing Font in Docker/Linux: Find the Fallback with fc-match and Make It Fail the Render

Published November 11, 2026 · 10 min read

Key takeaway

A Remotion Docker image built from the apt list in Remotion's Docker docs ends up with DejaVu as its only fonts. Any system font you ask for that isn't installed renders as DejaVu Sans, and the render still exits 0. Bundle your font and load it with FontFace plus delayRender, and install fonts-noto-color-emoji if you render emoji. To catch a silent fallback before it ships, call measureText({validateFontIsLoaded: true}) from @remotion/layout-utils and pass any error to cancelRender. That makes the render exit with code 1.

Who this is for

This is for you if your Remotion frames look right on your Mac but show a different font, or white boxes where emoji should be, when you render in Docker or on a Linux server. It assumes you already render with npx remotion still or render inside a container.

The problem

The test composition (naive) asks for "Helvetica Neue", sans-serif and draws three rows: regular text, bold text, and an emoji line. It was rendered as a still (frame 0) on an Apple M4 Mac and in two Linux arm64 containers:

The Docker render exited 0 both times, but the frame looked wrong. Comparing the text rows against the Mac still gave SSIM scores of 0.7469 (regular) and 0.7128 (bold).

Find the fallback with fc-match

The fastest way to see what a missing system font turns into is to ask fontconfig inside the container:

for f in "Helvetica Neue" "Inter" "sans-serif" "emoji"; do printf '%s => ' "$f"; fc-match -f '%{family} | %{file}\n' "$f"; done

In the base image, all four of those names resolved to DejaVu Sans. In the emoji image, emoji resolved to Noto Color Emoji, and the other three still resolved to DejaVu Sans.

fc-match only knows about system fonts. A font loaded in the browser with FontFace or @font-face is invisible to it. fc-match Inter returned DejaVu Sans even when the bundled Inter rendered correctly.

The fix

1. Bundle the font and block rendering until it loads

The Inter woff2 files (400 and 700, latin subset, from @fontsource/inter 5.2.8) go in public/fonts/. src/fonts.ts:

import {staticFile} from 'remotion';

let promise: Promise<void> | null = null;

/** Load the bundled Inter files (400, 700) through the FontFace API. Resolves once both are in document.fonts. */
export const loadInter = (): Promise<void> => {
	if (!promise) {
		const faces = [
			new FontFace('Inter', `url(${staticFile('fonts/inter-latin-400-normal.woff2')})`, {weight: '400'}),
			new FontFace('Inter', `url(${staticFile('fonts/inter-latin-700-normal.woff2')})`, {weight: '700'}),
		];
		promise = Promise.all(faces.map((f) => f.load())).then((loaded) => {
			loaded.forEach((f) => document.fonts.add(f));
		});
	}
	return promise;
};

src/Card.tsx holds the frame until the fonts load. Then it runs an optional validator and calls continueRender. Any error goes to cancelRender:

import React, {useEffect, useState} from 'react';
import {AbsoluteFill, cancelRender, continueRender, delayRender} from 'remotion';
import {loadInter} from './fonts';

export type CardProps = {
	family: string;
	load: boolean;
	/** run the validator registered by the entry file (functions cannot travel through composition props) */
	validate?: boolean;
};

type Validator = (family: string) => void;
let validator: Validator | null = null;
export const setValidator = (v: Validator) => {
	validator = v;
};

export const LINE = 'Hamburgefonstiv 0123456789';
export const EMOJI_LINE = 'Emoji: \u{1F600} \u{1F3AC} ✅';

const row = (top: number, weight: number, family: string): React.CSSProperties => ({
	position: 'absolute',
	left: 40,
	top,
	height: 140,
	lineHeight: '140px',
	fontSize: 64,
	fontWeight: weight,
	fontFamily: family,
	color: '#111',
	whiteSpace: 'nowrap',
});

export const Card: React.FC<CardProps> = ({family, load, validate}) => {
	const [handle] = useState(() => delayRender('fonts'));
	useEffect(() => {
		(load ? loadInter() : Promise.resolve())
			.then(() => {
				if (validate && validator) validator(family);
				else if (validate) throw new Error('validate=true but no validator registered');
				continueRender(handle);
			})
			.catch((e) => cancelRender(e));
	}, [handle, load, family, validate]);

	return (
		<AbsoluteFill style={{backgroundColor: '#fff'}}>
			<div style={row(40, 400, family)}>{LINE}</div>
			<div style={row(220, 700, family)}>{LINE}</div>
			<div style={row(400, 400, family)}>{EMOJI_LINE}</div>
		</AbsoluteFill>
	);
};

The bundled composition in src/index.tsx uses family: 'Inter, sans-serif', load: true.

Gotcha: if your project has no package.json, Remotion may resolve public/ relative to a package.json in a parent folder. In this test it used the home directory, and staticFile fonts returned 404 on the host. Adding a package.json and a tsconfig.json to the project root fixed it.

2. Install an emoji font

Bundling Inter does nothing for emoji. Without an emoji font, the emoji row showed tofu boxes, and one glyph was substituted from DejaVu. Adding fonts-noto-color-emoji to the image gave colored emoji.

3. Make a silent fallback fail the render

src/index-guarded.tsx registers a validator at module scope:

import React from 'react';
import {Composition, registerRoot} from 'remotion';
import {measureText} from '@remotion/layout-utils';
import {Card, LINE, setValidator} from './Card';

// Docker-only entry (needs @remotion/layout-utils): same card, but the render fails if the font is not the one requested.
setValidator((family) => {
	measureText({text: LINE, fontFamily: family, fontSize: 64, fontWeight: '400', validateFontIsLoaded: true});
});

const mk = (id: string, family: string, load: boolean) => (
	<Composition key={id} id={id} component={Card} durationInFrames={30} fps={30} width={1280} height={720}
		defaultProps={{family, load, validate: true}} />
);

const Root: React.FC = () => (
	<>
		{mk('g-naive-bare', '"Helvetica Neue"', false)}
		{mk('g-naive-generic', '"Helvetica Neue", sans-serif', false)}
		{mk('g-bundled', 'Inter, sans-serif', true)}
		{mk('g-typo', 'Intr, sans-serif', true)}
	</>
);
registerRoot(Root);

Why module scope? In an earlier attempt, the validator function was passed through composition defaultProps. It never ran, and those renders exited 0. Functions don't survive the props path, so pass a boolean in props and keep the function in the module.

When the font wasn't loaded, measureText threw, and the error message said the measurement matched the fallback font "Times New Roman." The check also caught the typo Intr.

4. Optional: a gate for system fonts

If you depend on fonts installed in the image, scripts/fc-check.mjs exits 1 when fc-match resolves a requested family to a different one:

#!/usr/bin/env node
// Build gate for SYSTEM fonts: exit 1 if fc-match resolves any requested family to a different one.
// Does NOT see fonts loaded in the browser via FontFace/@font-face (those are not known to fontconfig).
import {execFileSync} from 'node:child_process';
import {isSilentFallback, parseArgs} from './fc-lib.mjs';
const {json, families} = parseArgs(process.argv.slice(2));
const results = families.map((requested) => {
	const out = execFileSync('fc-match', ['-f', '%{family}|%{file}', requested]).toString();
	const [matched, file] = out.split('|');
	return {requested, matched, file, silentFallback: isSilentFallback(requested, matched)};
});
if (json) console.log(JSON.stringify(results, null, 1));
else results.forEach((r) => console.log(`${r.silentFallback ? 'FALLBACK' : 'ok      '} ${r.requested} -> ${r.matched}`));
process.exit(results.some((r) => r.silentFallback) ? 1 : 0);

scripts/fc-lib.mjs:

// Pure helpers (unit-tested). fc-match output format used: "%{family}" -> e.g. "DejaVu Sans" or "Foo,Foo Bar".
export const familyList = (s) => s.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean);

/** true when fontconfig answered with a different family than the one requested (generic families are never "requested" fonts). */
export const isSilentFallback = (requested, matchedFamilyField) =>
	!familyList(matchedFamilyField).includes(requested.trim().toLowerCase());

/** Parse CLI args: returns {json, families} */
export const parseArgs = (argv) => ({json: argv.includes('--json'), families: argv.filter((a) => !a.startsWith('--'))});

In both images, it exited 1 for "Helvetica Neue" and "Inter" and passed "DejaVu Sans." Because it can't see FontFace fonts, the in-browser measureText check is the gate for bundled fonts.

Results

All numbers come from the final run of scripts/run-all.sh.

Mac vs Docker frame similarity. "composition" is the Remotion composition. "Docker image" is the container it ran in. Each row column is the ffmpeg ssim score for a 1280x140 crop of that row in frame 0, with the Mac still as the reference (1.0 means identical).

compositionDocker imageregular rowbold rowemoji row
naive ("Helvetica Neue", sans-serif)base0.74690.71280.878
naiveemoji0.74690.71280.8522
bundled (Inter via FontFace)base0.98050.9750.9218
bundledemoji0.98050.9750.9114

The bundled frame is close to the Mac frame but not identical. Antialiasing and hinting differences are a likely reason, but they were not isolated, so the cause is not confirmed. Mac emoji (Apple Color Emoji) and Noto emoji are drawn differently by design, so the emoji column doesn't score the font fix.

Emoji row mean saturation. "platform" is where the still was rendered. The "naive" and "bundled" columns show the ffmpeg signalstats SATAVG of the emoji row crop for each composition. A low value means gray tofu or monochrome glyphs.

platformnaivebundled
mac2.482.48
docker-base0.000.00
docker-emoji0.970.97

These values are low overall because the crop is mostly white space. Read them as color vs. no color, not as quality.

Docker render exit codes. "composition" is the composition passed to remotion still. "base" and "emoji" show the exit code in each image (0 means success).

compositionbaseemoji
naive00
bundled00
g-naive-bare ("Helvetica Neue", validated)11
g-naive-generic ("Helvetica Neue", sans-serif, validated)11
g-typo (Intr, sans-serif, Inter loaded, validated)11
g-bundled (Inter loaded, validated)00

The validator still failed g-naive-generic even with sans-serif appended. According to the test notes, the check compares against the browser's default font, not the generic fallback. On a system where the default font differs from the sans-serif font, it could miss a fallback. That case was not tested.

The unit tests (node --test test/fc-lib.test.mjs) reported 6 of 6 passing.

Test environment

Limitations

Reproduce

Prerequisites: Docker with network access (apt, npm, and the Chrome download), Node with Remotion 4.0.524 in the host node_modules, ffmpeg on the host (used by scripts/measure.mjs), a package.json and tsconfig.json in the project root, and the Inter woff2 files in public/fonts/. The docker/Dockerfile isn't reproduced here. It uses the apt list from Remotion's Docker docs plus fontconfig, and with EMOJI=1 it also installs fonts-noto-color-emoji.

docker pull node:22-bookworm-slim
docker build -t wp-font:base docker/
docker build --build-arg EMOJI=1 -t wp-font:emoji docker/
./scripts/run-all.sh

run-all.sh runs the unit tests and renders the Mac stills. Then it runs scripts/docker-run.sh in each image, which records fc-match output, runs fc-check.mjs, and renders all compositions. Finally, measure.mjs writes artifacts/results.md.

Sources

Official Remotion documentation:

Last tested: 2026-11-11, Remotion 4.0.524