Architecture Overview
cvmd.sh is a platform for publishing a CV from a Markdown file in a Git repository. Users connect a GitHub repo, push changes, and the platform builds a public CV page and generates a downloadable PDF automatically.
The product runs as three services: cvmd-platform (the core Next.js app — auth, dashboard, public routes), cvmd-pdf-service (a headless-browser worker that renders PDFs, deployed on Railway), and cvmd-site (the marketing site). They share a monorepo but are deployed independently.
Key decisions
- All data lives in one database, owned exclusively by the platform. The PDF service has no direct database access — it reports results back to the platform via HTTP callback. This keeps schema ownership in one place and eliminates cross-service migration coordination.
- PDF rendering runs as a separate service on Railway. Vercel's serverless function size limits rule out bundling Puppeteer/Chromium there, and the pipeline must be async to meet GitHub's ~10-second webhook timeout. A long-running Node process on Railway solves both.
- The PDF service reports results via HTTP callback, not an event bus. The pipeline is linear — one trigger, one worker, one outcome. Cross-platform event infrastructure (Railway + Vercel) would add complexity with no proportionate benefit at this scale.
- The webhook handler returns
200immediately after inserting the deployment row. Work is deferred; the row acts as a durable record that enables future retry logic without any schema change. - Auth is handled by Better Auth, evaluated hands-on as an alternative to more established options.
Services
The cvmd.sh product is built from three independently deployable services. They share one monorepo (apps/ and packages/) but are deployed and scaled on their own.
| # | Application directory | Package name | Role | Typical deployment |
|---|---|---|---|---|
| 1 | apps/cvmd-platform | @cvmd/platform | Core product: auth, dashboard, CV editor, deployments, public CV routes | Vercel (Next.js) |
| 2 | apps/cvmd-site | @cvmd/site | Marketing / landing site | Vercel (Next.js) |
| 3 | apps/cvmd-pdf-service | @cvmd/pdf-service | PDF rendering worker (HTTP API + Puppeteer/Chromium) | Railway (Node long-running) |
Why is the PDF service a separate deployable?
Two independent constraints pushed in the same direction:
- GitHub's webhook timeout. GitHub expects a response within ~10 seconds. PDF rendering via a headless browser can exceed that. The webhook handler must return fast and hand off the work. Vercel does offer an
after()primitive for post-response work, but that still ties the work to a serverless function lifecycle. - Puppeteer/Chromium binary size. Vercel's serverless function size limits make bundling a full headless browser impractical. Railway, by contrast, runs the service as a long-lived Node process with no such constraint.
A message queue (e.g. on Vercel) was a real alternative: it would have kept everything in one app and solved the timeout problem. But it would still have hit the binary size wall, and it would have added operational overhead (queue provisioning, DLQs, consumer setup) not justified at current scale. Splitting to Railway solved both problems at once.
Data
All cvmd product data lives in a single PostgreSQL database hosted on Neon, accessed exclusively by cvmd-platform. The schema is versioned in this repository via SQL migrations. The marketing site (cvmd-site) and the PDF service (cvmd-pdf-service) have no database connection.
Concentrating data access in one service keeps schema ownership unambiguous: migrations only need to be coordinated and deployed in one place, and there is no risk of two services diverging on schema expectations.
| Area | Tables | Role |
|---|---|---|
| Auth (Better Auth) | user, session, account, verification | Sign-in, sessions, and linked OAuth accounts |
| Profile | profiles | Per-user public handle (username), avatar, and linkage to user.id. Used for public CV URLs such as /cv/[username]. |
| GitHub App | github_installations | Maps a user to a GitHub App installation id for API access. |
| Source connection | repositories | Which GitHub repo and branch are connected for a user (one row per user in the current schema). |
| Build / CV snapshot | deployments | One row per deployment (see Domain concepts); stores commit info, pipeline status, fetched CV markdown and theme, PDF URL and PDF sub-status, public deployment URL, optional HTML cache, timestamps. |
| Observability | deployment_logs | Append-only step messages for a deployment (fetch, theme, PDF, errors). |
| Analytics | events | Anonymous view / download style events tied to a deployment when applicable. |
Domain concepts
CV
A CV is the user's resume content, authored as Markdown in the connected Git repository at src/cv.md. Optional YAML front matter can carry theme hints; src/theme.css can override styling. On each relevant push, the platform fetches those files from GitHub, parses them, and persists markdown_content and theme_css on the deployments row so the public page and PDF pipeline do not depend on GitHub at read time. The live site route /cv/[username] loads the latest deployment with status = 'ready' for that user's connected repository (ordered by created_at).
Deployment
A deployment is one build pipeline run for a connected repository: created when a GitHub push affects the CV or theme paths (see Webhooks and callbacks). It tracks status (pending, queued, building, ready, failed), commit metadata, deployment_url (public CV page), content fields, and PDF fields. Multiple deployments over time give a history of publishes; the product UI surfaces logs in deployment_logs.
A PDF is a rendered artifact of the CV Markdown (and theme) produced by cvmd-pdf-service using a headless browser, uploaded to Vercel Blob, and referenced from deployments.pdf_url. The row also stores pdf_status, pdf_error, and pdf_generated_at. Preview PDFs can be generated on demand from editor content without a full Git deployment (POST on the pdf-service preview endpoint); deployment PDFs follow the main pipeline after deployment.created.
Webhooks and callbacks
GitHub → cvmd-platform (webhook)
GitHub is configured to send push webhooks to POST /api/webhooks/github on the platform. The handler (apps/cvmd-platform/app/api/webhooks/github/route.ts):
- Verifies the payload with
GITHUB_WEBHOOK_SECRET. - Only handles
push(no other event types in code today). - Ignores non-branch refs, deleted pushes, and pushes that do not touch
src/cv.mdorsrc/theme.css. - Resolves the matching
repositoriesrow (owner, name, branch), thenrecordDeploymentAndBuild: inserts adeploymentsrow and kicks off the build pipeline asynchronously.
The handler returns 200 as soon as the deployment row is inserted, before any content is fetched or PDF is generated. GitHub enforces a ~10-second response window; PDF rendering can take longer than that, so the webhook must return fast and defer the work.
A side-effect of inserting first and building second is that the deployments row acts as a durable work record. If the PDF service goes down mid-build, the row is already there and could be picked up for retry or re-queued later. Retry logic is not implemented today — at current scale it would be over-engineering — but the data model leaves the door open for it (polling the deployments table for stuck rows, or re-triggering from the platform UI).
cvmd-platform ↔ cvmd-pdf-service (HTTP callbacks)
These are authenticated HTTP calls between the two services (Bearer PDF_SERVICE_SECRET):
| Direction | Endpoint | Purpose |
|---|---|---|
| Platform → pdf-service | POST …/api/deployments/build | Body: { type: "deployment.created", deploymentId } (DeploymentCreatedPayload). Starts the deployment PDF build. |
| Platform → pdf-service | POST …/api/preview/build | Body: { markdown, themeCss? }. On-demand preview PDF; returns a Blob pdfUrl without updating a deployment row. |
| Pdf-service → platform | POST /api/deployments/pdf-complete | Success: { deploymentId, pdfUrl }; failure: { deploymentId, error }. Updates deployments (status, PDF columns, logs) and revalidates the public CV path. |
The PDF service can't write to the database directly, so it reports results back to the platform over HTTP. An event-driven approach (publish/subscribe) was considered but rejected: the services run on different platforms (Railway and Vercel), wiring a shared event bus across them adds non-trivial infrastructure, and the pipeline is linear enough that a direct call is the right level of complexity.
Interaction model
Shared packages
Workspace packages under packages/ are libraries (and one CLI) consumed by apps. They are not separate deployable services.
@cvmd/shared (packages/shared)
Shared TypeScript contracts used by cvmd-platform and cvmd-pdf-service: the DeploymentCreatedPayload type (platform → pdf-service) and the PDF completion payloads for the /api/deployments/pdf-complete callback. Keeping these in one package avoids drift between the two services.
cvmd (packages/cvmd-cli)
A command-line tool for local workflows: build a PDF from a CV Markdown file on the developer machine without going through the hosted platform. Useful for offline checks; it does not replace the hosted deployment pipeline.
External systems
| Concern | Where it lives |
|---|---|
| Primary database | PostgreSQL (Neon) |
| Deployment trigger | GitHub App |
| PDF blob storage | Vercel Blob |
Build and workspace tooling
- Package manager: pnpm workspaces (
pnpm-workspace.yaml). - Task orchestration: Turborepo (
turbo.json) fordev,build, andlintacross packages.