BuildCV: I shipped a free CV builder — no account, no paywall, no server-side data
Try a mainstream "free" CV builder and you will meet the same funnel: a slick wizard, twenty minutes of typing your work history, and then a paywall squatting on the download button. Your data is already in their database, and the exit costs €2.95 "for a trial".
That annoyed me enough to build the opposite. BuildCV is a CV builder that is free the way a hammer is free after you buy it — except you don't buy it. No account, no paywall, and structurally no way for me to harvest your data, because it never leaves your browser.
Product decisions first, code second
I time-boxed v1 around four decisions, and each one deleted a whole category of work:
- Client-only data. The entire CV lives in
localStorage, with JSON export/import as the backup story. No accounts, no database rows with strangers' phone numbers, no GDPR surface, no marginal cost per user. - No AI in v1. The "powerful" feel comes from a curated corpus instead: example bullet points, summary templates and skill suggestions for six job families, in French and English, keyed by experience level. Static data files, zero runtime cost, no jailbreak surface.
- Client-side PDF. The PDF pipeline is print CSS plus
window.print(). No headless Chrome on my 4 GB box, no upload of personal data just to render a document. - One excellent template instead of thirty-five mediocre ones — single column, real text, ATS-parseable, with accent colors and font pairs behind a small registry so template #2 is one folder away.

The wizard: guided, but never locked
Seven hash-routed steps — start, contact, experience, education, skills, summary, download — with a step rail that lets you jump anywhere. The right half of the screen is a live A4 preview that re-renders on every keystroke, so there is no "generate" moment: you watch the document assemble itself.
The experience step is where the static guidance earns its keep. Pick a job family and you get bullet phrases written to be adapted, not pasted — every one keeps a [bracketed] slot that nudges you to insert your own numbers. One click drops it into the current entry.

The preview IS the print target
The trick that makes the whole PDF story work: there is exactly one render of the CV. The A4 sheet you see on screen is laid out at true paper metrics (210mm wide) and scaled down with a CSS transform to fit the pane. When you hit "Download as PDF", print CSS hides everything else and un-scales the sheet:
@page { size: A4; margin: 0; } /* margin 0 also kills the browser's URL header */
@media print {
body * { visibility: hidden; }
.cv-sheet, .cv-sheet * { visibility: visible; }
.cv-sheet { position: absolute; top: 0; left: 0; width: 210mm; min-height: auto; }
/* CRITICAL: a scaled ancestor wrecks print pagination */
.bc-preview-scaler { transform: none !important; }
.cv-entry { break-inside: avoid; }
.cv-section-title { break-after: avoid; }
}Three details cost me the most time: min-height: auto in print (a fixed 297mm forces a blank trailing page), resetting the preview's scale transform (Chrome paginates transformed content in fascinating and wrong ways), and print-color-adjust: exact so the accent color survives "background graphics off". What you get is a real vector PDF with selectable text — which is exactly what applicant tracking systems want to parse.


--cv-accent) set on the sheet root — restyling is instant, and template #2 inherits the mechanism for free.Third host, same jar
zakaria.lu already runs as one Spring Boot fat jar behind Traefik: the gated dossier SPA on the apex and a server-rendered blog on blog.zakaria.lu. BuildCV became host number three in the same container, which kept the infra bill at exactly zero.
The frontend is a second Vite entry in the existing React project. Vite's multi-page build keys output on the source path, so frontend/buildcv/index.html lands in dist/buildcv/ while both apps share dist/assets/ — and the Dockerfile's existing "copy dist into Spring's static dir" step ships it with zero changes.
On the backend, a small OncePerRequestFilter detects the host from X-Forwarded-Host and forwards. The interesting line is the SPA fallback — Spring Boot's welcome-page resolution is root-only, so a subdirectory index must be targeted explicitly:
private static final List<String> SEO_FILES = List.of("/robots.txt", "/sitemap.xml");
static String targetFor(String path) {
return SEO_FILES.contains(path) ? "/buildcv" + path : "/buildcv/index.html";
}My first version used a dot-heuristic ("last segment contains a dot = static file"). Code review talked me out of it: a typed path like /john.doe would 404 instead of landing on the app. An explicit allowlist cannot misroute anything, and adding a third SEO file is a one-line diff.
The one-line change that nearly took down three sites
The best catch of the pre-ship review was not in the application at all. Adding the host meant extending the Traefik router rule in docker-compose:
- traefik.http.routers.cvnext.rule=Host(`${CVNEXT_DOMAIN}`) || Host(`${CVNEXT_BLOG_DOMAIN}`) || Host(`${CVNEXT_BUILDCV_DOMAIN}`)My compose file is deployed by hand (CD only ships the image), and the production .env predates the new variable. Unset variable → empty Host(``) clause → Traefik rejects the entire router — apex, blog and builder all dark, from a feature launch. The fix is one character of compose syntax: ${CVNEXT_BUILDCV_DOMAIN:?add it to .env} fails the deploy loudly instead of failing the site silently. When I deployed for real, the box's .env was indeed missing the variable — the guard I added because of the review was guarding against me, that same evening.
Boring numbers
- The builder adds ~56 kB gzipped of JS on top of the shared React vendor chunk.
- Backend footprint: one filter, one SEO controller, three config properties, 117 tests still green.
- Marginal cost per user: zero. localStorage is their disk, print-to-PDF is their CPU.
What v2 might bring
An optional AI bullet-improver is the obvious candidate — the jar already carries a DeepSeek ChatModel for my dossier concierge, and an IP-keyed rate limiter pattern for anonymous endpoints. A second template is one folder plus a registry entry. More job families are pure data files. None of it changes the core promise: your CV is yours, it lives in your browser, and the download button will never grow a price tag.
buildcv.zakaria.lu — go build one. If it lands you an interview, I want to hear about it.