Keep every role, project and bullet point in one library, then tailor a LaTeX-typeset resume for each application without losing the original.
Most resume tools make you keep a separate copy of your history for every version of your resume, and the copies drift. This one inverts that: your experience lives in one library, and a resume is a selection and ordering of things already in it.
Add a role once. Use it in the frontend-focused resume and the backend-focused one. Fix a typo in the library and both follow along, because neither of them holds a copy — they hold a reference.
The output is not an HTML approximation of a resume. It is a real PDF, typeset by pdfTeX from generated LaTeX, produced by a background worker. See the PDF pipeline.
Education, experience, projects, skills and contact details live in a single place, grouped by type.
A resume picks sections out of that library and puts them in the order it wants. Each one keeps its own selection, its own ordering and its own header, so tailoring one leaves the others exactly as they were.
Bullet points support bold spans — select a phrase, press ⌘B — with a live preview of how the line will typeset. Bullets drag to reorder, and so do whole sections.
Deleting a resume says which one, and what survives it — the sections it used stay in the library.
|
|
The whole app reflows to a single column on small screens, and follows your system light/dark preference or an explicit toggle.
Typesetting is slow and bursty — a run is CPU-bound for a second or two — so it
does not belong on the request path. The API writes a job into Postgres and
a pool of workers claims it. There is no separate queue server: the table
is the queue, LISTEN/NOTIFY is the doorbell, and FOR UPDATE SKIP LOCKED
is what hands each job to exactly one worker.
sequenceDiagram
participant C as Browser
participant A as FastAPI
participant P as Postgres
participant W as Worker
participant T as pdfTeX
C->>A: POST /resumes/:id/pdf
A->>A: verify the resume belongs to the caller
A->>P: INSERT pdf_jobs + NOTIFY pdf_jobs
P->>W: notification wakes an idle worker
W->>P: claim with FOR UPDATE SKIP LOCKED
W->>P: load resume, sections, bullet points
W->>W: build document, serialize to LaTeX
W->>T: pdflatex in a sandbox, 20s cap
T-->>W: PDF bytes
W->>P: UPDATE pdf_jobs + NOTIFY pdf_jobs_done
P-->>A: notification, awaited up to 45s
A-->>C: 200 application/pdf
The job carries a resume id and nothing else. It re-reads the rows itself rather than trusting a document sent in from outside, so there is no caller-supplied LaTeX anywhere in the pipeline. Ownership is settled by the endpoint before the job is ever queued.
The queue bounds work rather than hiding it. The request waits for its own
result, because the client asked for a file and gets one in the same response.
What the queue buys is a ceiling on how many compiles run at once: a compile is
CPU-bound, so running more of them makes each one slower rather than finishing
sooner, and unbounded exports would take the host down instead of merely
queueing. Jobs run with max_tries = 1 — the document is identical on a retry,
so a retry cannot fix what failed.
How many workers is set when the API starts. PDF_WORKER_COUNT defaults to
3, and the API's lifespan starts that many:
PDF_WORKER_COUNT=6 bun run dev:apiZero is a real answer, and it is what docker-compose.yml gives the API: there
a separate worker container claims the jobs instead. Either way the API keeps
one LISTEN connection open, because a request waiting here has to hear that
its job finished wherever it ran.
The engine runs in a container. A TeX document is a program, and the engine that runs it is the least trustworthy thing in the application. Which sandbox it gets depends on where the worker is:
LATEX_BACKEND |
Used by | The fence |
|---|---|---|
docker (default) |
the API on the host | a container per compile: --network=none, read-only root, --cap-drop=ALL, --memory=512m, --pids-limit=128, nothing mounted. The document goes in on stdin and the PDF comes back on stdout |
local |
the worker container |
the engine as a child process — that container is already the boundary, and starting containers from inside one would mean giving it a Docker socket, a far larger privilege than the one being contained |
Both are fenced the same way from the engine's side:
| Fence | Why |
|---|---|
openin_any=p, openout_any=p |
paranoid mode — \input{/etc/passwd} cannot reach the PDF |
-no-shell-escape |
no \write18, whatever the document says |
| a minimal environment | the engine inherits nothing the worker was started with, so a stray secret cannot leak into a document |
-halt-on-error |
fail on the first error instead of cascading |
| 20s timeout | a hung run is killed, container and all |
SOURCE_DATE_EPOCH=0 |
identical input produces an identical file |
Build the compile image once before exporting on the host:
bun run docker:latexThe engine has to be pdfTeX. The template calls \pdfgentounicode, a pdfTeX
primitive that emits the glyph-to-Unicode map making the PDF readable by
applicant tracking systems — which for a resume is close to the whole point.
XeTeX-based engines such as Tectonic reject it.
Failures come back as themselves rather than a generic 500: a document the
engine rejected returns 422 with the tail of the TeX log, a missing engine
returns 503, a compile that outlives the wait returns 504, and a resume
deleted between queueing and running returns 404. The worker cannot throw
across a table, so it records which failure it hit and the endpoint raises it
again on the other side.
Finished rows are deleted a minute after they are read, and a job whose worker died is failed rather than left running, both by a reaper that runs alongside the workers.
Relevant code:
routers/resume.py |
the endpoint that queues and waits |
services/pdf_queue.py |
the queue: enqueue, claim, result, reap |
services/pdf_worker.py |
the worker loop and the pool |
deps/notify.py |
the one LISTEN connection everything waits on |
services/compiler.py |
the sandboxed pdfTeX run, both backends |
services/latex/ |
document → LaTeX source |
docker/latex/ |
the compile sandbox image |
worker.py |
standalone worker, for the compose deployment |
| Web | React 19, React Router 8, TanStack Query + Form, Tailwind 4, Vite |
| Desktop | Rust, Iced 0.14, hayro (PDF preview), cargo-packager |
| API | FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 |
| Data | Postgres 16 hosted, SQLite on the desktop |
| Jobs | Postgres queue (LISTEN/NOTIFY, SKIP LOCKED), pdfTeX |
| Tooling | Bun, Cargo, uv, Biome, Ruff, mypy, pytest |
There are two clients over one API. The web app is a React Router SPA; the desktop app is a native Rust program written with Iced. They share no UI code — each is written for its platform — but they talk to the same API, and the LaTeX the desktop saves is checked against the same golden file as the web's and the API's.
apps/
web/ the React Router SPA, plus its Dockerfile and screenshots
app/routes/ pages
app/components/ shared UI, and the landing page demos
app/lib/ LaTeX serialization, resume documents
app/api/ generated OpenAPI types + client
desktop/ the native app (Rust, Iced)
src/ui/ screens: dashboard, sections, forms, resumes, editor
src/api/ a typed client for the sidecar
src/latex/ the .tex serializer, ported from the web app
src/sidecar.rs starting and stopping the bundled API
api/ FastAPI service, worker, migrations
routers/ HTTP endpoints
services/ compile pipeline, LaTeX serialization, sections
models/ SQLAlchemy tables
schemas/ Pydantic request/response types
The API is the same in both too. MODE=cloud is the hosted service — Postgres,
a queue, accounts, and compiles fenced in a container. MODE=local is the copy
the desktop app embeds: one SQLite file, nobody signed in, no queue, and
typesetting done in this process with the TeX the app ships. The routers,
services and schemas are the same code either way.
docker compose up --buildThat builds and starts the whole application — Postgres, the API, the PDF worker and the web app — and applies the migrations on the way up. The app is at http://localhost:5173 and the API at http://localhost:8000; create an account from the sign-in page.
No .env is needed: docker-compose.yml defaults every setting. Drop one at
the repo root (see .env.example) to change ports or the database credentials,
or to fill in GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET for Google sign-in.
The database lives in a named volume and survives docker compose down;
down -v is what throws it away. The job queue is a table in that same
database, so there is no second service to run.
The image the API and worker share carries a TinyTeX install with just the
packages the resume template loads, so pdflatex is there without a TeX Live
install on your machine. PDF_WORKER_COUNT sets how many compiles the worker
container runs at once (default 3).
This runs the app, not a development loop: the web bundle is built into the image and the API runs without
--reload, so code changes need a rebuild. For day-to-day work use the setup below, which runs only the backing services in Docker.
From a fresh clone:
cp .env.example .env # dev defaults work as-is
bun install
uv sync --directory apps/api
bun run docker:dev # postgres + cloudbeaver; leave this running
bun run docker:latex # the compile sandbox image, once
bun run db:upgrade # apply migrations
bun run db:seed # sample data + demo accountThen, in two more terminals:
bun run dev:api # http://localhost:8000
bun run dev:web # http://localhost:5173Sign in at http://localhost:5173 as demo@example.com / demo1234.
There is no separate worker to start: the API runs its own, three by default.
PDF_WORKER_COUNT=6 bun run dev:apiruns six instead, and0runs none — useful if you want a standaloneuv run python worker.pyto do the work.Each compile runs in the
resume-builder-latexcontainer, sobun run docker:latexhas to have been run at least once; the endpoint returns503when the image or Docker itself is missing. SetLATEX_BACKEND=localto use apdflatexon yourPATHinstead.
bun run db:seed fills the database with one demo user and a realistic spread
of sections (personal info, educations, experiences with bolded bullet points,
projects, skills) plus two resumes built from them.
Every seeded row has a deterministic id, so re-running the seed deletes exactly
the rows it owns and reinserts them — anything you created by hand is left
alone. It is safe to re-run whenever you want a clean baseline, and it refuses
to run unless NODE_ENV=development. The demo account's password is committed
in the seed script; it is a development fixture and must never exist in a
deployed environment.
To change the sample data, edit apps/api/scripts/seed.py and re-run the seed.
CloudBeaver runs at http://localhost:8978 with the connection Resume Builder
(dev) already created (see docker/cloudbeaver/initial-data-sources.conf).
The username is filled in; enter POSTGRES_PASSWORD from your .env on first
connect and CloudBeaver remembers it.
The connection is created from the committed config only when the CloudBeaver workspace volume is empty, so it appears on a fresh clone and never overwrites connections you have saved. To get it back after changing that file:
docker compose -f docker-compose.dev.yml rm -sf cloudbeaver
docker volume rm resume-builder_cloudbeaver_workspace
bun run docker:devFor a shell instead:
docker compose -f docker-compose.dev.yml exec postgres psql -U resume_user -d resume_dbThe frontend's types are generated from the FastAPI OpenAPI spec by
openapi-typescript, and consumed through
openapi-fetch / openapi-react-query. The generated file is committed, so
regenerate it whenever a route or schema changes:
bun run codegenIt reads http://localhost:8000/openapi.json, so the API must be running.
The output is a single file, apps/web/app/api/schema.d.ts — never edit it
by hand, since the next codegen run overwrites it. The typed client built on
top lives in apps/web/app/api/api.ts.
The desktop app's types are written by hand in
apps/desktop/src/api/models.rs; when a
schema changes, change them too. serde refuses a response that no longer fits,
so a drift shows up as an error rather than as missing data.
bun run test # pytest + bun test + cargo test
bun run verify # format, lint and typecheck every appThe API tests run against a throwaway SQLite file and need nothing started.
Set TEST_DATABASE_URL to run the same suite against a Postgres — the hosted
app's database — which is worth doing before changing a model.
The job queue's tests only run on that second pass. FOR UPDATE SKIP LOCKED
and LISTEN/NOTIFY are the whole mechanism and SQLite has no stand-in for
them, so they skip rather than pretend:
TEST_DATABASE_URL=postgresql+psycopg://resume_user:resume_pass@localhost:5432/resume_db_test \
uv run --directory apps/api pytestA native Rust app, built with Iced, with everything on your own machine. No account, no network: it starts the API as a child process against a SQLite file in your application data directory, and carries its own TeX distribution so a resume typesets to a real PDF on a computer that has never had LaTeX installed. The preview is that PDF, rasterised in-process by hayro, a pure-Rust renderer.
bun run dev:desktop # cargo run: builds the app and opens itIn a checkout the API runs from source through uv, so an edit to a router
shows up without rebuilding anything. The library lives where the Electron
app kept it — ~/Library/Application Support/Resume Builder on macOS,
%APPDATA%\Resume Builder on Windows — so an existing library opens as it was.
Set RESUME_BUILDER_DATA_DIR to point a development run at a scratch library
instead.
bun run test:desktop # unit + headless UI tests
cargo test --manifest-path apps/desktop/Cargo.toml -- --ignored # end to end: real API, real pdfTeX
SNAPSHOT_DIR=/tmp/shots bun run test:desktop # also writes every screen as a PNGbun run package:desktopThis needs cargo-packager
(cargo install cargo-packager --locked). Three things go in, and two of them
cannot be cross-built — PyInstaller
produces a binary for the machine it runs on, and the TeX distribution is
per-platform. So an installer can only be built on the kind of machine it is
for. Building an x64 installer on an Apple Silicon Mac produces a .dmg that
fails on the first launch of the machine it was meant for.
resume-api.spec |
the API as a single binary |
bundle-texlive.sh |
TinyTeX plus the packages the template needs |
Cargo.toml |
the app, and under [package.metadata.packager] what ships beside it |
The TeX bundle is most of the download — around 235MB installed.
The release workflow, release-desktop.yml,
is disabled: nothing triggers it, and its first job is switched off. Its
build steps still describe the Electron app and need rewriting for
cargo-packager before it is turned back on — the note at the top of the file
says how.
Nothing is notarised, so both systems warn on first launch. If a packaged
macOS app is reported as "damaged", ad-hoc sign it with
adhoc-sign-mac.sh before building
the .dmg.
The screenshots on the marketing page — and in this README — are captures of the running app, regenerated by a script rather than taken by hand:
bun run apps/web/scripts/capture-shots.mjsIt needs the full stack running (including the worker, for the PDF preview) and
cwebp on PATH. See the header of
capture-shots.mjs for the details.