Skip to content

Building the server

The server is a container image: a Python virtualenv, the LaTeX toolchain and uvicorn. It mounts nothing and seeds nothing.

Build it

cd <repo root>
DOCKER_BUILDKIT=0 docker build -t resumix-server .

The Dockerfile lives at the repository root, not in server/, because some cloud builders refuse a Dockerfile in a subfolder. The build context is the root: the image needs contracts/ and server/, and nothing else.

Classic builder only. No BuildKit features and no heredocs — CI builds with DOCKER_BUILDKIT=0 on purpose, and the image must stay buildable that way.

flowchart TB
    subgraph s1["stage 1 — build (uv:python3.12-bookworm-slim)"]
        L1["pyproject.toml · uv.lock<br/>+ the three member pyprojects"] --> L2["uv sync --frozen --no-dev<br/>--package resumix-server<br/>--no-install-workspace"]
        L2 --> L3["contracts/src · server/src · server/resources"]
        L3 --> L4["uv sync --frozen --no-dev --no-editable<br/>→ /app/.venv"]
    end
    subgraph s2["stage 2 — runtime (python:3.12-slim-bookworm)"]
        R1["texlive-latex-base + cm-super"] --> R2["vendored fontawesome5<br/>→ TEXMFLOCAL"]
        R2 --> R3["COPY --from=build /app/.venv"]
        R3 --> R4["entrypoint.sh → resumix-api"]
    end
    L4 --> R3

Why the layers are in that order: the lock file changes rarely and the code changes every commit, so dependencies resolve in a layer of their own. --no-editable copies the packages into the venv instead of linking them back to /app/src, so the runtime stage needs nothing but the venv.

What the LaTeX bits are for

Package Why
texlive-latex-base hyperref, graphicx, array, hhline, amsmath, babel-english — everything resume.tex.jinja uses
cm-super Type 1 CM fonts. Without them, T1 encoding falls back to bitmap fonts and the PDF looks scanned. cm-super-minimal is not enough: it leaves the design sizes the CV uses for headings and small print as Type 3 bitmaps
fontawesome5 (vendored) The contact icons in the CV header. Debian ships it only inside the 1.7 GB texlive-fonts-extra, so it is vendored as a TeX Live archive in server/docker/vendor/ and unpacked into TEXMFLOCAL. No network access during the build

The image is ~780 MB, almost all of it that toolchain.

Run it

docker run --rm -p 8080:8080 --env-file .env resumix-server
curl localhost:8080/healthz          # expect "pdflatex": true

It runs as any uid (--user "$(id -u):$(id -g)"), needs no volume, and works with a read-only root filesystem as long as /tmp is writable and executable:

docker run --read-only --tmpfs /tmp:exec -p 8080:8080 --env-file .env resumix-server

exec on that tmpfs is required: pdflatex writes and then reads back its font cache there. A tmpfs also means finished CVs do not survive a restart — mount a volume at RESUMIX_WORK_DIR if you want them to.

server/compose.yaml does all of the above with the settings already wired:

cp .env.example .env     # then fill in one provider's API key
cd server && docker compose up --build

The published image is on Docker Hub:

docker run -d -p 8080:8080 -e MODEL_API_KEY=... lmstch/resumix:latest

:latest and :x.y.z are pushed by the release workflow, not by hand — see Cutting a release. Pin the version tag if you want the server to stay where the client you handed out expects it.

Configure it

The provider

Three model roles, one provider, one API key. The endpoint is declared once in the [provider] table of resources/models.toml; the key comes from the environment.

MODEL_API_KEY=sk-...
MODEL_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1

Any OpenAI-compatible /chat/completions endpoint works — OpenAI, DeepSeek, a local Ollama. Switching providers is replacing those two values; models.toml names the same two variables and does not need to change.

A model that sets use_alternate_provider = 2 calls a second endpoint, from MODEL_API_KEY2 / MODEL_BASE_URL2, instead; 3 uses MODEL_API_KEY3 / MODEL_BASE_URL3, and so on.

Per-role overrides need no rebuild either — RESUMIX_<ROLE>_<FIELD> for DETECT, SUMMARY, CV, REVIEW and HIGHLIGHT:

RESUMIX_CV_MODEL=qwen-max
RESUMIX_CV_TEMPERATURE=0.2
RESUMIX_CV_THINKING=off            # auto | on | off
RESUMIX_CV_REASONING_EFFORT=high   # low | medium | high
RESUMIX_CV_THINKING_BUDGET=        # empty unsets a budget declared in models.toml
RESUMIX_CV_STRUCTURED_OUTPUT=json_object
RESUMIX_CV_USE_ALTERNATE_PROVIDER=2  # MODEL_API_KEY2 / MODEL_BASE_URL2

thinking_budget and reasoning_effort are mutually exclusive: a declared budget always wins, so unsetting it (empty value) is what lets REASONING_EFFORT take effect for a role that has a budget in models.toml.

Environment

Variable Default What it does
MODEL_API_KEY — The provider key. Without it the server starts but every model call fails
MODEL_BASE_URL DashScope intl. The OpenAI-compatible endpoint
MODEL_API_KEY<N> / MODEL_BASE_URL<N> — Key/endpoint N (2, 3, …), for models with use_alternate_provider = N
PORT / HOST 8080 / 0.0.0.0 Where uvicorn listens
WEB_CONCURRENCY 1 uvicorn worker processes. Keep it at 1 per work root
RESUMIX_KEEPALIVE 75 uvicorn keep-alive timeout, seconds
RESUMIX_API_TOKEN unset Bearer token clients must send. Unset means no auth
RESUMIX_RESOURCES baked in Directory of replacement prompts / template / models.toml
RESUMIX_WORK_DIR system temp One directory per request: scratch, log, and a CV job's state and result
RESUMIX_MAX_CONCURRENT_JOBS 10 Requests in flight; the rest get 429
RESUMIX_LATEX_TIMEOUT 120 Seconds before a compile is killed
RESUMIX_REQUEST_BUDGET_SECONDS 1200 Wall clock for one CV run before 504
RESUMIX_MAX_ATTEMPTS 4 Generate → review → page-check rounds
RESUMIX_MAX_PART_BYTES 2000000 Cap on any one uploaded part
RESUMIX_JD_MIN_CHARS / _MAX_CHARS 1000 / 10000 Length band for the free JD check
RESUMIX_LOG_LEVEL INFO Logging level

Prompts and the template

The five prompts and resume.tex.jinja ship inside the image. To change them permanently, mount a directory with your versions and set RESUMIX_RESOURCES; anything missing there falls back to the built-in copy. To change them for one request, upload them as parts — that is what the client does when it finds them next to its executable.

docker run -p 8080:8080 --env-file .env \
  -v "$PWD/my-resources:/resources:ro" -e RESUMIX_RESOURCES=/resources \
  resumix-server

From a checkout

uv sync
uv run resumix-api            # http://localhost:8080
uv run pytest server/tests      # no API key, no network
uv run pytest                   # every package, plus the integration test

The LaTeX-dependent tests skip themselves when pdflatex is absent. Nothing in the suite calls a model: server/tests/server_helpers.py wires a real ModelSelector to a fake HTTP client, so request assembly, structured-output negotiation and usage logging are all the production code paths. server/tests/golden/cv_golden.tex pins the whole render — an escaping regression is otherwise invisible until someone reads a bad PDF.

Deploying

A CV job runs for minutes after the request that started it has been answered. That is the one thing to plan around.

  • RESUMIX_WORK_DIR is state. A job's status, its result and every request's log live there. The default is the system temp directory, which on most container platforms is RAM-backed and empty again after a redeploy — fine, because a client collects its CV within the minute. Mount a volume only if you want results to outlive a restart.
  • One process per work root. The job slots and the worker threads are per-process, and a starting process marks every job still marked running as failed — right for its own orphans, wrong for a sibling's live jobs.
  • Request timeouts barely matter now. Every call returns in seconds; only /v1/cv/render waits on a compile. What must outlast RESUMIX_REQUEST_BUDGET_SECONDS is the client's willingness to keep polling.
  • Size for the compiles, not the waiting. Ten jobs in flight is the default; each is mostly idle on the provider, but each also compiles LaTeX several times.
  • Scaling. A job id only means something to the instance that has its directory: run one instance, or give several a shared work root and route by request id.
  • Cold starts build three HTTP clients and read six files. Fast — but the first request also warms the LaTeX font cache in /tmp.

Sandboxing

A request may supply the LaTeX template, and /v1/cv/render accepts a whole .tex. That is arbitrary LaTeX, so every compile runs with shell escape disabled (-no-shell-escape, shell_escape=f), reads and writes confined to the scratch directory (openin_any=p, openout_any=p), no stdin to block on, a timeout, and its own HOME/TEXMFVAR. The scratch directory is deleted when the request ends, and the scratch path is scrubbed out of any LaTeX log that goes back to a client.

In CI

.github/workflows/ci.yml has three jobs. The image one builds with DOCKER_BUILDKIT=0, starts the container with a deliberately invalid provider endpoint, and asserts /healthz reports "pdflatex":true — the image is checked for the toolchain, not for a model.

.github/workflows/release.yml builds it the same way — classic builder, linux/amd64 — and adds one assertion before it pushes: that /healthz reports the version being released. The server reads its version from the installed package metadata, which the image builds with --no-editable, so that check is what proves the number on the Docker Hub tag is the number the container will report.