Self-hosted webfonts
PicPeak ships with a curated set of webfonts baked into the backend image and serves them from your own origin. No requests go to fonts.googleapis.com or any third-party CDN — guest IPs stay private, which is important for GDPR compliance (LG München 2022).
The font picker in the admin theme customizer is data-driven: whatever the backend finds on disk, the picker offers. This page documents the conventions and the workflow for adding your own families.
What ships out of the box
The Docker image bundles 8 OFL-licensed families at backend/assets/fonts/:
- Comic Neue
- IBM Plex Sans
- Inter (the default)
- Jost
- Montserrat
- Noto Sans
- Playfair Display
- Poppins
These appear in the admin theme customizer with no configuration.
Adding your own font (drop a folder, restart)
You don't need to fork the repo. Place a font folder in your runtime storage volume — the same volume that holds events, thumbnails, etc. — and it appears in the picker after the next backend restart (or within ~30 seconds of being added, whichever comes first).
1. Choose where on the host
Bind-mount target inside the container is /app/storage/fonts/ (the env var STORAGE_PATH controls the prefix; defaults to /app/storage). On the host, that's wherever your docker-compose.yml mounts ${APP_STORAGE} from — typically ./storage/.
2. Folder layout
storage/fonts/
└── <Family-Name>/
├── 400.woff2
├── 600.woff2
├── 700.woff2
└── meta.json (optional)
Rules:
- Folder name = display family name with spaces replaced by hyphens. The scanner turns
Roboto-Slab/→Roboto Slab. Use the exact upstream family name; capitalisation is preserved. - File names are
<weight>.woff2where<weight>is an integer (100-900). Other names are ignored. The picker doesn't expose individual weights, but the runtime injects all available weights in the@font-faceblock so headings (semibold/bold) render correctly. - Format must be
.woff2. Other formats are ignored. WOFF2 is universally supported and the smallest on the wire. - No italics in v1 (the picker doesn't expose them). Italic files in the folder are silently ignored.
meta.json(optional) tells the picker which CSS generic family to fall back to while the font file is loading (and permanently if the file ever 404s). Shape:{ "generic": "sans-serif" | "serif" | "cursive" | "monospace" }. Defaults tosans-serifif absent. Add this for serif fonts (e.g. Playfair Display) and cursive/display fonts (e.g. Comic Neue, Lobster) so visitors don't briefly see Helvetica during the font fetch.
3. Where to download fonts
For Google-Fonts-licensed families, use google-webfonts-helper:
- Pick the family.
- Charsets section → Latin only (uncheck others unless you actually need them; Cyrillic alone roughly doubles file size).
- Styles section → 400, 600, 700 at minimum (these match what the picker uses).
- Click "Download files" — you'll get a ZIP containing the
.woff2files plus the family's OFL license. - Rename the files to
400.woff2,600.woff2,700.woff2and drop them instorage/fonts/<Family-Name>/. - Keep the OFL license file alongside (the static handler serves anything in the folder, so
/fonts/<Family-Name>/OFL.txtis publicly available — this satisfies OFL §2's "license must be included with all copies").
For non-Google fonts, ensure you have the right to redistribute. SIL Open Font License (OFL), Apache 2.0, and most "free for commercial use" web licenses allow this.
4. Activation
Either:
- Restart the backend container (immediate), or
- Wait ~30 seconds for the in-memory cache to expire and the next
/api/public/fontsrequest to re-scan.
Refresh the admin customizer; the new family appears in the body and heading dropdowns.
Note: The admin customizer caches the fonts list separately for 5 minutes (React Query staleTime). After the backend picks up a new family, hard-reload the customizer page (⌘+Shift+R / Ctrl+Shift+R) to see it immediately, or wait up to 5 minutes for the frontend cache to expire on its own. The two caches serve different purposes — the backend avoids disk hits per request; the frontend avoids network hits per re-render — so we keep them independent and document the worst case rather than try to synchronise them.
How it works
- Scanner:
backend/src/services/fontsService.jsreads two locations and merges them:backend/assets/fonts/(bundled) +STORAGE_PATH/fonts/(user). User additions override bundled families of the same name. Cached for 30 s. - Listing endpoint:
GET /api/public/fontsreturns{ fonts: [{ family, weights, generic }, ...] }. - Static serving:
GET /fonts/<Family-Name>/<weight>.woff2returns the actual file. Path-traversal protected.Cache-Control: max-age=7d— clients revalidate viaIf-Modified-Sinceafter expiry, so replacing a file on disk eventually rolls out without admin action (see "Replacing an existing font" below). - Lazy injection:
frontend/src/contexts/ThemeContext.tsxwatchestheme.fontFamily/theme.headingFontFamilyand injects exactly one@font-faceblock per family the page actually uses, into a single<style id="self-hosted-fonts">element. Other families are not loaded for that visitor. - Bootstrap:
frontend/src/index.cssships static@font-faceblocks for Inter so the very first paint already has the default body font.
Caveats and edge cases
- Empty folder (no
<weight>.woff2files) → silently skipped, warning in backend logs. - Two folders that normalize to the same family on case-insensitive filesystems (macOS APFS) → second is skipped, warning logged.
- Weight files with non-numeric names (e.g.
bold.woff2,regular.woff2) → ignored; family entry still includes its other weights. - Removing the
Inter/folder → the very-first-paint bootstrap CSS inindex.csswill 404 the font requests; browsers fall back to the next family in--font-family(Noto Sans → system-ui). Cosmetic only; no other breakage. - Variable fonts are not supported in v1. Each weight must be a separate file.
- Italics are not exposed in the picker.
- Per-option dropdown previews (each font name rendered in its own face inside the picker) are not supported in v1. Browser support for styling
<option>elements is inconsistent — Safari ignores it in the popup entirely, and Chrome/Firefox were unreliable in testing. A future improvement is to replace the native<select>with a custom dropdown component or to render a separate "preview text" box below the picker.
Replacing an existing font
Browsers cache font files for up to 7 days. When you overwrite an existing weight file (e.g. swap your Inter/400.woff2 for a different cut), some visitors may keep seeing the old face for up to a week, even after a backend restart.
Two ways to roll out a replacement:
- Wait it out. Without
immutableon the cache header, browsers sendIf-Modified-Sinceonce the 7-day window expires; the backend responds based on file mtime, so the new file gets picked up automatically the next time each client revisits the gallery. - Force-bust the cache by renaming the family folder. Move
Inter/→Inter-v2/(with the new file inside) and update the affected event themes to useInter v2. The new folder is served from a new URL, so caches don't apply and every client picks up the new face on next page load. This is the right approach when you need an immediate, gallery-wide rollout.
The first option is fine for cosmetic touch-ups; the second is what to do when a font replacement is genuinely urgent.
License
The bundled fonts are all SIL Open Font License v1.1 (OFL). The license text and per-font copyright notices live at backend/assets/fonts/LICENSE-OFL.txt and are publicly served at /fonts/LICENSE-OFL.txt.
If you redistribute the PicPeak Docker image, you redistribute these fonts too — you must keep the LICENSE-OFL.txt file accessible. The default static handler does this for you.
PicPeak Documentation