Remotion Missing Font in Docker/Linux: Find the Fallback with fc-match and Make It Fail the Render
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:
wp-font:base:node:22-bookworm-slim, the apt list from Remotion's Docker docs, andfontconfig. Installingfontconfigpulled infonts-dejavu-coreas a dependency. There were no other fonts.wp-font:emoji: the same image built with--build-arg EMOJI=1, which addsfonts-noto-color-emoji.
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).
| composition | Docker image | regular row | bold row | emoji row |
|---|---|---|---|---|
| naive ("Helvetica Neue", sans-serif) | base | 0.7469 | 0.7128 | 0.878 |
| naive | emoji | 0.7469 | 0.7128 | 0.8522 |
| bundled (Inter via FontFace) | base | 0.9805 | 0.975 | 0.9218 |
| bundled | emoji | 0.9805 | 0.975 | 0.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.
| platform | naive | bundled |
|---|---|---|
| mac | 2.48 | 2.48 |
| docker-base | 0.00 | 0.00 |
| docker-emoji | 0.97 | 0.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).
| composition | base | emoji |
|---|---|---|
| naive | 0 | 0 |
| bundled | 0 | 0 |
g-naive-bare ("Helvetica Neue", validated) | 1 | 1 |
g-naive-generic ("Helvetica Neue", sans-serif, validated) | 1 | 1 |
g-typo (Intr, sans-serif, Inter loaded, validated) | 1 | 1 |
| g-bundled (Inter loaded, validated) | 0 | 0 |
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
- Host: macOS 26.6.1 (Darwin 25.6.0), Apple M4, Node v24.13.1, Remotion 4.0.524 with its bundled Chrome headless shell.
- Docker: engine 29.5.2 on a Linux arm64 VM.
- Image base:
node:22-bookworm-slim, digestsha256:5ec23b7c622b00547753bc6ffd78085dd868a68aeb41c8e0a1418f033e13d96d, Debian 12.15, Node v22.23.4, aarch64, Chromium 149.0.7790.0 headless shell. - In the containers:
remotion,@remotion/cli, and@remotion/layout-utils4.0.524;reactandreact-dom19.2.0;@fontsource/inter5.2.8;fonts-dejavu-core2.37-6.
Limitations
- Only Linux arm64 containers and one Apple M4 Mac were used. Linux amd64 and CI runners were not tried, and their font sets may differ.
- Only stills (frame 0) were rendered, not videos. The Mac and Docker renders used different, unpinned Chrome headless shell builds, so the SSIM gaps may partly reflect Chrome, antialiasing, and hinting rather than fonts alone.
- SSIM and SATAVG are rough proxies.
- The
@remotion/layout-utilsvalidator was only run in Docker, not on the Mac. Its default-font comparison was not checked on other systems. - Only one font (Inter 400 and 700, latin subset) and one missing family (Helvetica Neue) were tried.
font-display, variable fonts, CJK text, and non-latin glyph coverage were not covered. - Only the FontFace plus
delayRenderapproach was used.@remotion/fontsloadFontand@remotion/google-fontswere not. - Apt package versions were not pinned, so results depend on the state of the apt repositories. The images are local tags, so only the base image digest is reported.
- Docker VM CPU and RAM were not recorded. Timing and performance were not measured.
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:
- Remotion docs: Docker
- Remotion docs: Fonts
- Remotion docs: Font loading errors
Last tested: 2026-11-11, Remotion 4.0.524