# 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/ └── / ├── 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 `.woff2` where `` 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-face` block 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 to `sans-serif` if 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](https://gwfh.mranftl.com/fonts): 1. Pick the family. 2. Charsets section → **Latin** only (uncheck others unless you actually need them; Cyrillic alone roughly doubles file size). 3. Styles section → **400, 600, 700** at minimum (these match what the picker uses). 4. Click "Download files" — you'll get a ZIP containing the `.woff2` files plus the family's OFL license. 5. Rename the files to `400.woff2`, `600.woff2`, `700.woff2` and drop them in `storage/fonts//`. 6. Keep the OFL license file alongside (the static handler serves anything in the folder, so `/fonts//OFL.txt` is 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/fonts` request 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.js` reads 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/fonts` returns `{ fonts: [{ family, weights, generic }, ...] }`. - **Static serving**: `GET /fonts//.woff2` returns the actual file. Path-traversal protected. `Cache-Control: max-age=7d` — clients revalidate via `If-Modified-Since` after 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.tsx` watches `theme.fontFamily` / `theme.headingFontFamily` and injects exactly one `@font-face` block per family the page actually uses, into a single `