/.well-known/oauth-protected-resource"` header. OAuth clients use it to discover the authorization server.
+- Authorization server metadata is at `/.well-known/oauth-authorization-server`. The authorization server supports dynamic client registration and PKCE with `S256`.
+- The consent screen always lists API access, plus the scopes the client asked for: `profile`, `email` and `offline_access` (which lets the client refresh its token while you're away).
## Troubleshooting
-| Issue | Solution |
-| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
-| "Unauthorized" with no login prompt | Your client may not support MCP OAuth discovery. Use API key mode (`x-api-key`) |
-| OAuth login opens but fails redirect/callback | Confirm your client's MCP OAuth callback settings and retry the connection |
-| "API error (401)" | Your API key is invalid or expired. Create a new one in **Settings → AI & developer** |
-| "API error (404)" | The resume ID doesn't exist. Use `list_resumes` to find valid IDs |
-| "API error (403)" | The resume is locked. Unlock it in the Reactive Resume dashboard |
-| Connection refused | Check that the URL is correct and the instance is running |
-| "ReferenceError: File is not defined" when using `mcp-remote` | You're running Node.js 18. `mcp-remote` requires **Node.js 20 or later**; upgrade with `nvm use 20` or `nvm alias default 20` |
-| "Application documents must be PDF files" | `attach_application_document` only accepts `contentType: "application/pdf"` and base64-encoded PDF bytes |
+| Problem | What to do |
+| --- | --- |
+| "Unauthorized" and no sign-in window | Your client doesn't support MCP OAuth. Connect with an API key. |
+| The sign-in window says the request is invalid or has expired | Start the connection again from your client. |
+| `401` with an API key | The key is wrong, revoked or expired. Create a new one in **Settings** → **AI & developer**. |
+| A tool says the resume is locked | Unlock it in the app, or ask the client to run `unlock_resume`. |
+| A tool says an ID wasn't found | Ask the client to list resumes, letters or applications first to get valid IDs. |
+| An edit to dates doesn't stick | The client changed the `period` or `date` text. Ask it to write the `dates` object instead. |
+| `import_resume` fails on a large file | Your client's message size limit is too small. Import the file in the app instead. |
+| A self-hosted connection made before an upgrade stops working | Remove the server from your client and add it again, so it registers afresh and you approve it again. |
+
+## Related guides
+
+- [Managing applications with MCP](/guides/managing-applications-with-mcp): prompts for tracking your job search from an AI client.
+- [Using the patch API](/guides/using-the-patch-api): how resume edits are expressed.
+- [Using the API](/guides/using-the-api): API keys and the REST API.
diff --git a/docs/guides/using-the-patch-api.mdx b/docs/guides/using-the-patch-api.mdx
index 04b4f1c3a..9366bcc2d 100644
--- a/docs/guides/using-the-patch-api.mdx
+++ b/docs/guides/using-the-patch-api.mdx
@@ -1,191 +1,230 @@
---
title: "Using the patch API"
-description: "Partially update a Reactive Resume with JSON Patch (RFC 6902) operations to add, remove, replace, and move fields without sending the full document."
+description: "Change individual fields of a Reactive Resume with JSON Patch (RFC 6902): add, remove, replace and move items, set dates and avoid overwriting edits."
---
-The Patch API lets you make small, targeted changes to your resume without sending the entire data object. Instead of replacing the whole resume with a `PUT`, you send a list of **JSON Patch** operations that describe exactly what to change.
+The patch endpoint lets you make small, targeted changes to a resume's content without sending the whole document. You send a list of [JSON Patch (RFC 6902)](https://datatracker.ietf.org/doc/html/rfc6902) operations, and the server applies them together and returns the updated resume. This is the same mechanism the MCP server's `apply_resume_patch` tool uses.
-This is based on the [JSON Patch (RFC 6902)](https://datatracker.ietf.org/doc/html/rfc6902) standard.
+## Before you start
-## When to use PATCH vs PUT
+- Create an API key and try a first request, as described in [Using the API](/guides/using-the-api).
+- Find the resume's ID with `GET /api/openapi/resumes`, then fetch its current content with `GET /api/openapi/resumes/{id}`. Paths in your operations point into the `data` object of that response.
+- Keep the [JSON resume schema](/guides/json-resume-schema) at hand. It lists every field, its type and which fields a new item needs.
-| Use case | Method |
-| -------------------------------------------- | --------- |
-| Update a single field (e.g., name, headline) | **PATCH** |
-| Add or remove an item in a section | **PATCH** |
-| Change template, colors, or fonts | **PATCH** |
-| Replace the entire resume data at once | **PUT** |
+## Choose PATCH or PUT
-
- The PATCH endpoint only modifies the resume `data` (the JSONB column). To update top-level resume properties like
- `name`, `slug`, `tags`, or `isPublic`, use the existing `PUT /resume/{id}` endpoint.
-
+| You want to | Use |
+| --- | --- |
+| Change one field, such as the headline or a company name | `PATCH /resumes/{id}` |
+| Add, remove or reorder entries in a section | `PATCH /resumes/{id}` |
+| Change the template, colors, fonts or layout | `PATCH /resumes/{id}` |
+| Rename the resume, change its slug or tags, or make it public | `PUT /resumes/{id}` |
+| Replace the whole resume content at once | `PUT /resumes/{id}` with `data` |
-## Authentication
+PATCH only changes the resume's `data`. The name, slug, tags and public setting sit outside `data`, so change them with `PUT`, which updates only the fields you send.
-All requests require your API key in the `x-api-key` header. See [Using the API](/guides/using-the-api) for how to create one.
+## Send a patch
-
- If you're self-hosting, replace `https://rxresu.me` with your instance URL. The API is served under `/api/openapi`.
-
-
-## Endpoint
-
-```
-PATCH /api/openapi/resume/{id}
+```http
+PATCH /api/openapi/resumes/{id}
+x-api-key: YOUR_API_KEY
+Content-Type: application/json
```
-### Request body
-
-The resume ID is taken from the URL path, so the request body only requires the `operations` array:
+The body holds the operations, and optionally the version of the resume you based them on:
```json
{
- "operations": [{ "op": "replace", "path": "/basics/name", "value": "Jane Doe" }]
+ "expectedUpdatedAt": "2026-09-30T00:10:08.197Z",
+ "operations": [
+ { "op": "replace", "path": "/basics/headline", "value": "Senior Game Developer" }
+ ]
}
```
-Each operation is an object with the following properties:
+| Field | Required | Description |
+| --- | --- | --- |
+| `operations` | Yes | At least one JSON Patch operation. They run in order. |
+| `expectedUpdatedAt` | No | The resume's `updatedAt` when you read it. If the resume has changed since, the patch is rejected with `409`. See [Avoid overwriting other edits](#avoid-overwriting-other-edits). |
-| Property | Required | Description |
-| -------- | ---------------------------- | ------------------------------------------------------------------------------- |
-| `op` | Yes | The operation to perform: `add`, `remove`, `replace`, `move`, `copy`, or `test` |
-| `path` | Yes | A JSON Pointer (RFC 6901) to the target location in the resume data |
-| `value` | For `add`, `replace`, `test` | The value to use for the operation |
-| `from` | For `move`, `copy` | A JSON Pointer to the source location |
+Each operation has these properties:
+
+| Property | Required | Description |
+| --- | --- | --- |
+| `op` | Yes | `add`, `remove`, `replace`, `move`, `copy` or `test` |
+| `path` | Yes | A [JSON Pointer (RFC 6901)](https://datatracker.ietf.org/doc/html/rfc6901) into the resume data, such as `/basics/name` |
+| `value` | For `add`, `replace` and `test` | The value to write or compare |
+| `from` | For `move` and `copy` | A JSON Pointer to the source |
+
+A successful patch returns `200` with the full updated resume, including its new `updatedAt`.
## Examples
-### Replace a basic field
-
-Update the resume holder's name and headline:
+### Replace basic fields
```bash
-curl -X PATCH "https://rxresu.me/api/openapi/resume/YOUR_RESUME_ID" \
+curl -X PATCH "https://rxresu.me/api/openapi/resumes/YOUR_RESUME_ID" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"operations": [
- { "op": "replace", "path": "/basics/name", "value": "Jane Doe" },
- { "op": "replace", "path": "/basics/headline", "value": "Senior Software Engineer" }
+ { "op": "replace", "path": "/basics/name", "value": "David Kowalski" },
+ { "op": "replace", "path": "/basics/headline", "value": "Senior Game Developer" }
]
}'
```
### Add an experience entry
-Append a new item to the experience section:
+A new entry must be a complete item: include every required field of that item type, a unique `id` (a UUID) and `"hidden": false`. Set its dates in `dates` and leave `period` empty; the server fills it in.
```bash
-curl -X PATCH "https://rxresu.me/api/openapi/resume/YOUR_RESUME_ID" \
+curl -X PATCH "https://rxresu.me/api/openapi/resumes/YOUR_RESUME_ID" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"operations": [
{
"op": "add",
- "path": "/sections/experience/items/-",
+ "path": "/sections/experience/items/0",
"value": {
- "id": "a1b2c3d4-0000-0000-0000-000000000000",
+ "id": "019a0000-0000-7000-8000-000000000001",
"hidden": false,
- "company": "Acme Corp",
- "position": "Staff Engineer",
- "location": "San Francisco, CA",
- "period": "Jan 2024 - Present",
- "website": { "url": "https://acme.example.com", "label": "Acme Corp" },
- "description": "Leading the platform team.
"
+ "company": "Northwind Games",
+ "position": "Senior Gameplay Engineer",
+ "location": "Remote",
+ "period": "",
+ "dates": { "start": "2024-01", "end": null, "present": true },
+ "website": { "url": "", "label": "" },
+ "description": "Leading the combat systems team.
",
+ "roles": []
}
}
]
}'
```
-
- The path `/sections/experience/items/-` uses the special `-` index, which means "append to the end of the array". To
- insert at a specific position, use a numeric index like `/sections/experience/items/0` for the beginning.
-
+A numeric index inserts at that position (`0` puts the entry first). The special index `-` appends to the end, as in `/sections/skills/items/-`.
-### Remove an item from a section
+### Remove an entry
-Remove the second skill (index `1`) from the skills section:
+Remove the second skill:
-```bash
-curl -X PATCH "https://rxresu.me/api/openapi/resume/YOUR_RESUME_ID" \
- -H "x-api-key: YOUR_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "operations": [
- { "op": "remove", "path": "/sections/skills/items/1" }
- ]
- }'
+```json
+{ "operations": [{ "op": "remove", "path": "/sections/skills/items/1" }] }
```
-### Update metadata (template, colors, fonts)
+### Move an entry within a section
-Switch the template and update the primary color:
+Move the first experience entry to the third position:
-```bash
-curl -X PATCH "https://rxresu.me/api/openapi/resume/YOUR_RESUME_ID" \
- -H "x-api-key: YOUR_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "operations": [
- { "op": "replace", "path": "/metadata/template", "value": "bronzor" },
- { "op": "replace", "path": "/metadata/design/colors/primary", "value": "rgba(37, 99, 235, 1)" }
- ]
- }'
+```json
+{ "operations": [{ "op": "move", "from": "/sections/experience/items/0", "path": "/sections/experience/items/2" }] }
```
-### Test then replace (optimistic concurrency)
+### Change the design
-The `test` operation checks that a value matches before the rest of the patch runs. If the test fails, the whole patch is rejected, which keeps you from overwriting changes made by another client:
+Switch the template and the primary color:
-```bash
-curl -X PATCH "https://rxresu.me/api/openapi/resume/YOUR_RESUME_ID" \
- -H "x-api-key: YOUR_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "operations": [
- { "op": "test", "path": "/basics/name", "value": "Albert Einstein" },
- { "op": "replace", "path": "/basics/name", "value": "Jane Doe" }
- ]
- }'
+```json
+{
+ "operations": [
+ { "op": "replace", "path": "/metadata/template", "value": "bronzor" },
+ { "op": "replace", "path": "/metadata/design/colors/primary", "value": "rgba(37, 99, 235, 1)" }
+ ]
+}
```
-If `/basics/name` is not `"Albert Einstein"` at the time of the request, the entire patch will fail with a `400` error and no changes will be applied.
+### Hide a section
-### Move an item within a section
-
-Move the first experience item to the third position:
-
-```bash
-curl -X PATCH "https://rxresu.me/api/openapi/resume/YOUR_RESUME_ID" \
- -H "x-api-key: YOUR_API_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "operations": [
- { "op": "move", "from": "/sections/experience/items/0", "path": "/sections/experience/items/2" }
- ]
- }'
+```json
+{ "operations": [{ "op": "replace", "path": "/sections/interests/hidden", "value": true }] }
```
-## Error handling
+## Dates
-| Status | Error Code | Description |
-| ------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
-| `400` | `INVALID_PATCH_OPERATIONS` | The operations are structurally invalid, target a non-existent path, or produce resume data that fails schema validation. |
-| `401` | `UNAUTHORIZED` | Missing or invalid API key. |
-| `404` | `NOT_FOUND` | The resume does not exist or does not belong to the authenticated user. |
-| `403` | `RESUME_LOCKED` | The resume is locked and cannot be modified. Unlock it first. |
+Experience (and its roles), education, projects and volunteer entries, and awards, certifications and publications, have a structured `dates` object:
-
- All operations in a single request are applied atomically. If any operation fails (including a `test`), none of the
- operations are applied.
-
+```json
+"dates": { "start": "2022-03", "end": null, "present": true }
+```
-## Tips
+| Field | Value |
+| --- | --- |
+| `start` | A year (`"2022"`) or a year and month (`"2022-03"`), or `null`. Single-date entries (awards, certifications, publications) use only `start`. |
+| `end` | A year or year and month, or `null` while the entry is ongoing or for single dates. |
+| `present` | `true` when the entry is ongoing. It prints as "Present" in the resume's language. |
+| `raw` | Optional. Original text that couldn't be read exactly, such as "Summer 2016". It prints as written until the dates are edited. |
-- Fetch first, then patch. Use `GET /resume/{id}` to inspect the current structure before writing your operations, so you target the correct paths and array indices.
-- Use `test` for safety. When you expect a field to hold a specific value, combine `test` + `replace` so you don't overwrite a concurrent change.
-- Batch related changes. You can send multiple operations in one request. They are applied in order, so a later operation can depend on an earlier one.
-- The `-` index appends. When adding items to arrays, use `-` as the index (e.g., `/sections/skills/items/-`) to append to the end.
+**Write `dates`, not the text.** Each entry also has a text field, `period` (ranges) or `date` (single dates). On every save, the server rewrites that text from `dates`, in the resume's language and date format (`/metadata/page/dateFormat`: `short`, `long`, `numeric` or `iso`). A patch that changes only `period` or `date` is overwritten, and the resume keeps its old dates.
+
+To change an entry's dates, replace the whole object:
+
+```json
+{
+ "operations": [
+ {
+ "op": "replace",
+ "path": "/sections/experience/items/0/dates",
+ "value": { "start": "2021", "end": "2024-06", "present": false }
+ }
+ ]
+}
+```
+
+With the `long` date format, that entry's `period` becomes "2021 – June 2024". An entry sent without `dates` (for example from an older export) gets them read from its text.
+
+## Avoid overwriting other edits
+
+Someone might edit the resume in the browser between your read and your write. Two tools protect you:
+
+**`expectedUpdatedAt`** rejects the whole patch if the resume changed after you read it. Send the `updatedAt` value from your last read:
+
+```json
+{
+ "expectedUpdatedAt": "2026-09-30T00:10:08.197Z",
+ "operations": [{ "op": "replace", "path": "/basics/headline", "value": "Lead Game Developer" }]
+}
+```
+
+If the resume has moved on, you get `409` with the code `RESUME_VERSION_CONFLICT` and the current `updatedAt` in `data`. Read the resume again, rebuild your operations and retry.
+
+**`test`** checks a value before the other operations run. If it doesn't match, nothing is applied:
+
+```json
+{
+ "operations": [
+ { "op": "test", "path": "/basics/name", "value": "David Kowalski" },
+ { "op": "replace", "path": "/basics/name", "value": "Dave Kowalski" }
+ ]
+}
+```
+
+## What happens when a patch is applied
+
+- **All or nothing.** The operations run in order inside one transaction. If any operation fails, or the result doesn't match the resume schema, none of them are saved.
+- **Validation.** The patched resume must pass the same validation as any other save. For example, `/metadata/template` must be one of the available templates. Rich-text fields such as `description` and `/summary/content` are HTML strings, so send HTML (`…
`) rather than plain text or Markdown.
+- **History.** Each successful patch saves a version in the resume's history. It appears as **AI edit**, described as "from the assistant or API", and you can restore an earlier version from the editor. See [Undoing changes and version history](/guides/undoing-changes-and-version-history).
+- **Cover letters leave the resume.** If a patch adds a section of type `cover-letter`, the server saves its letter as a separate cover letter linked to the resume and removes the section. Manage letters with the `/cover-letters` endpoints instead.
+- **Public resumes update at once.** If the resume is public, the change is visible on its public page straight away.
+
+## Errors
+
+| Status | Code | Cause |
+| --- | --- | --- |
+| `400` | `BAD_REQUEST` | The body is malformed, for example `operations` is empty or an operation lacks `value` or `from`. |
+| `400` | `INVALID_PATCH_OPERATIONS` | An operation targets a path that doesn't exist, a `test` failed, or the result doesn't match the schema. `data` holds the failing `index`, the `operation` and a `code` such as `TEST_OPERATION_FAILED` or `OPERATION_PATH_UNRESOLVABLE`. |
+| `401` | `UNAUTHORIZED` | The API key is missing, revoked or expired. |
+| `404` | `NOT_FOUND` | The resume doesn't exist or belongs to someone else. |
+| `409` | `RESUME_VERSION_CONFLICT` | The resume changed after `expectedUpdatedAt`. |
+| — | `RESUME_LOCKED` | The resume is locked. Unlock it with `POST /resumes/{id}/lock` and `{ "isLocked": false }`, or in the app. |
+
+
+ A locked resume currently answers with HTTP status `500` rather than a `4xx` status. Check the `code` field for `RESUME_LOCKED` instead of relying on the status.
+
+
+## Related guides
+
+- [Using the API](/guides/using-the-api): keys, authentication and the other endpoints.
+- [JSON resume schema](/guides/json-resume-schema): every path and value type you can patch.
+- [Using the MCP server](/guides/using-the-mcp-server): let an AI client write these patches for you.
diff --git a/docs/guides/using-the-trash.mdx b/docs/guides/using-the-trash.mdx
new file mode 100644
index 000000000..3e5e9c95e
--- /dev/null
+++ b/docs/guides/using-the-trash.mdx
@@ -0,0 +1,75 @@
+---
+title: "Using the Trash"
+description: "Move resumes and cover letters to Trash in Reactive Resume, undo it, restore them within 30 days, or delete them for good."
+---
+
+When you remove a resume or cover letter, it goes to **Trash** instead of disappearing. It stays there for 30 days, so you can bring it back if you change your mind.
+
+## Move a document to Trash
+
+On Documents, open the document's **⋯** menu and choose **Move to Trash**. In the editor, open the document name menu at the top left and choose **Move to Trash**; you return to Documents.
+
+Reactive Resume doesn't ask you to confirm, because the move can be undone. A message appears at the bottom of the screen. Select **Undo** to put the document back straight away.
+
+
+
+
+
+While a resume is in Trash, its public link stops working. Restoring it brings the resume back as it was.
+
+
+ Locked documents can't be moved to Trash. Choose **Unlock** in the document's menu first.
+
+
+## Open the Trash
+
+Once Trash holds something, a **Trash** item with a count appears at the bottom of the sidebar. Select it to open the Trash page.
+
+
+
+
+
+You can also open the [command bar](/guides/using-the-command-bar) with ⌘ K (Ctrl K on Windows and Linux) and choose **Trash**, or go to `https://rxresu.me/dashboard/trash`. Self-hosters use their own address.
+
+The Trash page lists each document with its **Type**, linked **Application**, and how many days are left under **Deleted in**. When it's empty, it says **Trash is empty**.
+
+## Restore a document
+
+
+
+ On the Trash page, select **⋯** at the end of the document's row.
+
+
+
+
+
+
+ Select **Restore**. The document goes back to Documents with its content, tags and links intact.
+
+
+
+## Delete a document for good
+
+Documents are deleted permanently 30 days after you moved them to Trash. To delete one sooner:
+
+
+
+ On the Trash page, open the row's **⋯** menu and choose **Delete now…**.
+
+
+ Select **Delete now** to confirm, or **Keep in Trash** to change your mind.
+
+
+
+
+
+
+
+
+ A deleted document can't be restored, and neither can its version history. If you might need it again, download a JSON copy first (see [Exporting your resume](/guides/exporting-your-resume)).
+
+
+## Related guides
+
+- [Managing your documents](/guides/managing-documents): rename, duplicate and lock documents.
+- [Exporting your data](/guides/exporting-your-data): keep a copy of everything in your account.
diff --git a/docs/guides/viewing-application-insights.mdx b/docs/guides/viewing-application-insights.mdx
new file mode 100644
index 000000000..263b363dd
--- /dev/null
+++ b/docs/guides/viewing-application-insights.mdx
@@ -0,0 +1,52 @@
+---
+title: "Viewing application insights"
+description: "See how far your applications get, how often and how fast people reply, whether tailored resumes do better, and export a picture of your job search pipeline."
+---
+
+The **Insights** view turns your applications into a few simple numbers and charts. Use it to spot where things stall, for example lots of applications but few replies, and to see whether tailoring your resume is paying off.
+
+To open it, go to **Applications** and select the **Insights** tab. Insights counts all of your applications, including closed ones, whatever you've typed in the search field.
+
+
+ Insights appears once at least one application has reached **Applied**. Until then you see a short message instead.
+
+
+## Read the charts
+
+
+
+
+
+- **How far applications get**: For each stage from **Applied** to **Offer**, the number of applications that ever reached it. This uses each application's stage history, so an application that reached **Interview** and was later closed still counts for **Applied**, **Screening** and **Interview**.
+- **heard back**: The share of sent applications that got a reply. A reply means the application moved on to **Screening** or later, or was closed as **Not selected**.
+- **median to first reply**: The typical number of days between sending an application and that first reply. It uses the dates in each application's **Activity**, so correct those dates if you added applications after the fact.
+- **Tailored vs. base resume**: Compares applications sent with a resume made for that job (with **Tailor a resume** or **Copy for a job…**) against those sent with another resume. It only counts applications with a linked resume. With fewer than 10 of those, it reminds you the numbers are small.
+
+## The pipeline picture
+
+**Where your applications went** draws your pipeline as a flow from **Saved** to **Offer**. The number above each bar is how many of your current, not-closed applications are at that stage or beyond, and the percentage under it is how many made it on from the stage before. The top line shows how many applications you're tracking in total, and how many are closed.
+
+
+
+
+
+Select **Export PNG** to download the picture as `pipeline-flow.png`, a high-resolution image with a small Reactive Resume mark in the corner. It's made for sharing, for example with a mentor or career coach.
+
+
+ The pipeline picture counts only applications that aren't closed, while **How far applications get** includes closed ones. That's why their numbers can differ.
+
+
+## Weekly activity and sources
+
+
+
+
+
+- **Applications over time**: How many applications you applied for each week over the last 8 weeks. Saved applications count in the week you added them, and closed applications aren't included.
+- **Where applications come from**: How many applications have each **Source**, such as LinkedIn or Referral. Fill in the source on each application to make this useful. Applications without a source aren't shown.
+
+## Related guides
+
+- [Managing an application](/guides/managing-an-application): fix stage dates and sources so the numbers are right.
+- [Tailoring a resume for a job](/guides/tailoring-a-resume-for-a-job): make tailored copies to compare against your base resume.
+- [Tracking job applications](/guides/tracking-job-applications): the other views.
diff --git a/docs/guides/whats-new-in-v6.mdx b/docs/guides/whats-new-in-v6.mdx
new file mode 100644
index 000000000..8266cbcec
--- /dev/null
+++ b/docs/guides/whats-new-in-v6.mdx
@@ -0,0 +1,175 @@
+---
+title: "What's new in v6"
+description: "A guide for returning users: how Reactive Resume v6 reorganizes documents, the editor, sharing, the assistant, cover letters, applications and settings."
+---
+
+Reactive Resume v6 is a full redesign. Your resumes, letters and applications carry over, but many things live in new
+places. This page explains what changed from v5 and where to find what you used before.
+
+
+
+
+
+## At a glance
+
+| In v5 | In v6 |
+| --- | --- |
+| Separate **Resumes** and **Cover Letters** dashboards | One **Documents** page with **All**, **Resumes** and **Letters** tabs |
+| Builder with left and right sidebars and a dock | Editor with three modes: **Write**, **Design** and **Check** |
+| Export panel, sharing section and version history | One **Share & export** panel with **Link**, **Download** and **History** tabs |
+| **Agents** page and the builder's AI sheet | The **Assistant** panel inside the editor (⌘ J) |
+| Cover letters inside resumes, or in their own library | Cover letters are documents of their own |
+| Applications table, board and insights | Applications **List**, **Board**, **Insights** and **Calendar** |
+| Dates typed as free text | Structured dates: a month and year, or just a year |
+| Six settings pages | **Settings** with **Account**, **Preferences** and **AI & developer** |
+
+## Documents replaces the dashboards
+
+The **Resumes** and **Cover Letters** pages are now one page, **Documents**. Use the tabs to show everything, only
+resumes or only letters, search with /, sort by **Last edited**, and switch between a grid and a list.
+Old links to the resume and letter dashboards still open **Documents**.
+
+Everything you create starts from **New** in the sidebar (or press N). The **New document** dialog offers
+**Import a resume**, **Copy a resume for a job**, **Start blank**, **New cover letter instead** and **Try with a sample
+resume**. You can also drop a file anywhere on **Documents** to import it.
+
+Deleting a document now moves it to **Trash**, with an undo. Documents stay in Trash for 30 days, and you can restore
+them or delete them for good before then. The **Trash** link appears in the sidebar only while Trash has something in
+it.
+
+Learn more in [Managing documents](/guides/managing-documents) and [Using the Trash](/guides/using-the-trash).
+
+## The editor has three modes
+
+The builder's sidebars, dock and panels are replaced by one editor with three modes. Switch with the control at the top
+of the editor, or press 1, 2 or 3.
+
+- **Write** holds your content: the **Basics** card (name, photo, contact details and custom fields), then your
+ sections in print order. Each section and entry has a **⋯** menu for options such as hiding, duplicating or moving
+ it. You can also select a block on the page to jump to it in the panel.
+- **Design** holds the look: **Template**, **Type**, **Color**, **Page** and **Advanced** (custom fonts, the date
+ format, custom CSS and more). The template gallery shows all 15 templates filled with your own content.
+- **Check** reviews your resume. It lists issues pinned to the lines they belong to and gives a readability score, a
+ **Job match** against a posting, and an AI **Writing** review. It replaces the ATS section of the old builder; the
+ public [ATS checker](/guides/using-the-ats-checker) page is still available.
+
+The document name at the top left opens the document menu: **Rename…**, **Duplicate**, **Lock editing**, **Notes**,
+**Details**, **Print** and **Move to Trash**. Private notes and printing moved here.
+
+Learn more in [Editor overview](/guides/editor-overview) and [Checking your resume](/guides/checking-your-resume).
+
+## Sharing, downloads and history live in one place
+
+**Share** in the editor bar opens the **Share & export** panel. It has three tabs:
+
+- **Link** turns your public link on or off, sets its address, can **Require a password**, and shows views and
+ downloads for the last 30 days. The public address moved here from the old "edit details" dialog.
+- **Download** saves a **PDF**, **Word**, **Markdown** or **JSON** file, with a file name you can change.
+- **History** lists earlier versions of your document. You can name a version (for example, the one you sent to a
+ company), preview it and restore it. Restoring no longer asks for confirmation, because it can be undone: your
+ state before the restore is kept as a version called **Before restore**.
+
+**Download PDF** in the editor bar downloads right away, and ⌘ P now downloads the PDF instead of
+opening the print dialog. To print, use **Print** in the document menu.
+
+Learn more in [Sharing your resume publicly](/guides/sharing-your-resume-publicly),
+[Exporting your resume](/guides/exporting-your-resume) and
+[Undoing changes and version history](/guides/undoing-changes-and-version-history).
+
+## The Assistant replaces Agents
+
+The separate **Agents** page is gone. The AI assistant now opens as a panel beside the page in the editor, for resumes
+and letters alike. Open it with the sparkle button in the editor bar or ⌘ J.
+
+The biggest change: the assistant never edits your document directly. It proposes edits, showing the old text struck
+through, the new text and why, and you accept or reject each one. It can ask you a clarifying question
+when a request is unclear. Your earlier conversations are under **Past conversations** in the panel, and old Agents
+links open the right document with the conversation showing.
+
+The assistant, **Improve** on a line of text, and Check's **Writing** review use an AI provider you connect yourself in
+**Settings → AI & developer**.
+
+Learn more in [Connecting an AI provider](/guides/using-ai) and [Using the assistant](/guides/using-the-assistant).
+
+## Cover letters are documents of their own
+
+In v5, a cover letter could be a section inside a resume. In v6, every letter is its own document in **Documents**,
+with its own editor (**Write** and **Design** modes), downloads and history. A letter can be linked to a resume, and it
+then uses that resume's contact details and design unless you give the letter its own design.
+
+When your account moved to v6, every cover-letter section in your resumes was saved as a separate letter linked to that
+resume, named after the resume and the section. The resumes themselves no longer contain letters. Letters don't have
+public links.
+
+Learn more in [Writing a cover letter](/guides/writing-a-cover-letter).
+
+## Applications got a redesign
+
+The job tracker now has four views: **List**, **Board**, **Insights** and **Calendar**. On phones, the list is used
+in place of the board and calendar.
+
+- **Add application** starts from a job link or a pasted posting.
+- Each application moves through **Saved**, **Applied**, **Screening**, **Interview** and **Offer**. When it ends, you
+ close it with a reason instead of marking it rejected or archiving it. Use **Show closed** to see closed ones.
+- Selecting an application opens its detail sheet with the stage, the next step (interviews and follow-ups), what you
+ sent, notes, contacts and activity.
+- The **Calendar** view shows your interviews. **Add to calendar** on an interview saves it as an `.ics` file for your
+ own calendar app.
+- From an application, you can tailor a resume or write a letter for that job.
+
+Learn more in [Tracking job applications](/guides/tracking-job-applications) and
+[Tailoring a resume for a job](/guides/tailoring-a-resume-for-a-job).
+
+## Dates are structured
+
+Dates used to be free text. Now each date is a month and year (such as Mar 2022) or just a year, and ongoing entries
+use **Present**. Because Reactive Resume understands the dates, it can sort entries and check them, and every date on
+your resume prints the same way. Choose how they print in **Design → Advanced → Date format**: Mar 2022, March 2022,
+03/2022 or 2022-03.
+
+Your existing dates were converted automatically. Anything that couldn't be read exactly, such as "Summer 2016", still
+prints as you wrote it until you edit that date.
+
+Learn more in [Entering dates](/guides/entering-dates).
+
+## A new PDF engine that runs in your browser
+
+The page you see in the editor is the real PDF, rendered in your browser as you type. Downloads are made in your
+browser too, so they match the preview exactly and don't wait for a server. The engine runs in the background, so
+typing stays responsive even on long resumes.
+
+## Settings are consolidated
+
+The six settings pages are now three. To open them, select your name at the bottom of the sidebar, then **Settings**:
+
+- **Account**: your profile and photo, password, two-step verification, passkeys and linked sign-in methods, exporting
+ all your data, and deleting your account. **Sign out** is here too.
+- **Preferences**: appearance (**Light**, **Dark**, or following your system), language and motion.
+- **AI & developer**: AI providers, API keys and the MCP server.
+
+Old settings links open the matching new page.
+
+Learn more in [Updating your profile](/guides/updating-your-profile) and
+[Changing appearance and language](/guides/changing-appearance-and-language).
+
+## Faster ways to get around
+
+Press ⌘ K (Ctrl K on Windows and Linux) anywhere to open the command bar.
+Search your resumes, applications and assistant conversations, jump to a page, change the theme or language, or type a
+question to ask the assistant. See [Using the command bar](/guides/using-the-command-bar) and the full list of
+[keyboard shortcuts](/guides/keyboard-shortcuts).
+
+## What was removed
+
+A few v5 features didn't make it into v6:
+
+- The compact view on the resume dashboard (grid and list remain) and the collapsible sidebar.
+- Automatically applied AI edits. Every assistant edit is now a proposal you review.
+- Adding a cover letter inside a resume, and the cover-letter tab in a resume's downloads. Use a separate letter.
+- Archiving assistant conversations and showing token counts.
+- The fit score stored on applications.
+
+## If you self-host
+
+Upgrading your own server from v5 to v6 involves database migrations and a few API changes. Follow
+[Upgrading to v6](/self-hosting/upgrading-to-v6) before you update.
diff --git a/docs/guides/writing-a-cover-letter.mdx b/docs/guides/writing-a-cover-letter.mdx
new file mode 100644
index 000000000..646f3baaf
--- /dev/null
+++ b/docs/guides/writing-a-cover-letter.mdx
@@ -0,0 +1,190 @@
+---
+title: "Writing a cover letter"
+description: "Create a cover letter as its own document, link it to a resume and a job application, match your resume's design, and download it."
+---
+
+In Reactive Resume, a cover letter is a document of its own. It sits next to your resumes in **Documents**, under the **Letters** tab. A letter can take your name, contact details and design from one of your resumes, and its recipient from a job application, so you only write the body.
+
+## Create a letter
+
+You can start a letter from an application or from **Documents**.
+
+
+
+ Starting from the job you're applying for is quickest, because the letter is set up for that job.
+
+
+
+ In **Applications**, select the application to open its details.
+
+
+ Under **What you sent**, select **Write a letter**. The button shows only while the application has no letter.
+
+
+
+
+
+
+
+ The new letter is named after the company, for example "Cover letter — Northwind Games". It becomes this application's letter, takes its recipient from the application (the company, and the first contact's name if there is one), and takes your details and design from the application's resume, if it has one.
+
+
+
+
+ In **Documents**, select **New**, or press N.
+
+
+ At the bottom of the dialog, select **New cover letter instead**.
+
+
+
+
+
+
+
+ The new letter, named "Untitled letter", takes its details and design from the resume you edited most recently. You can link an application and change the resume in the editor.
+
+
+
+To bring back a letter you downloaded as JSON from Reactive Resume, import the file from the **New** dialog as you would a resume; it comes back as a letter. See [Importing resumes](/guides/importing-resumes).
+
+## Fill in the letter
+
+The letter editor looks like the resume editor, with two modes: **Write** and **Design**. The panel on the left holds the letter's details, and the page on the right shows the letter as it will print, updating as you type.
+
+
+
+
+
+
+
+ Under **For**, select **Link an application** to choose the job the letter is for, or **Change** to pick another. Closed applications aren't listed. Linking fills the recipient's company and name. Choose **No application** to unlink it. Without an application, you fill in the recipient yourself.
+
+
+ Under **To**, check **Name or team**, **Company** and **Date**. The greeting follows the name: "Dear Priya," for Priya Shah, or a greeting to the hiring team when the name is empty.
+
+
+ Under **From**, choose the **Resume** your details come from. With **Use details from** that resume turned on, your name, headline, email, phone and location stay linked: when you change them on the resume, the letter changes too, and tells you the next time you open it.
+
+ Turn the switch off to keep a copy of your details in the letter instead. The copy doesn't change when the resume does.
+
+
+
+
+
+
+ Under **Letter**, select **Write it myself** and type the body. The greeting and sign-off are added around it for you. The editor has the same formatting tools as the resume; see [Formatting text](/guides/formatting-text).
+
+
+
+Below the body, **Length** counts your words against the 180 to 320 words most recruiters read, and says whether the letter is short, comfortable or getting long. It's a guide, not a rule.
+
+
+
+
+
+Changes save automatically. The status under the letter's name in the editor bar shows **Saving…**, **Saved** or, if something went wrong, **Not saved** with **Retry**.
+
+## Draft the body with AI
+
+If you've connected an AI provider, an empty body offers a first draft. See [Connecting an AI provider](/guides/using-ai) to set one up.
+
+
+
+
+
+
+
+ Select **Draft from the posting** when the letter is linked to an application, or **Draft from your resume** when it only has a resume. The draft uses only what the letter is linked to: the posting, your resume, or both. Select **Stop** to cancel it while it's being written.
+
+
+ When the draft is ready, ask for **Shorter** or **More personal** versions, or select **Discard** to drop it.
+
+
+ Select **Keep** to put the draft in the body. Until you do, the draft never replaces anything, and you can edit it freely afterwards.
+
+
+
+To draft, a letter needs a linked resume or application. You can also open the Assistant with ⌘ J (Ctrl J on Windows and Linux) to rework the letter; see [Using the Assistant](/guides/using-the-assistant).
+
+## Match your resume's design
+
+By default, a letter linked to a resume uses the resume's template, type and colors, so the two always look like a pair. When you change the resume's design, the letter follows.
+
+
+
+ Select **Design** in the editor bar, or press 2.
+
+
+ With **Match** turned on, the letter follows the resume. Select **Change the resume's design** to open the resume in **Design**.
+
+
+
+
+
+ Turn **Match** off to give the letter a design of its own. It keeps the current design as a starting point, and **Template**, **Type**, **Color** and **Page** settings appear for the letter alone. Hover over a template to preview it on the page.
+
+
+
+
+
+
+
+Turn **Match** back on at any time to follow the resume again. A letter with no resume chosen under **From** always has its own design.
+
+## Download the letter
+
+Select **Download PDF** in the editor bar, or press ⌘ P (Ctrl P). The PDF includes the header with your name and contact details, and is named after you, for example `David-Kowalski-Cover-Letter.pdf`.
+
+For other formats, select **Share**, or press ⌘ Shift E (Ctrl Shift E). The **Share & export** sheet opens on **Download**:
+
+
+
+
+
+| Option | What you get |
+| --- | --- |
+| **PDF** | The letter exactly as it looks on the page. Best for applying. |
+| **Resume + letter** | The linked resume and this letter as two PDFs, named to match. Shows only when the letter has a resume. |
+| **Word** | A `.docx` file with simplified layout, for portals that ask for Word. |
+| **Markdown** | Plain text with headings, to paste into application forms. |
+| **JSON** | A backup of the letter that imports back into Reactive Resume as a letter. |
+
+Edit **File name** if you like, then select the download button.
+
+Letters have no public link. To share one, download it and attach the file.
+
+When you download a resume from its own Share sheet and the resume's application has a letter, you can also tick **Also download the … cover letter** (the label names the company) to get both files at once. See [Exporting your resume](/guides/exporting-your-resume).
+
+## Letters and applications
+
+- Each application has one letter. If you link a letter to an application that already has one, the new letter takes its place.
+- When the application moves to **Applied** or a later stage, the letter as it reads then is saved in its **History** as the version you sent. The application shows it under **What you sent** as "Version sent" with the date.
+- In **Documents**, a letter's menu has **Link to application…** to link or unlink it without opening it.
+
+To see earlier versions of a letter, open **History** in the editor bar (the clock icon) or the **History** tab in **Share & export**. It works like resume history; see [Undoing changes and version history](/guides/undoing-changes-and-version-history).
+
+## Manage a letter
+
+Select the letter's name in the editor bar to **Rename…**, **Duplicate**, **Lock editing** or **Move to Trash**. A locked letter can't be edited until you select **Unlock editing**. A letter in Trash can be restored for 30 days; see [Using the trash](/guides/using-the-trash).
+
+
+ In earlier versions, a cover letter could be a section inside a resume. Those letters are now letters of their own in **Documents**, named after the resume and the letter (for example "Frontend Resume — Cover Letter"), still linked to the resume's details and design. The resume no longer contains them. These older letters keep their recipient block as you wrote it under **To**, instead of separate name, company and date fields.
+
+
+## Related guides
+
+
+
+ Copy a resume for an application and write its letter.
+
+
+ Track stages, what you sent and next steps.
+
+
+ Pick the design your resume and letter share.
+
+
+ Make sure software can read the resume you send with your letter.
+
+
diff --git a/docs/images/getting-started/banner.webp b/docs/images/getting-started/banner.webp
deleted file mode 100644
index 3eb7506f7..000000000
Binary files a/docs/images/getting-started/banner.webp and /dev/null differ
diff --git a/docs/images/getting-started/editor-overview.webp b/docs/images/getting-started/editor-overview.webp
new file mode 100644
index 000000000..deb036638
Binary files /dev/null and b/docs/images/getting-started/editor-overview.webp differ
diff --git a/docs/images/getting-started/infographic.webp b/docs/images/getting-started/infographic.webp
deleted file mode 100644
index 40cf10009..000000000
Binary files a/docs/images/getting-started/infographic.webp and /dev/null differ
diff --git a/docs/images/getting-started/quickstart/basics-and-page.webp b/docs/images/getting-started/quickstart/basics-and-page.webp
new file mode 100644
index 000000000..595903a68
Binary files /dev/null and b/docs/images/getting-started/quickstart/basics-and-page.webp differ
diff --git a/docs/images/getting-started/quickstart/design-template-gallery.webp b/docs/images/getting-started/quickstart/design-template-gallery.webp
new file mode 100644
index 000000000..bb3f47627
Binary files /dev/null and b/docs/images/getting-started/quickstart/design-template-gallery.webp differ
diff --git a/docs/images/getting-started/quickstart/documents-empty.webp b/docs/images/getting-started/quickstart/documents-empty.webp
new file mode 100644
index 000000000..40b6544cf
Binary files /dev/null and b/docs/images/getting-started/quickstart/documents-empty.webp differ
diff --git a/docs/images/getting-started/quickstart/editor-bar-actions.webp b/docs/images/getting-started/quickstart/editor-bar-actions.webp
new file mode 100644
index 000000000..e57ce874f
Binary files /dev/null and b/docs/images/getting-started/quickstart/editor-bar-actions.webp differ
diff --git a/docs/images/getting-started/quickstart/share-download-tab.webp b/docs/images/getting-started/quickstart/share-download-tab.webp
new file mode 100644
index 000000000..f68ff25f8
Binary files /dev/null and b/docs/images/getting-started/quickstart/share-download-tab.webp differ
diff --git a/docs/images/guides/adding-an-application/add-an-application-dialog.webp b/docs/images/guides/adding-an-application/add-an-application-dialog.webp
new file mode 100644
index 000000000..88e4c9bbf
Binary files /dev/null and b/docs/images/guides/adding-an-application/add-an-application-dialog.webp differ
diff --git a/docs/images/guides/ai-agent-tools/tool-status-in-conversation.webp b/docs/images/guides/ai-agent-tools/tool-status-in-conversation.webp
new file mode 100644
index 000000000..d6f7e92d1
Binary files /dev/null and b/docs/images/guides/ai-agent-tools/tool-status-in-conversation.webp differ
diff --git a/docs/images/guides/applying-custom-styles/custom-styles-editor.webp b/docs/images/guides/applying-custom-styles/custom-styles-editor.webp
new file mode 100644
index 000000000..cc00e657c
Binary files /dev/null and b/docs/images/guides/applying-custom-styles/custom-styles-editor.webp differ
diff --git a/docs/images/guides/applying-custom-styles/find-an-entry-by-name.webp b/docs/images/guides/applying-custom-styles/find-an-entry-by-name.webp
new file mode 100644
index 000000000..1c7c21500
Binary files /dev/null and b/docs/images/guides/applying-custom-styles/find-an-entry-by-name.webp differ
diff --git a/docs/images/guides/applying-custom-styles/pick-an-entry-on-the-page.webp b/docs/images/guides/applying-custom-styles/pick-an-entry-on-the-page.webp
new file mode 100644
index 000000000..e00bf652d
Binary files /dev/null and b/docs/images/guides/applying-custom-styles/pick-an-entry-on-the-page.webp differ
diff --git a/docs/images/guides/arranging-the-layout/advanced-layout-page.webp b/docs/images/guides/arranging-the-layout/advanced-layout-page.webp
new file mode 100644
index 000000000..279a02fde
Binary files /dev/null and b/docs/images/guides/arranging-the-layout/advanced-layout-page.webp differ
diff --git a/docs/images/guides/arranging-the-layout/layout-move-to-menu.webp b/docs/images/guides/arranging-the-layout/layout-move-to-menu.webp
new file mode 100644
index 000000000..33f456930
Binary files /dev/null and b/docs/images/guides/arranging-the-layout/layout-move-to-menu.webp differ
diff --git a/docs/images/guides/arranging-the-layout/section-columns-menu.webp b/docs/images/guides/arranging-the-layout/section-columns-menu.webp
new file mode 100644
index 000000000..63ef54af6
Binary files /dev/null and b/docs/images/guides/arranging-the-layout/section-columns-menu.webp differ
diff --git a/docs/images/guides/arranging-the-layout/sidebar-panel.webp b/docs/images/guides/arranging-the-layout/sidebar-panel.webp
new file mode 100644
index 000000000..ae22d279e
Binary files /dev/null and b/docs/images/guides/arranging-the-layout/sidebar-panel.webp differ
diff --git a/docs/images/guides/arranging-the-layout/write-outline-pages.webp b/docs/images/guides/arranging-the-layout/write-outline-pages.webp
new file mode 100644
index 000000000..a49db5a8d
Binary files /dev/null and b/docs/images/guides/arranging-the-layout/write-outline-pages.webp differ
diff --git a/docs/images/guides/changing-appearance-and-language/account-menu-theme.webp b/docs/images/guides/changing-appearance-and-language/account-menu-theme.webp
new file mode 100644
index 000000000..932139f7f
Binary files /dev/null and b/docs/images/guides/changing-appearance-and-language/account-menu-theme.webp differ
diff --git a/docs/images/guides/changing-appearance-and-language/preferences-page.webp b/docs/images/guides/changing-appearance-and-language/preferences-page.webp
new file mode 100644
index 000000000..3a74066d2
Binary files /dev/null and b/docs/images/guides/changing-appearance-and-language/preferences-page.webp differ
diff --git a/docs/images/guides/checking-your-resume/check-mode-overview.webp b/docs/images/guides/checking-your-resume/check-mode-overview.webp
new file mode 100644
index 000000000..6bcaedf72
Binary files /dev/null and b/docs/images/guides/checking-your-resume/check-mode-overview.webp differ
diff --git a/docs/images/guides/checking-your-resume/checks-by-category.webp b/docs/images/guides/checking-your-resume/checks-by-category.webp
new file mode 100644
index 000000000..43734ceb3
Binary files /dev/null and b/docs/images/guides/checking-your-resume/checks-by-category.webp differ
diff --git a/docs/images/guides/checking-your-resume/exported-pdf-pin.webp b/docs/images/guides/checking-your-resume/exported-pdf-pin.webp
new file mode 100644
index 000000000..331b53fb9
Binary files /dev/null and b/docs/images/guides/checking-your-resume/exported-pdf-pin.webp differ
diff --git a/docs/images/guides/checking-your-resume/exported-pdf-report.webp b/docs/images/guides/checking-your-resume/exported-pdf-report.webp
new file mode 100644
index 000000000..4bb4080a4
Binary files /dev/null and b/docs/images/guides/checking-your-resume/exported-pdf-report.webp differ
diff --git a/docs/images/guides/checking-your-resume/issue-pinned-on-page.webp b/docs/images/guides/checking-your-resume/issue-pinned-on-page.webp
new file mode 100644
index 000000000..d33dbb8e9
Binary files /dev/null and b/docs/images/guides/checking-your-resume/issue-pinned-on-page.webp differ
diff --git a/docs/images/guides/checking-your-resume/job-match-linked-application.webp b/docs/images/guides/checking-your-resume/job-match-linked-application.webp
new file mode 100644
index 000000000..f44048ed7
Binary files /dev/null and b/docs/images/guides/checking-your-resume/job-match-linked-application.webp differ
diff --git a/docs/images/guides/checking-your-resume/job-match-pasted-posting.webp b/docs/images/guides/checking-your-resume/job-match-pasted-posting.webp
new file mode 100644
index 000000000..b92df1089
Binary files /dev/null and b/docs/images/guides/checking-your-resume/job-match-pasted-posting.webp differ
diff --git a/docs/images/guides/checking-your-resume/parser-view.webp b/docs/images/guides/checking-your-resume/parser-view.webp
new file mode 100644
index 000000000..6859ec50d
Binary files /dev/null and b/docs/images/guides/checking-your-resume/parser-view.webp differ
diff --git a/docs/images/guides/checking-your-resume/score-and-issues.webp b/docs/images/guides/checking-your-resume/score-and-issues.webp
new file mode 100644
index 000000000..9dd58f72b
Binary files /dev/null and b/docs/images/guides/checking-your-resume/score-and-issues.webp differ
diff --git a/docs/images/guides/checking-your-resume/writing-review-start.webp b/docs/images/guides/checking-your-resume/writing-review-start.webp
new file mode 100644
index 000000000..bd33bd79d
Binary files /dev/null and b/docs/images/guides/checking-your-resume/writing-review-start.webp differ
diff --git a/docs/images/guides/choosing-a-template/screenshot-1.webp b/docs/images/guides/choosing-a-template/screenshot-1.webp
deleted file mode 100644
index 061fabebe..000000000
Binary files a/docs/images/guides/choosing-a-template/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/choosing-a-template/screenshot-2.webp b/docs/images/guides/choosing-a-template/screenshot-2.webp
deleted file mode 100644
index a8cddedde..000000000
Binary files a/docs/images/guides/choosing-a-template/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/choosing-a-template/screenshot-3.webp b/docs/images/guides/choosing-a-template/screenshot-3.webp
deleted file mode 100644
index 289ebf08a..000000000
Binary files a/docs/images/guides/choosing-a-template/screenshot-3.webp and /dev/null differ
diff --git a/docs/images/guides/choosing-a-template/screenshot-4.webp b/docs/images/guides/choosing-a-template/screenshot-4.webp
deleted file mode 100644
index 0c50b428c..000000000
Binary files a/docs/images/guides/choosing-a-template/screenshot-4.webp and /dev/null differ
diff --git a/docs/images/guides/choosing-a-template/template-gallery.webp b/docs/images/guides/choosing-a-template/template-gallery.webp
new file mode 100644
index 000000000..e3aa61f91
Binary files /dev/null and b/docs/images/guides/choosing-a-template/template-gallery.webp differ
diff --git a/docs/images/guides/choosing-a-template/template-hover-preview.webp b/docs/images/guides/choosing-a-template/template-hover-preview.webp
new file mode 100644
index 000000000..288f915ca
Binary files /dev/null and b/docs/images/guides/choosing-a-template/template-hover-preview.webp differ
diff --git a/docs/images/guides/choosing-colors/advanced-colors-and-level.webp b/docs/images/guides/choosing-colors/advanced-colors-and-level.webp
new file mode 100644
index 000000000..136d2e032
Binary files /dev/null and b/docs/images/guides/choosing-colors/advanced-colors-and-level.webp differ
diff --git a/docs/images/guides/choosing-colors/color-group.webp b/docs/images/guides/choosing-colors/color-group.webp
new file mode 100644
index 000000000..f2e5f813e
Binary files /dev/null and b/docs/images/guides/choosing-colors/color-group.webp differ
diff --git a/docs/images/guides/choosing-colors/contrast-warning.webp b/docs/images/guides/choosing-colors/contrast-warning.webp
new file mode 100644
index 000000000..20a0f8614
Binary files /dev/null and b/docs/images/guides/choosing-colors/contrast-warning.webp differ
diff --git a/docs/images/guides/creating-an-account/check-your-email.webp b/docs/images/guides/creating-an-account/check-your-email.webp
new file mode 100644
index 000000000..032438ef2
Binary files /dev/null and b/docs/images/guides/creating-an-account/check-your-email.webp differ
diff --git a/docs/images/guides/creating-an-account/sign-up-form.webp b/docs/images/guides/creating-an-account/sign-up-form.webp
new file mode 100644
index 000000000..e08cf44d6
Binary files /dev/null and b/docs/images/guides/creating-an-account/sign-up-form.webp differ
diff --git a/docs/images/guides/creating-your-first-resume/documents-first-visit.webp b/docs/images/guides/creating-your-first-resume/documents-first-visit.webp
new file mode 100644
index 000000000..277c0ff08
Binary files /dev/null and b/docs/images/guides/creating-your-first-resume/documents-first-visit.webp differ
diff --git a/docs/images/guides/creating-your-first-resume/download-pdf-button.webp b/docs/images/guides/creating-your-first-resume/download-pdf-button.webp
new file mode 100644
index 000000000..2921c8e55
Binary files /dev/null and b/docs/images/guides/creating-your-first-resume/download-pdf-button.webp differ
diff --git a/docs/images/guides/creating-your-first-resume/editor-with-basics.webp b/docs/images/guides/creating-your-first-resume/editor-with-basics.webp
new file mode 100644
index 000000000..cc3b3faa5
Binary files /dev/null and b/docs/images/guides/creating-your-first-resume/editor-with-basics.webp differ
diff --git a/docs/images/guides/creating-your-first-resume/first-entry.webp b/docs/images/guides/creating-your-first-resume/first-entry.webp
new file mode 100644
index 000000000..0b0d5df7b
Binary files /dev/null and b/docs/images/guides/creating-your-first-resume/first-entry.webp differ
diff --git a/docs/images/guides/creating-your-first-resume/new-document-dialog.webp b/docs/images/guides/creating-your-first-resume/new-document-dialog.webp
new file mode 100644
index 000000000..aeff319a6
Binary files /dev/null and b/docs/images/guides/creating-your-first-resume/new-document-dialog.webp differ
diff --git a/docs/images/guides/creating-your-first-resume/screenshot-1.webp b/docs/images/guides/creating-your-first-resume/screenshot-1.webp
deleted file mode 100644
index b3f70966b..000000000
Binary files a/docs/images/guides/creating-your-first-resume/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/creating-your-first-resume/starter-sections.webp b/docs/images/guides/creating-your-first-resume/starter-sections.webp
new file mode 100644
index 000000000..4a39e0e6c
Binary files /dev/null and b/docs/images/guides/creating-your-first-resume/starter-sections.webp differ
diff --git a/docs/images/guides/customizing-typography/advanced-typography.webp b/docs/images/guides/customizing-typography/advanced-typography.webp
new file mode 100644
index 000000000..e5ef6d8cc
Binary files /dev/null and b/docs/images/guides/customizing-typography/advanced-typography.webp differ
diff --git a/docs/images/guides/customizing-typography/font-family-search.webp b/docs/images/guides/customizing-typography/font-family-search.webp
new file mode 100644
index 000000000..dfc25ea11
Binary files /dev/null and b/docs/images/guides/customizing-typography/font-family-search.webp differ
diff --git a/docs/images/guides/customizing-typography/type-group.webp b/docs/images/guides/customizing-typography/type-group.webp
new file mode 100644
index 000000000..dea31393e
Binary files /dev/null and b/docs/images/guides/customizing-typography/type-group.webp differ
diff --git a/docs/images/guides/deleting-your-account/delete-account-dialog.webp b/docs/images/guides/deleting-your-account/delete-account-dialog.webp
new file mode 100644
index 000000000..d8f924d1c
Binary files /dev/null and b/docs/images/guides/deleting-your-account/delete-account-dialog.webp differ
diff --git a/docs/images/guides/editing-entries/entry-move-to-menu.webp b/docs/images/guides/editing-entries/entry-move-to-menu.webp
new file mode 100644
index 000000000..d61b56ab1
Binary files /dev/null and b/docs/images/guides/editing-entries/entry-move-to-menu.webp differ
diff --git a/docs/images/guides/editing-entries/experience-roles.webp b/docs/images/guides/editing-entries/experience-roles.webp
new file mode 100644
index 000000000..f436deeb5
Binary files /dev/null and b/docs/images/guides/editing-entries/experience-roles.webp differ
diff --git a/docs/images/guides/editing-entries/new-draft-entry.webp b/docs/images/guides/editing-entries/new-draft-entry.webp
new file mode 100644
index 000000000..8770d922f
Binary files /dev/null and b/docs/images/guides/editing-entries/new-draft-entry.webp differ
diff --git a/docs/images/guides/editing-entries/roles-on-page.webp b/docs/images/guides/editing-entries/roles-on-page.webp
new file mode 100644
index 000000000..a509ba92a
Binary files /dev/null and b/docs/images/guides/editing-entries/roles-on-page.webp differ
diff --git a/docs/images/guides/editing-entries/skill-more-options.webp b/docs/images/guides/editing-entries/skill-more-options.webp
new file mode 100644
index 000000000..e644b11ce
Binary files /dev/null and b/docs/images/guides/editing-entries/skill-more-options.webp differ
diff --git a/docs/images/guides/editing-on-mobile/phone-design-sheet.webp b/docs/images/guides/editing-on-mobile/phone-design-sheet.webp
new file mode 100644
index 000000000..a552e41eb
Binary files /dev/null and b/docs/images/guides/editing-on-mobile/phone-design-sheet.webp differ
diff --git a/docs/images/guides/editing-on-mobile/phone-entry-screen.webp b/docs/images/guides/editing-on-mobile/phone-entry-screen.webp
new file mode 100644
index 000000000..e207f2db6
Binary files /dev/null and b/docs/images/guides/editing-on-mobile/phone-entry-screen.webp differ
diff --git a/docs/images/guides/editing-on-mobile/phone-page-edit-entry.webp b/docs/images/guides/editing-on-mobile/phone-page-edit-entry.webp
new file mode 100644
index 000000000..73e795d7e
Binary files /dev/null and b/docs/images/guides/editing-on-mobile/phone-page-edit-entry.webp differ
diff --git a/docs/images/guides/editing-on-mobile/phone-write-view.webp b/docs/images/guides/editing-on-mobile/phone-write-view.webp
new file mode 100644
index 000000000..824b26332
Binary files /dev/null and b/docs/images/guides/editing-on-mobile/phone-write-view.webp differ
diff --git a/docs/images/guides/editing-on-mobile/tablet-panel-drawer.webp b/docs/images/guides/editing-on-mobile/tablet-panel-drawer.webp
new file mode 100644
index 000000000..3afe86040
Binary files /dev/null and b/docs/images/guides/editing-on-mobile/tablet-panel-drawer.webp differ
diff --git a/docs/images/guides/editor-overview/document-menu.webp b/docs/images/guides/editor-overview/document-menu.webp
new file mode 100644
index 000000000..65cc7cac3
Binary files /dev/null and b/docs/images/guides/editor-overview/document-menu.webp differ
diff --git a/docs/images/guides/editor-overview/editor-bar.webp b/docs/images/guides/editor-overview/editor-bar.webp
new file mode 100644
index 000000000..f5e3fa883
Binary files /dev/null and b/docs/images/guides/editor-overview/editor-bar.webp differ
diff --git a/docs/images/guides/editor-overview/editor-write-mode.webp b/docs/images/guides/editor-overview/editor-write-mode.webp
new file mode 100644
index 000000000..deb036638
Binary files /dev/null and b/docs/images/guides/editor-overview/editor-write-mode.webp differ
diff --git a/docs/images/guides/editor-overview/locked-document.webp b/docs/images/guides/editor-overview/locked-document.webp
new file mode 100644
index 000000000..3e35adf1b
Binary files /dev/null and b/docs/images/guides/editor-overview/locked-document.webp differ
diff --git a/docs/images/guides/editor-overview/offline-save-status.webp b/docs/images/guides/editor-overview/offline-save-status.webp
new file mode 100644
index 000000000..5fbcd07a3
Binary files /dev/null and b/docs/images/guides/editor-overview/offline-save-status.webp differ
diff --git a/docs/images/guides/editor-overview/zoom-bar.webp b/docs/images/guides/editor-overview/zoom-bar.webp
new file mode 100644
index 000000000..b292e5093
Binary files /dev/null and b/docs/images/guides/editor-overview/zoom-bar.webp differ
diff --git a/docs/images/guides/entering-dates/dates-on-page.webp b/docs/images/guides/entering-dates/dates-on-page.webp
new file mode 100644
index 000000000..423924a7f
Binary files /dev/null and b/docs/images/guides/entering-dates/dates-on-page.webp differ
diff --git a/docs/images/guides/entering-dates/dates-present.webp b/docs/images/guides/entering-dates/dates-present.webp
new file mode 100644
index 000000000..b2a731337
Binary files /dev/null and b/docs/images/guides/entering-dates/dates-present.webp differ
diff --git a/docs/images/guides/entering-dates/dates-unreadable-error.webp b/docs/images/guides/entering-dates/dates-unreadable-error.webp
new file mode 100644
index 000000000..bfc73022f
Binary files /dev/null and b/docs/images/guides/entering-dates/dates-unreadable-error.webp differ
diff --git a/docs/images/guides/entering-dates/design-advanced-date-format.webp b/docs/images/guides/entering-dates/design-advanced-date-format.webp
new file mode 100644
index 000000000..f71382c58
Binary files /dev/null and b/docs/images/guides/entering-dates/design-advanced-date-format.webp differ
diff --git a/docs/images/guides/exporting-resume-to-markdown/download-tab-markdown.webp b/docs/images/guides/exporting-resume-to-markdown/download-tab-markdown.webp
new file mode 100644
index 000000000..4fbe812f9
Binary files /dev/null and b/docs/images/guides/exporting-resume-to-markdown/download-tab-markdown.webp differ
diff --git a/docs/images/guides/exporting-resume-to-markdown/screenshot-1.webp b/docs/images/guides/exporting-resume-to-markdown/screenshot-1.webp
deleted file mode 100644
index b6e2da598..000000000
Binary files a/docs/images/guides/exporting-resume-to-markdown/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/exporting-your-data/your-data-section.webp b/docs/images/guides/exporting-your-data/your-data-section.webp
new file mode 100644
index 000000000..540d8583f
Binary files /dev/null and b/docs/images/guides/exporting-your-data/your-data-section.webp differ
diff --git a/docs/images/guides/exporting-your-resume/document-menu-print.webp b/docs/images/guides/exporting-your-resume/document-menu-print.webp
new file mode 100644
index 000000000..d49ff2079
Binary files /dev/null and b/docs/images/guides/exporting-your-resume/document-menu-print.webp differ
diff --git a/docs/images/guides/exporting-your-resume/download-pdf-button.webp b/docs/images/guides/exporting-your-resume/download-pdf-button.webp
new file mode 100644
index 000000000..6fd0f2f2b
Binary files /dev/null and b/docs/images/guides/exporting-your-resume/download-pdf-button.webp differ
diff --git a/docs/images/guides/exporting-your-resume/screenshot-1.webp b/docs/images/guides/exporting-your-resume/screenshot-1.webp
deleted file mode 100644
index a5f2a62d8..000000000
Binary files a/docs/images/guides/exporting-your-resume/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/exporting-your-resume/share-sheet-download-tab.webp b/docs/images/guides/exporting-your-resume/share-sheet-download-tab.webp
new file mode 100644
index 000000000..16acaea17
Binary files /dev/null and b/docs/images/guides/exporting-your-resume/share-sheet-download-tab.webp differ
diff --git a/docs/images/guides/filling-in-your-details/basics-card.webp b/docs/images/guides/filling-in-your-details/basics-card.webp
new file mode 100644
index 000000000..a7b772239
Binary files /dev/null and b/docs/images/guides/filling-in-your-details/basics-card.webp differ
diff --git a/docs/images/guides/filling-in-your-details/crop-picture-dialog.webp b/docs/images/guides/filling-in-your-details/crop-picture-dialog.webp
new file mode 100644
index 000000000..d0873d869
Binary files /dev/null and b/docs/images/guides/filling-in-your-details/crop-picture-dialog.webp differ
diff --git a/docs/images/guides/filling-in-your-details/photo-popover.webp b/docs/images/guides/filling-in-your-details/photo-popover.webp
new file mode 100644
index 000000000..643eac888
Binary files /dev/null and b/docs/images/guides/filling-in-your-details/photo-popover.webp differ
diff --git a/docs/images/guides/filling-in-your-details/summary-editor.webp b/docs/images/guides/filling-in-your-details/summary-editor.webp
new file mode 100644
index 000000000..a4afabaf7
Binary files /dev/null and b/docs/images/guides/filling-in-your-details/summary-editor.webp differ
diff --git a/docs/images/guides/fitting-content-on-a-page/overflow-chip.webp b/docs/images/guides/fitting-content-on-a-page/overflow-chip.webp
new file mode 100644
index 000000000..09915446b
Binary files /dev/null and b/docs/images/guides/fitting-content-on-a-page/overflow-chip.webp differ
diff --git a/docs/images/guides/fitting-content-on-a-page/page-boundary-marker.webp b/docs/images/guides/fitting-content-on-a-page/page-boundary-marker.webp
new file mode 100644
index 000000000..01a89d766
Binary files /dev/null and b/docs/images/guides/fitting-content-on-a-page/page-boundary-marker.webp differ
diff --git a/docs/images/guides/fitting-content-on-a-page/screenshot-1.webp b/docs/images/guides/fitting-content-on-a-page/screenshot-1.webp
deleted file mode 100644
index f6764cf8b..000000000
Binary files a/docs/images/guides/fitting-content-on-a-page/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/fitting-content-on-a-page/screenshot-2.webp b/docs/images/guides/fitting-content-on-a-page/screenshot-2.webp
deleted file mode 100644
index 4e1af8404..000000000
Binary files a/docs/images/guides/fitting-content-on-a-page/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/fitting-content-on-a-page/zoom-bar-page-count.webp b/docs/images/guides/fitting-content-on-a-page/zoom-bar-page-count.webp
new file mode 100644
index 000000000..6c36cae4d
Binary files /dev/null and b/docs/images/guides/fitting-content-on-a-page/zoom-bar-page-count.webp differ
diff --git a/docs/images/guides/formatting-text/formatting-toolbar.webp b/docs/images/guides/formatting-text/formatting-toolbar.webp
new file mode 100644
index 000000000..0aa7878ac
Binary files /dev/null and b/docs/images/guides/formatting-text/formatting-toolbar.webp differ
diff --git a/docs/images/guides/formatting-text/link-address-dialog.webp b/docs/images/guides/formatting-text/link-address-dialog.webp
new file mode 100644
index 000000000..7da0387a4
Binary files /dev/null and b/docs/images/guides/formatting-text/link-address-dialog.webp differ
diff --git a/docs/images/guides/formatting-text/phone-formatting-toolbar.webp b/docs/images/guides/formatting-text/phone-formatting-toolbar.webp
new file mode 100644
index 000000000..976d3fe05
Binary files /dev/null and b/docs/images/guides/formatting-text/phone-formatting-toolbar.webp differ
diff --git a/docs/images/guides/importing-applications-from-csv/export-applications-sheet.webp b/docs/images/guides/importing-applications-from-csv/export-applications-sheet.webp
new file mode 100644
index 000000000..1a545d2fb
Binary files /dev/null and b/docs/images/guides/importing-applications-from-csv/export-applications-sheet.webp differ
diff --git a/docs/images/guides/importing-applications-from-csv/import-from-csv-column-matching.webp b/docs/images/guides/importing-applications-from-csv/import-from-csv-column-matching.webp
new file mode 100644
index 000000000..a03e870ad
Binary files /dev/null and b/docs/images/guides/importing-applications-from-csv/import-from-csv-column-matching.webp differ
diff --git a/docs/images/guides/importing-applications-from-csv/import-from-csv-ready-summary.webp b/docs/images/guides/importing-applications-from-csv/import-from-csv-ready-summary.webp
new file mode 100644
index 000000000..e83d36bbd
Binary files /dev/null and b/docs/images/guides/importing-applications-from-csv/import-from-csv-ready-summary.webp differ
diff --git a/docs/images/guides/importing-applications-from-csv/screenshot-1.webp b/docs/images/guides/importing-applications-from-csv/screenshot-1.webp
deleted file mode 100644
index 66187fd42..000000000
Binary files a/docs/images/guides/importing-applications-from-csv/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/importing-resumes/drop-to-import.webp b/docs/images/guides/importing-resumes/drop-to-import.webp
new file mode 100644
index 000000000..9ad897686
Binary files /dev/null and b/docs/images/guides/importing-resumes/drop-to-import.webp differ
diff --git a/docs/images/guides/importing-resumes/import-finished.webp b/docs/images/guides/importing-resumes/import-finished.webp
new file mode 100644
index 000000000..549fa29d9
Binary files /dev/null and b/docs/images/guides/importing-resumes/import-finished.webp differ
diff --git a/docs/images/guides/importing-resumes/import-word-needs-ai.webp b/docs/images/guides/importing-resumes/import-word-needs-ai.webp
new file mode 100644
index 000000000..41a45f64d
Binary files /dev/null and b/docs/images/guides/importing-resumes/import-word-needs-ai.webp differ
diff --git a/docs/images/guides/importing-resumes/imported-from-note.webp b/docs/images/guides/importing-resumes/imported-from-note.webp
new file mode 100644
index 000000000..dcac13afc
Binary files /dev/null and b/docs/images/guides/importing-resumes/imported-from-note.webp differ
diff --git a/docs/images/guides/importing-resumes/screenshot-1.webp b/docs/images/guides/importing-resumes/screenshot-1.webp
deleted file mode 100644
index d0d07119e..000000000
Binary files a/docs/images/guides/importing-resumes/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/linking-social-accounts/sign-in-and-security-providers.webp b/docs/images/guides/linking-social-accounts/sign-in-and-security-providers.webp
new file mode 100644
index 000000000..8b1fd1cbb
Binary files /dev/null and b/docs/images/guides/linking-social-accounts/sign-in-and-security-providers.webp differ
diff --git a/docs/images/guides/managing-an-application/application-detail-sheet.webp b/docs/images/guides/managing-an-application/application-detail-sheet.webp
new file mode 100644
index 000000000..d9a57d6ad
Binary files /dev/null and b/docs/images/guides/managing-an-application/application-detail-sheet.webp differ
diff --git a/docs/images/guides/managing-an-application/close-this-application-dialog.webp b/docs/images/guides/managing-an-application/close-this-application-dialog.webp
new file mode 100644
index 000000000..63929cbc4
Binary files /dev/null and b/docs/images/guides/managing-an-application/close-this-application-dialog.webp differ
diff --git a/docs/images/guides/managing-an-application/detail-sheet-contacts.webp b/docs/images/guides/managing-an-application/detail-sheet-contacts.webp
new file mode 100644
index 000000000..571636879
Binary files /dev/null and b/docs/images/guides/managing-an-application/detail-sheet-contacts.webp differ
diff --git a/docs/images/guides/managing-an-application/detail-sheet-next-step.webp b/docs/images/guides/managing-an-application/detail-sheet-next-step.webp
new file mode 100644
index 000000000..8f875046c
Binary files /dev/null and b/docs/images/guides/managing-an-application/detail-sheet-next-step.webp differ
diff --git a/docs/images/guides/managing-an-application/detail-sheet-notes-and-activity.webp b/docs/images/guides/managing-an-application/detail-sheet-notes-and-activity.webp
new file mode 100644
index 000000000..5c05716fb
Binary files /dev/null and b/docs/images/guides/managing-an-application/detail-sheet-notes-and-activity.webp differ
diff --git a/docs/images/guides/managing-an-application/detail-sheet-stage-stepper.webp b/docs/images/guides/managing-an-application/detail-sheet-stage-stepper.webp
new file mode 100644
index 000000000..79dd17733
Binary files /dev/null and b/docs/images/guides/managing-an-application/detail-sheet-stage-stepper.webp differ
diff --git a/docs/images/guides/managing-an-application/detail-sheet-what-you-sent.webp b/docs/images/guides/managing-an-application/detail-sheet-what-you-sent.webp
new file mode 100644
index 000000000..3c676ffc8
Binary files /dev/null and b/docs/images/guides/managing-an-application/detail-sheet-what-you-sent.webp differ
diff --git a/docs/images/guides/managing-an-application/edit-application-sheet.webp b/docs/images/guides/managing-an-application/edit-application-sheet.webp
new file mode 100644
index 000000000..846475c9d
Binary files /dev/null and b/docs/images/guides/managing-an-application/edit-application-sheet.webp differ
diff --git a/docs/images/guides/managing-an-application/what-you-sent-attach-files.webp b/docs/images/guides/managing-an-application/what-you-sent-attach-files.webp
new file mode 100644
index 000000000..5928fba47
Binary files /dev/null and b/docs/images/guides/managing-an-application/what-you-sent-attach-files.webp differ
diff --git a/docs/images/guides/managing-documents/copy-for-a-job.webp b/docs/images/guides/managing-documents/copy-for-a-job.webp
new file mode 100644
index 000000000..2fbd36207
Binary files /dev/null and b/docs/images/guides/managing-documents/copy-for-a-job.webp differ
diff --git a/docs/images/guides/managing-documents/document-options-menu.webp b/docs/images/guides/managing-documents/document-options-menu.webp
new file mode 100644
index 000000000..956e52e94
Binary files /dev/null and b/docs/images/guides/managing-documents/document-options-menu.webp differ
diff --git a/docs/images/guides/managing-documents/documents-grid.webp b/docs/images/guides/managing-documents/documents-grid.webp
new file mode 100644
index 000000000..71bd4a9e9
Binary files /dev/null and b/docs/images/guides/managing-documents/documents-grid.webp differ
diff --git a/docs/images/guides/managing-documents/documents-list-view.webp b/docs/images/guides/managing-documents/documents-list-view.webp
new file mode 100644
index 000000000..3e7f26de3
Binary files /dev/null and b/docs/images/guides/managing-documents/documents-list-view.webp differ
diff --git a/docs/images/guides/managing-documents/documents-toolbar.webp b/docs/images/guides/managing-documents/documents-toolbar.webp
new file mode 100644
index 000000000..e8fcb326e
Binary files /dev/null and b/docs/images/guides/managing-documents/documents-toolbar.webp differ
diff --git a/docs/images/guides/managing-documents/locked-document-menu.webp b/docs/images/guides/managing-documents/locked-document-menu.webp
new file mode 100644
index 000000000..bde029364
Binary files /dev/null and b/docs/images/guides/managing-documents/locked-document-menu.webp differ
diff --git a/docs/images/guides/managing-documents/new-document-dialog.webp b/docs/images/guides/managing-documents/new-document-dialog.webp
new file mode 100644
index 000000000..aeff319a6
Binary files /dev/null and b/docs/images/guides/managing-documents/new-document-dialog.webp differ
diff --git a/docs/images/guides/managing-documents/rename-inline.webp b/docs/images/guides/managing-documents/rename-inline.webp
new file mode 100644
index 000000000..8fbe3faae
Binary files /dev/null and b/docs/images/guides/managing-documents/rename-inline.webp differ
diff --git a/docs/images/guides/managing-resumes-from-the-dashboard/screenshot-1.webp b/docs/images/guides/managing-resumes-from-the-dashboard/screenshot-1.webp
deleted file mode 100644
index 4794cbe8e..000000000
Binary files a/docs/images/guides/managing-resumes-from-the-dashboard/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/managing-resumes-from-the-dashboard/screenshot-2.webp b/docs/images/guides/managing-resumes-from-the-dashboard/screenshot-2.webp
deleted file mode 100644
index 4edb11708..000000000
Binary files a/docs/images/guides/managing-resumes-from-the-dashboard/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/managing-sections/add-section-menu.webp b/docs/images/guides/managing-sections/add-section-menu.webp
new file mode 100644
index 000000000..38aaaafc2
Binary files /dev/null and b/docs/images/guides/managing-sections/add-section-menu.webp differ
diff --git a/docs/images/guides/managing-sections/outline-print-order.webp b/docs/images/guides/managing-sections/outline-print-order.webp
new file mode 100644
index 000000000..64cba308b
Binary files /dev/null and b/docs/images/guides/managing-sections/outline-print-order.webp differ
diff --git a/docs/images/guides/managing-sections/rename-section-dialog.webp b/docs/images/guides/managing-sections/rename-section-dialog.webp
new file mode 100644
index 000000000..4f9d05be6
Binary files /dev/null and b/docs/images/guides/managing-sections/rename-section-dialog.webp differ
diff --git a/docs/images/guides/managing-sections/section-options-menu.webp b/docs/images/guides/managing-sections/section-options-menu.webp
new file mode 100644
index 000000000..c4f0d7f27
Binary files /dev/null and b/docs/images/guides/managing-sections/section-options-menu.webp differ
diff --git a/docs/images/guides/managing-sections/start-suggestions.webp b/docs/images/guides/managing-sections/start-suggestions.webp
new file mode 100644
index 000000000..7b6e53e47
Binary files /dev/null and b/docs/images/guides/managing-sections/start-suggestions.webp differ
diff --git a/docs/images/guides/moving-items-between-sections/screenshot-1.webp b/docs/images/guides/moving-items-between-sections/screenshot-1.webp
deleted file mode 100644
index a244631aa..000000000
Binary files a/docs/images/guides/moving-items-between-sections/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/moving-items-between-sections/screenshot-2.webp b/docs/images/guides/moving-items-between-sections/screenshot-2.webp
deleted file mode 100644
index 89a7f15d8..000000000
Binary files a/docs/images/guides/moving-items-between-sections/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/moving-items-between-sections/screenshot-3.webp b/docs/images/guides/moving-items-between-sections/screenshot-3.webp
deleted file mode 100644
index 91551d729..000000000
Binary files a/docs/images/guides/moving-items-between-sections/screenshot-3.webp and /dev/null differ
diff --git a/docs/images/guides/moving-items-between-sections/screenshot-4.webp b/docs/images/guides/moving-items-between-sections/screenshot-4.webp
deleted file mode 100644
index 8b33fb9fd..000000000
Binary files a/docs/images/guides/moving-items-between-sections/screenshot-4.webp and /dev/null differ
diff --git a/docs/images/guides/organizing-with-tags/tag-filter-active.webp b/docs/images/guides/organizing-with-tags/tag-filter-active.webp
new file mode 100644
index 000000000..f43366551
Binary files /dev/null and b/docs/images/guides/organizing-with-tags/tag-filter-active.webp differ
diff --git a/docs/images/guides/organizing-with-tags/tags-dialog.webp b/docs/images/guides/organizing-with-tags/tags-dialog.webp
new file mode 100644
index 000000000..78c31efdf
Binary files /dev/null and b/docs/images/guides/organizing-with-tags/tags-dialog.webp differ
diff --git a/docs/images/guides/scheduling-interviews/applications-calendar-view.webp b/docs/images/guides/scheduling-interviews/applications-calendar-view.webp
new file mode 100644
index 000000000..5e65574ba
Binary files /dev/null and b/docs/images/guides/scheduling-interviews/applications-calendar-view.webp differ
diff --git a/docs/images/guides/scheduling-interviews/follow-up-dialog.webp b/docs/images/guides/scheduling-interviews/follow-up-dialog.webp
new file mode 100644
index 000000000..29f0bf20e
Binary files /dev/null and b/docs/images/guides/scheduling-interviews/follow-up-dialog.webp differ
diff --git a/docs/images/guides/scheduling-interviews/next-step-edit-menu.webp b/docs/images/guides/scheduling-interviews/next-step-edit-menu.webp
new file mode 100644
index 000000000..0f44370cc
Binary files /dev/null and b/docs/images/guides/scheduling-interviews/next-step-edit-menu.webp differ
diff --git a/docs/images/guides/scheduling-interviews/schedule-an-interview-dialog.webp b/docs/images/guides/scheduling-interviews/schedule-an-interview-dialog.webp
new file mode 100644
index 000000000..a9eb9c919
Binary files /dev/null and b/docs/images/guides/scheduling-interviews/schedule-an-interview-dialog.webp differ
diff --git a/docs/images/guides/selecting-page-format/advanced-page.webp b/docs/images/guides/selecting-page-format/advanced-page.webp
new file mode 100644
index 000000000..66374099e
Binary files /dev/null and b/docs/images/guides/selecting-page-format/advanced-page.webp differ
diff --git a/docs/images/guides/selecting-page-format/page-group.webp b/docs/images/guides/selecting-page-format/page-group.webp
new file mode 100644
index 000000000..355dfa7af
Binary files /dev/null and b/docs/images/guides/selecting-page-format/page-group.webp differ
diff --git a/docs/images/guides/selecting-page-format/screenshot-1.webp b/docs/images/guides/selecting-page-format/screenshot-1.webp
deleted file mode 100644
index c73f9c988..000000000
Binary files a/docs/images/guides/selecting-page-format/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/setting-up-passkeys/name-this-passkey.webp b/docs/images/guides/setting-up-passkeys/name-this-passkey.webp
new file mode 100644
index 000000000..0c29ab106
Binary files /dev/null and b/docs/images/guides/setting-up-passkeys/name-this-passkey.webp differ
diff --git a/docs/images/guides/setting-up-passkeys/passkeys-list.webp b/docs/images/guides/setting-up-passkeys/passkeys-list.webp
new file mode 100644
index 000000000..8d608ecb6
Binary files /dev/null and b/docs/images/guides/setting-up-passkeys/passkeys-list.webp differ
diff --git a/docs/images/guides/setting-up-two-factor-authentication/backup-codes.webp b/docs/images/guides/setting-up-two-factor-authentication/backup-codes.webp
new file mode 100644
index 000000000..f4fd7b821
Binary files /dev/null and b/docs/images/guides/setting-up-two-factor-authentication/backup-codes.webp differ
diff --git a/docs/images/guides/setting-up-two-factor-authentication/disable-dialog.webp b/docs/images/guides/setting-up-two-factor-authentication/disable-dialog.webp
new file mode 100644
index 000000000..b1921a118
Binary files /dev/null and b/docs/images/guides/setting-up-two-factor-authentication/disable-dialog.webp differ
diff --git a/docs/images/guides/setting-up-two-factor-authentication/enter-password.webp b/docs/images/guides/setting-up-two-factor-authentication/enter-password.webp
new file mode 100644
index 000000000..f12126602
Binary files /dev/null and b/docs/images/guides/setting-up-two-factor-authentication/enter-password.webp differ
diff --git a/docs/images/guides/setting-up-two-factor-authentication/scan-qr-code.webp b/docs/images/guides/setting-up-two-factor-authentication/scan-qr-code.webp
new file mode 100644
index 000000000..8b35dd220
Binary files /dev/null and b/docs/images/guides/setting-up-two-factor-authentication/scan-qr-code.webp differ
diff --git a/docs/images/guides/setting-up-two-factor-authentication/two-step-verification-on.webp b/docs/images/guides/setting-up-two-factor-authentication/two-step-verification-on.webp
new file mode 100644
index 000000000..0f21c0acd
Binary files /dev/null and b/docs/images/guides/setting-up-two-factor-authentication/two-step-verification-on.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/address-invalid.webp b/docs/images/guides/sharing-your-resume-publicly/address-invalid.webp
new file mode 100644
index 000000000..1a46ed703
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/address-invalid.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/editor-bar-share-button.webp b/docs/images/guides/sharing-your-resume-publicly/editor-bar-share-button.webp
new file mode 100644
index 000000000..183f3f67d
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/editor-bar-share-button.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/password-dialog.webp b/docs/images/guides/sharing-your-resume-publicly/password-dialog.webp
new file mode 100644
index 000000000..880dbb811
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/password-dialog.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/password-gate.webp b/docs/images/guides/sharing-your-resume-publicly/password-gate.webp
new file mode 100644
index 000000000..2ed148b43
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/password-gate.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/public-link-off.webp b/docs/images/guides/sharing-your-resume-publicly/public-link-off.webp
new file mode 100644
index 000000000..ef88f49a1
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/public-link-off.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/public-resume-on-phone.webp b/docs/images/guides/sharing-your-resume-publicly/public-resume-on-phone.webp
new file mode 100644
index 000000000..82b32e936
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/public-resume-on-phone.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/public-resume-page.webp b/docs/images/guides/sharing-your-resume-publicly/public-resume-page.webp
new file mode 100644
index 000000000..024871e43
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/public-resume-page.webp differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/screenshot-1.webp b/docs/images/guides/sharing-your-resume-publicly/screenshot-1.webp
deleted file mode 100644
index eb29eaab0..000000000
Binary files a/docs/images/guides/sharing-your-resume-publicly/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/sharing-your-resume-publicly/share-sheet-link-tab.webp b/docs/images/guides/sharing-your-resume-publicly/share-sheet-link-tab.webp
new file mode 100644
index 000000000..361eae1d9
Binary files /dev/null and b/docs/images/guides/sharing-your-resume-publicly/share-sheet-link-tab.webp differ
diff --git a/docs/images/guides/signing-in/backup-code.webp b/docs/images/guides/signing-in/backup-code.webp
new file mode 100644
index 000000000..558db2b1e
Binary files /dev/null and b/docs/images/guides/signing-in/backup-code.webp differ
diff --git a/docs/images/guides/signing-in/forgot-password.webp b/docs/images/guides/signing-in/forgot-password.webp
new file mode 100644
index 000000000..8bffcc12d
Binary files /dev/null and b/docs/images/guides/signing-in/forgot-password.webp differ
diff --git a/docs/images/guides/signing-in/sign-in-page.webp b/docs/images/guides/signing-in/sign-in-page.webp
new file mode 100644
index 000000000..317511c9b
Binary files /dev/null and b/docs/images/guides/signing-in/sign-in-page.webp differ
diff --git a/docs/images/guides/signing-in/two-factor-code.webp b/docs/images/guides/signing-in/two-factor-code.webp
new file mode 100644
index 000000000..ff1626859
Binary files /dev/null and b/docs/images/guides/signing-in/two-factor-code.webp differ
diff --git a/docs/images/guides/tailoring-a-resume-for-a-job/copy-a-resume-for-a-job-dialog.webp b/docs/images/guides/tailoring-a-resume-for-a-job/copy-a-resume-for-a-job-dialog.webp
new file mode 100644
index 000000000..ebf368204
Binary files /dev/null and b/docs/images/guides/tailoring-a-resume-for-a-job/copy-a-resume-for-a-job-dialog.webp differ
diff --git a/docs/images/guides/tailoring-a-resume-for-a-job/prepare-for-next-step-suggestions.webp b/docs/images/guides/tailoring-a-resume-for-a-job/prepare-for-next-step-suggestions.webp
new file mode 100644
index 000000000..72f0ccbf4
Binary files /dev/null and b/docs/images/guides/tailoring-a-resume-for-a-job/prepare-for-next-step-suggestions.webp differ
diff --git a/docs/images/guides/tailoring-a-resume-for-a-job/what-you-sent-tailor-and-write-buttons.webp b/docs/images/guides/tailoring-a-resume-for-a-job/what-you-sent-tailor-and-write-buttons.webp
new file mode 100644
index 000000000..c5ab3022b
Binary files /dev/null and b/docs/images/guides/tailoring-a-resume-for-a-job/what-you-sent-tailor-and-write-buttons.webp differ
diff --git a/docs/images/guides/tracking-job-applications/applications-board-view.webp b/docs/images/guides/tracking-job-applications/applications-board-view.webp
new file mode 100644
index 000000000..6e2ecd6fe
Binary files /dev/null and b/docs/images/guides/tracking-job-applications/applications-board-view.webp differ
diff --git a/docs/images/guides/tracking-job-applications/applications-list-view.webp b/docs/images/guides/tracking-job-applications/applications-list-view.webp
new file mode 100644
index 000000000..90f27391c
Binary files /dev/null and b/docs/images/guides/tracking-job-applications/applications-list-view.webp differ
diff --git a/docs/images/guides/tracking-job-applications/board-card-move-to-menu.webp b/docs/images/guides/tracking-job-applications/board-card-move-to-menu.webp
new file mode 100644
index 000000000..c149da522
Binary files /dev/null and b/docs/images/guides/tracking-job-applications/board-card-move-to-menu.webp differ
diff --git a/docs/images/guides/tracking-job-applications/list-bulk-actions-bar.webp b/docs/images/guides/tracking-job-applications/list-bulk-actions-bar.webp
new file mode 100644
index 000000000..80267c04a
Binary files /dev/null and b/docs/images/guides/tracking-job-applications/list-bulk-actions-bar.webp differ
diff --git a/docs/images/guides/tracking-job-applications/screenshot-1.webp b/docs/images/guides/tracking-job-applications/screenshot-1.webp
deleted file mode 100644
index c393619c6..000000000
Binary files a/docs/images/guides/tracking-job-applications/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/tracking-job-applications/screenshot-2.webp b/docs/images/guides/tracking-job-applications/screenshot-2.webp
deleted file mode 100644
index d8ca060a3..000000000
Binary files a/docs/images/guides/tracking-job-applications/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/tracking-job-applications/screenshot-3.webp b/docs/images/guides/tracking-job-applications/screenshot-3.webp
deleted file mode 100644
index 801404dfd..000000000
Binary files a/docs/images/guides/tracking-job-applications/screenshot-3.webp and /dev/null differ
diff --git a/docs/images/guides/tracking-job-applications/screenshot-4.webp b/docs/images/guides/tracking-job-applications/screenshot-4.webp
deleted file mode 100644
index c99e2b1d1..000000000
Binary files a/docs/images/guides/tracking-job-applications/screenshot-4.webp and /dev/null differ
diff --git a/docs/images/guides/undoing-changes-and-version-history/history-after-restoring.webp b/docs/images/guides/undoing-changes-and-version-history/history-after-restoring.webp
new file mode 100644
index 000000000..148dad4a8
Binary files /dev/null and b/docs/images/guides/undoing-changes-and-version-history/history-after-restoring.webp differ
diff --git a/docs/images/guides/undoing-changes-and-version-history/history-tab.webp b/docs/images/guides/undoing-changes-and-version-history/history-tab.webp
new file mode 100644
index 000000000..6087072f2
Binary files /dev/null and b/docs/images/guides/undoing-changes-and-version-history/history-tab.webp differ
diff --git a/docs/images/guides/undoing-changes-and-version-history/history-viewing-a-named-version.webp b/docs/images/guides/undoing-changes-and-version-history/history-viewing-a-named-version.webp
new file mode 100644
index 000000000..3edfc4d52
Binary files /dev/null and b/docs/images/guides/undoing-changes-and-version-history/history-viewing-a-named-version.webp differ
diff --git a/docs/images/guides/updating-your-profile/profile-section.webp b/docs/images/guides/updating-your-profile/profile-section.webp
new file mode 100644
index 000000000..3c5e1cc9b
Binary files /dev/null and b/docs/images/guides/updating-your-profile/profile-section.webp differ
diff --git a/docs/images/guides/updating-your-profile/update-password-dialog.webp b/docs/images/guides/updating-your-profile/update-password-dialog.webp
new file mode 100644
index 000000000..47d14f050
Binary files /dev/null and b/docs/images/guides/updating-your-profile/update-password-dialog.webp differ
diff --git a/docs/images/guides/using-ai-agent/screenshot-1.webp b/docs/images/guides/using-ai-agent/screenshot-1.webp
deleted file mode 100644
index eeedc6e40..000000000
Binary files a/docs/images/guides/using-ai-agent/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/using-ai-in-the-builder/screenshot-1.webp b/docs/images/guides/using-ai-in-the-builder/screenshot-1.webp
deleted file mode 100644
index 8a4555ee3..000000000
Binary files a/docs/images/guides/using-ai-in-the-builder/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/using-ai/add-provider-dialog.webp b/docs/images/guides/using-ai/add-provider-dialog.webp
new file mode 100644
index 000000000..9946de580
Binary files /dev/null and b/docs/images/guides/using-ai/add-provider-dialog.webp differ
diff --git a/docs/images/guides/using-ai/assistant-connect-provider.webp b/docs/images/guides/using-ai/assistant-connect-provider.webp
new file mode 100644
index 000000000..a04f74eb5
Binary files /dev/null and b/docs/images/guides/using-ai/assistant-connect-provider.webp differ
diff --git a/docs/images/guides/using-ai/edit-provider-dialog.webp b/docs/images/guides/using-ai/edit-provider-dialog.webp
new file mode 100644
index 000000000..66b8329af
Binary files /dev/null and b/docs/images/guides/using-ai/edit-provider-dialog.webp differ
diff --git a/docs/images/guides/using-ai/provider-row-connected.webp b/docs/images/guides/using-ai/provider-row-connected.webp
new file mode 100644
index 000000000..734ae9ea3
Binary files /dev/null and b/docs/images/guides/using-ai/provider-row-connected.webp differ
diff --git a/docs/images/guides/using-ai/screenshot-1.webp b/docs/images/guides/using-ai/screenshot-1.webp
deleted file mode 100644
index 9d9f9e52d..000000000
Binary files a/docs/images/guides/using-ai/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/using-ai/screenshot-2.webp b/docs/images/guides/using-ai/screenshot-2.webp
deleted file mode 100644
index 6ff89f0de..000000000
Binary files a/docs/images/guides/using-ai/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/using-custom-styles/screenshot-2.webp b/docs/images/guides/using-custom-styles/screenshot-2.webp
deleted file mode 100644
index 4fa179db6..000000000
Binary files a/docs/images/guides/using-custom-styles/screenshot-2.webp and /dev/null differ
diff --git a/docs/images/guides/using-custom-styles/screenshot-3.webp b/docs/images/guides/using-custom-styles/screenshot-3.webp
deleted file mode 100644
index d6b08fd7e..000000000
Binary files a/docs/images/guides/using-custom-styles/screenshot-3.webp and /dev/null differ
diff --git a/docs/images/guides/using-custom-styles/screenshot-4.webp b/docs/images/guides/using-custom-styles/screenshot-4.webp
deleted file mode 100644
index 48c2cb0a1..000000000
Binary files a/docs/images/guides/using-custom-styles/screenshot-4.webp and /dev/null differ
diff --git a/docs/images/guides/using-custom-styles/screenshot-5.webp b/docs/images/guides/using-custom-styles/screenshot-5.webp
deleted file mode 100644
index 0e4a3fa41..000000000
Binary files a/docs/images/guides/using-custom-styles/screenshot-5.webp and /dev/null differ
diff --git a/docs/images/guides/using-private-notes/notes-dialog.webp b/docs/images/guides/using-private-notes/notes-dialog.webp
new file mode 100644
index 000000000..ffbfe4196
Binary files /dev/null and b/docs/images/guides/using-private-notes/notes-dialog.webp differ
diff --git a/docs/images/guides/using-private-notes/screenshot-1.webp b/docs/images/guides/using-private-notes/screenshot-1.webp
deleted file mode 100644
index 08b358fbf..000000000
Binary files a/docs/images/guides/using-private-notes/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/using-the-api/api-key-shown-once.webp b/docs/images/guides/using-the-api/api-key-shown-once.webp
new file mode 100644
index 000000000..06f92af69
Binary files /dev/null and b/docs/images/guides/using-the-api/api-key-shown-once.webp differ
diff --git a/docs/images/guides/using-the-api/api-keys-section.webp b/docs/images/guides/using-the-api/api-keys-section.webp
new file mode 100644
index 000000000..e136df799
Binary files /dev/null and b/docs/images/guides/using-the-api/api-keys-section.webp differ
diff --git a/docs/images/guides/using-the-api/new-api-key-dialog.webp b/docs/images/guides/using-the-api/new-api-key-dialog.webp
new file mode 100644
index 000000000..8bb78d144
Binary files /dev/null and b/docs/images/guides/using-the-api/new-api-key-dialog.webp differ
diff --git a/docs/images/guides/using-the-api/revoke-key-undo-toast.webp b/docs/images/guides/using-the-api/revoke-key-undo-toast.webp
new file mode 100644
index 000000000..7768b5094
Binary files /dev/null and b/docs/images/guides/using-the-api/revoke-key-undo-toast.webp differ
diff --git a/docs/images/guides/using-the-assistant/assistant-beside-the-page.webp b/docs/images/guides/using-the-assistant/assistant-beside-the-page.webp
new file mode 100644
index 000000000..f06eb84b7
Binary files /dev/null and b/docs/images/guides/using-the-assistant/assistant-beside-the-page.webp differ
diff --git a/docs/images/guides/using-the-assistant/clarifying-question-card.webp b/docs/images/guides/using-the-assistant/clarifying-question-card.webp
new file mode 100644
index 000000000..820acff18
Binary files /dev/null and b/docs/images/guides/using-the-assistant/clarifying-question-card.webp differ
diff --git a/docs/images/guides/using-the-assistant/model-menu.webp b/docs/images/guides/using-the-assistant/model-menu.webp
new file mode 100644
index 000000000..813c97fc9
Binary files /dev/null and b/docs/images/guides/using-the-assistant/model-menu.webp differ
diff --git a/docs/images/guides/using-the-assistant/past-conversations.webp b/docs/images/guides/using-the-assistant/past-conversations.webp
new file mode 100644
index 000000000..7dcf9f870
Binary files /dev/null and b/docs/images/guides/using-the-assistant/past-conversations.webp differ
diff --git a/docs/images/guides/using-the-assistant/proposed-edit-card.webp b/docs/images/guides/using-the-assistant/proposed-edit-card.webp
new file mode 100644
index 000000000..0da0e8710
Binary files /dev/null and b/docs/images/guides/using-the-assistant/proposed-edit-card.webp differ
diff --git a/docs/images/guides/using-the-assistant/proposed-edit-on-page.webp b/docs/images/guides/using-the-assistant/proposed-edit-on-page.webp
new file mode 100644
index 000000000..bba714540
Binary files /dev/null and b/docs/images/guides/using-the-assistant/proposed-edit-on-page.webp differ
diff --git a/docs/images/guides/using-the-ats-checker/as-software-reads-it.webp b/docs/images/guides/using-the-ats-checker/as-software-reads-it.webp
new file mode 100644
index 000000000..7c727d34c
Binary files /dev/null and b/docs/images/guides/using-the-ats-checker/as-software-reads-it.webp differ
diff --git a/docs/images/guides/using-the-ats-checker/checker-result.webp b/docs/images/guides/using-the-ats-checker/checker-result.webp
new file mode 100644
index 000000000..31c41e646
Binary files /dev/null and b/docs/images/guides/using-the-ats-checker/checker-result.webp differ
diff --git a/docs/images/guides/using-the-ats-checker/drop-zone-and-posting.webp b/docs/images/guides/using-the-ats-checker/drop-zone-and-posting.webp
new file mode 100644
index 000000000..f938495f3
Binary files /dev/null and b/docs/images/guides/using-the-ats-checker/drop-zone-and-posting.webp differ
diff --git a/docs/images/guides/using-the-ats-checker/report-and-fix.webp b/docs/images/guides/using-the-ats-checker/report-and-fix.webp
new file mode 100644
index 000000000..13417fe9f
Binary files /dev/null and b/docs/images/guides/using-the-ats-checker/report-and-fix.webp differ
diff --git a/docs/images/guides/using-the-builder-dock/screenshot-1.webp b/docs/images/guides/using-the-builder-dock/screenshot-1.webp
deleted file mode 100644
index ede330ba4..000000000
Binary files a/docs/images/guides/using-the-builder-dock/screenshot-1.webp and /dev/null differ
diff --git a/docs/images/guides/using-the-command-bar/command-bar-ask.webp b/docs/images/guides/using-the-command-bar/command-bar-ask.webp
new file mode 100644
index 000000000..8febf2603
Binary files /dev/null and b/docs/images/guides/using-the-command-bar/command-bar-ask.webp differ
diff --git a/docs/images/guides/using-the-command-bar/command-bar-root.webp b/docs/images/guides/using-the-command-bar/command-bar-root.webp
new file mode 100644
index 000000000..6bf5f8786
Binary files /dev/null and b/docs/images/guides/using-the-command-bar/command-bar-root.webp differ
diff --git a/docs/images/guides/using-the-mcp-server/mcp-server-settings.webp b/docs/images/guides/using-the-mcp-server/mcp-server-settings.webp
new file mode 100644
index 000000000..a603d1878
Binary files /dev/null and b/docs/images/guides/using-the-mcp-server/mcp-server-settings.webp differ
diff --git a/docs/images/guides/using-the-mcp-server/oauth-consent.webp b/docs/images/guides/using-the-mcp-server/oauth-consent.webp
new file mode 100644
index 000000000..3c0af2119
Binary files /dev/null and b/docs/images/guides/using-the-mcp-server/oauth-consent.webp differ
diff --git a/docs/images/guides/using-the-trash/delete-now-confirmation.webp b/docs/images/guides/using-the-trash/delete-now-confirmation.webp
new file mode 100644
index 000000000..af22f7020
Binary files /dev/null and b/docs/images/guides/using-the-trash/delete-now-confirmation.webp differ
diff --git a/docs/images/guides/using-the-trash/moved-to-trash-toast.webp b/docs/images/guides/using-the-trash/moved-to-trash-toast.webp
new file mode 100644
index 000000000..5471480e1
Binary files /dev/null and b/docs/images/guides/using-the-trash/moved-to-trash-toast.webp differ
diff --git a/docs/images/guides/using-the-trash/trash-in-sidebar.webp b/docs/images/guides/using-the-trash/trash-in-sidebar.webp
new file mode 100644
index 000000000..bb6704802
Binary files /dev/null and b/docs/images/guides/using-the-trash/trash-in-sidebar.webp differ
diff --git a/docs/images/guides/using-the-trash/trash-page-row-menu.webp b/docs/images/guides/using-the-trash/trash-page-row-menu.webp
new file mode 100644
index 000000000..61839944b
Binary files /dev/null and b/docs/images/guides/using-the-trash/trash-page-row-menu.webp differ
diff --git a/docs/images/guides/viewing-application-insights/insights-funnel-and-reply-figures.webp b/docs/images/guides/viewing-application-insights/insights-funnel-and-reply-figures.webp
new file mode 100644
index 000000000..29c65897b
Binary files /dev/null and b/docs/images/guides/viewing-application-insights/insights-funnel-and-reply-figures.webp differ
diff --git a/docs/images/guides/viewing-application-insights/insights-pipeline-chart.webp b/docs/images/guides/viewing-application-insights/insights-pipeline-chart.webp
new file mode 100644
index 000000000..43cd878af
Binary files /dev/null and b/docs/images/guides/viewing-application-insights/insights-pipeline-chart.webp differ
diff --git a/docs/images/guides/viewing-application-insights/insights-weekly-and-source-charts.webp b/docs/images/guides/viewing-application-insights/insights-weekly-and-source-charts.webp
new file mode 100644
index 000000000..d21ff2cfa
Binary files /dev/null and b/docs/images/guides/viewing-application-insights/insights-weekly-and-source-charts.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/application-write-a-letter.webp b/docs/images/guides/writing-a-cover-letter/application-write-a-letter.webp
new file mode 100644
index 000000000..2e8a1e09d
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/application-write-a-letter.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-body-empty.webp b/docs/images/guides/writing-a-cover-letter/letter-body-empty.webp
new file mode 100644
index 000000000..52a316102
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-body-empty.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-design-matching.webp b/docs/images/guides/writing-a-cover-letter/letter-design-matching.webp
new file mode 100644
index 000000000..36f3e4451
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-design-matching.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-design-own.webp b/docs/images/guides/writing-a-cover-letter/letter-design-own.webp
new file mode 100644
index 000000000..45a22ad1c
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-design-own.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-editor-write.webp b/docs/images/guides/writing-a-cover-letter/letter-editor-write.webp
new file mode 100644
index 000000000..a7d4c4ef1
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-editor-write.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-from-resume.webp b/docs/images/guides/writing-a-cover-letter/letter-from-resume.webp
new file mode 100644
index 000000000..bf1222c9a
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-from-resume.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-length-and-design.webp b/docs/images/guides/writing-a-cover-letter/letter-length-and-design.webp
new file mode 100644
index 000000000..02c3bd131
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-length-and-design.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/letter-share-download.webp b/docs/images/guides/writing-a-cover-letter/letter-share-download.webp
new file mode 100644
index 000000000..e81f5842f
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/letter-share-download.webp differ
diff --git a/docs/images/guides/writing-a-cover-letter/new-dialog-cover-letter.webp b/docs/images/guides/writing-a-cover-letter/new-dialog-cover-letter.webp
new file mode 100644
index 000000000..12b1a51f4
Binary files /dev/null and b/docs/images/guides/writing-a-cover-letter/new-dialog-cover-letter.webp differ
diff --git a/docs/images/self-hosting/examples/add-provider-local-ollama.webp b/docs/images/self-hosting/examples/add-provider-local-ollama.webp
new file mode 100644
index 000000000..8beda19b1
Binary files /dev/null and b/docs/images/self-hosting/examples/add-provider-local-ollama.webp differ
diff --git a/docs/images/templates/azurill.webp b/docs/images/templates/azurill.webp
index d6f430075..dccfd3d4f 100644
Binary files a/docs/images/templates/azurill.webp and b/docs/images/templates/azurill.webp differ
diff --git a/docs/images/templates/bronzor.webp b/docs/images/templates/bronzor.webp
index f1b0e0117..74a34e2b7 100644
Binary files a/docs/images/templates/bronzor.webp and b/docs/images/templates/bronzor.webp differ
diff --git a/docs/images/templates/chikorita.webp b/docs/images/templates/chikorita.webp
index 5bbd5d37b..b9dc007e3 100644
Binary files a/docs/images/templates/chikorita.webp and b/docs/images/templates/chikorita.webp differ
diff --git a/docs/images/templates/ditgar.webp b/docs/images/templates/ditgar.webp
index 3ebe327ca..223689458 100644
Binary files a/docs/images/templates/ditgar.webp and b/docs/images/templates/ditgar.webp differ
diff --git a/docs/images/templates/ditto.webp b/docs/images/templates/ditto.webp
index 4b933b7d0..1ea042e0f 100644
Binary files a/docs/images/templates/ditto.webp and b/docs/images/templates/ditto.webp differ
diff --git a/docs/images/templates/gengar.webp b/docs/images/templates/gengar.webp
index 6a183d0d3..a1c146853 100644
Binary files a/docs/images/templates/gengar.webp and b/docs/images/templates/gengar.webp differ
diff --git a/docs/images/templates/glalie.webp b/docs/images/templates/glalie.webp
index e3ec1062a..f1cafd31e 100644
Binary files a/docs/images/templates/glalie.webp and b/docs/images/templates/glalie.webp differ
diff --git a/docs/images/templates/kakuna.webp b/docs/images/templates/kakuna.webp
index 96c49d10b..7eafcfb76 100644
Binary files a/docs/images/templates/kakuna.webp and b/docs/images/templates/kakuna.webp differ
diff --git a/docs/images/templates/lapras.webp b/docs/images/templates/lapras.webp
index 7711197ff..64236a954 100644
Binary files a/docs/images/templates/lapras.webp and b/docs/images/templates/lapras.webp differ
diff --git a/docs/images/templates/leafish.webp b/docs/images/templates/leafish.webp
index f35b96af5..dfe1ce9fe 100644
Binary files a/docs/images/templates/leafish.webp and b/docs/images/templates/leafish.webp differ
diff --git a/docs/images/templates/meowth.webp b/docs/images/templates/meowth.webp
index 9208d69c9..3a7af5500 100644
Binary files a/docs/images/templates/meowth.webp and b/docs/images/templates/meowth.webp differ
diff --git a/docs/images/templates/onyx.webp b/docs/images/templates/onyx.webp
index 9f10946d0..420bfbc37 100644
Binary files a/docs/images/templates/onyx.webp and b/docs/images/templates/onyx.webp differ
diff --git a/docs/images/templates/pikachu.webp b/docs/images/templates/pikachu.webp
index 31bdf4e9a..3a200cd4c 100644
Binary files a/docs/images/templates/pikachu.webp and b/docs/images/templates/pikachu.webp differ
diff --git a/docs/images/templates/rhyhorn.webp b/docs/images/templates/rhyhorn.webp
index 7d444fde8..333287c1d 100644
Binary files a/docs/images/templates/rhyhorn.webp and b/docs/images/templates/rhyhorn.webp differ
diff --git a/docs/images/templates/scizor.webp b/docs/images/templates/scizor.webp
index d8cde8965..d0bd27917 100644
Binary files a/docs/images/templates/scizor.webp and b/docs/images/templates/scizor.webp differ
diff --git a/docs/legal/license.mdx b/docs/legal/license.mdx
index 9118b47d2..d3fd4399c 100644
--- a/docs/legal/license.mdx
+++ b/docs/legal/license.mdx
@@ -17,7 +17,7 @@ For the upstream repository, see: [reactive-resume/reactive-resume](https://gith
MIT License
-Copyright (c) 2023 Amruth Pillai
+Copyright (c) 2026 Amruth Pillai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/docs/legal/privacy-policy.mdx b/docs/legal/privacy-policy.mdx
index 031d61808..130edc4b6 100644
--- a/docs/legal/privacy-policy.mdx
+++ b/docs/legal/privacy-policy.mdx
@@ -28,11 +28,12 @@ If you are self-hosting, **you** are the Service Operator and responsible for co
Reactive Resume is a resume builder that lets you:
-- Create and edit resumes in a browser-based builder
-- Store resumes in an account, optionally mark them public, and share them via a link
-- Export/print resumes to PDF and generate preview screenshots
+- Create and edit resumes and cover letters in a browser-based editor
+- Store documents in an account, optionally make a resume public, and share it via a link
+- Export resumes and cover letters (for example, to PDF, DOCX, Markdown, or JSON)
+- Track job applications, including notes, contacts, interviews, and the documents you sent
- Upload files such as profile pictures (and other assets used in a resume)
-- Optionally configure AI features (e.g., using OpenAI/Gemini/Anthropic) from your own device
+- Optionally connect your own AI provider (e.g., OpenAI, Anthropic, Google Gemini, or another supported provider) to use AI features such as the assistant
---
@@ -45,7 +46,7 @@ When you create an account or sign in, the Service stores:
- **Identity and profile**: name, email address, username/display username, optional profile image
- **Authentication state**: whether email is verified; whether two-factor authentication is enabled
-If you use social sign-in (e.g., Google, GitHub, or a custom OAuth provider), the Service stores identifiers and tokens needed to link and maintain that login.
+If you use social sign-in (e.g., Google, GitHub, LinkedIn, or a custom OAuth provider), the Service stores identifiers and tokens needed to link and maintain that login.
### Authentication and security data
@@ -58,21 +59,26 @@ To keep your account secure and keep you signed in, the Service stores:
The Service Operator may also send **transactional emails** (for example, password reset or email verification). Depending on deployment, these emails may be delivered via an email provider or (in development/testing) the links may be logged to server output.
-### Resume content
+### Resume and cover letter content
-When you create or import a resume, the Service stores the resume data you provide, which may include personal data such as:
+When you create or import a resume or cover letter, the Service stores the data you provide, which may include personal data such as:
- Contact details, location, summary
- Employment, education, projects, links, and other resume sections
-- Any other content you add (including rich text)
+- Cover letter text and recipient details
+- Any other content you add (including rich text and private notes)
-Resumes may also have metadata such as tags, a slug, visibility (public/private), and an optional resume password (if you lock a resume).
+Resumes may also have metadata such as tags, a slug, visibility (public/private), and an optional resume password (if you password-protect a resume). The Service also keeps a version history of your documents so you can restore earlier versions.
+
+### Job applications
+
+If you use the application tracker, the Service stores the application details you enter or import, such as company, role, job posting details, stages, interviews, notes, contacts, and which resume or cover letter you sent.
### Public resume access and statistics
If you publish a resume, other users may access it via its public link. The Service may also maintain simple statistics such as:
-- View count and download count
+- View count and download count, including daily totals
- Last viewed/downloaded timestamps
### Uploaded files (e.g., profile pictures)
@@ -80,7 +86,8 @@ If you publish a resume, other users may access it via its public link. The Serv
If you upload files, the Service stores them either:
- On the **local filesystem** of the server (default: under a `data/` directory), or
-- In **S3-compatible object storage**, if configured by the Service Operator
+- In **S3-compatible object storage**, if configured by the Service Operator, or
+- In **Vercel Blob** storage, when the Service runs on Vercel
Depending on configuration, uploaded files may be publicly accessible (for example, some S3 configurations may default to public read access for uploaded objects). The Service Operator is responsible for selecting appropriate access controls for uploads.
@@ -89,14 +96,14 @@ Depending on configuration, uploaded files may be publicly accessible (for examp
If the Service Operator enables API key functionality, the Service can store:
- API key metadata and rate limit counters
-- The API key value itself (as stored by the Service)
+- A hashed form of the API key, used to verify it
### Local-only preferences and settings
Some settings are stored on your device:
- **Cookies**: UI preferences such as `theme` and `locale`
-- **Local storage**: some client-side state and, if you enable AI features, your **AI provider configuration and API key** may be stored in your browser's local storage
+- **Local storage**: some client-side state, such as view preferences and unsaved drafts
These local-only values are stored in your browser and are not necessarily transmitted to the Service Operator unless you choose to use related features.
@@ -108,7 +115,7 @@ We use the information above to:
- Provide and operate the Service (account access, resume editing, storage, sharing)
- Authenticate users and prevent abuse/fraud (sessions, security logs/metadata)
-- Generate PDFs and screenshots you request
+- Generate PDFs, previews, and other exports you request
- Maintain basic functionality such as localization and theme preferences
- Provide support and respond to user requests (if you contact the Service Operator)
@@ -129,21 +136,21 @@ The Service does not include built-in behavioral advertising or third-party anal
We share information only as needed to provide the Service:
-### PDF generation (client-side)
+### PDF generation
-When you export to PDF, the Service renders the document directly in your browser using the Forme PDF engine, which runs in your browser as WebAssembly. Resume content is processed locally on your device for this purpose and is not sent to a separate rendering service. No third-party "printer" or headless-browser service is involved in the export.
+When you download a PDF from the editor, the Service renders the document directly in your browser using the Forme PDF engine, which runs in your browser as WebAssembly. Resume content is processed locally on your device for this purpose. When a visitor downloads the PDF of a public resume, or when a PDF is requested through the API, the Service's own server renders it with the same engine. No third-party "printer" or headless-browser service is involved in either case.
### Storage providers (optional)
-If configured, uploaded files may be stored in an S3-compatible provider. In that case, the storage provider processes and stores file data on behalf of the Service Operator.
+If configured, uploaded files may be stored in an S3-compatible provider or in Vercel Blob. In that case, the storage provider processes and stores file data on behalf of the Service Operator.
### OAuth providers (optional)
-If you sign in via OAuth (Google/GitHub/custom), those providers receive authentication requests and return profile information (such as email/name) to the Service, as permitted by your provider settings.
+If you sign in via OAuth (Google/GitHub/LinkedIn/custom), those providers receive authentication requests and return profile information (such as email/name) to the Service, as permitted by your provider settings.
### AI providers (optional, user-supplied)
-If you enable AI features and provide your own API key, prompts and generated content may be sent to your selected AI provider (OpenAI, Google, Anthropic), according to your use of those features and the provider's policies.
+If you enable AI features and provide your own API key, the Service stores your provider settings and stores the API key encrypted. When you use AI features, the Service sends prompts, the relevant document content, and any files you attach to your selected AI provider (for example, OpenAI, Anthropic, or Google Gemini), according to your use of those features and the provider's policies. The Service stores your assistant conversations, attachments, and proposed changes in your account until you delete them or your account.
---
@@ -156,7 +163,7 @@ As a baseline:
- Account data and resumes are retained until you delete them (or your account is deleted).
- Session and security data may be retained as needed for authentication and security.
- Uploaded files are retained until deleted (for example, when you remove a picture or delete a resume/account).
-- Cached screenshot artifacts may be retained briefly (for example, minutes) for performance.
+- Documents you move to the Trash are deleted permanently after 30 days, unless you restore or delete them sooner.
The Service Operator may also retain backups and logs for limited periods.
diff --git a/docs/legal/terms-of-service.mdx b/docs/legal/terms-of-service.mdx
index 68574c024..f05a15d9e 100644
--- a/docs/legal/terms-of-service.mdx
+++ b/docs/legal/terms-of-service.mdx
@@ -24,7 +24,7 @@ If you do not agree, do not use the Service.
## The Service
-Reactive Resume is a resume builder that allows you to create, store, and share resumes, upload related assets, and export/print resumes (including generating PDFs and screenshots).
+Reactive Resume is a resume builder that allows you to create, store, and share resumes and cover letters, track job applications, upload related assets, and export resumes (including generating PDFs).
---
@@ -82,7 +82,7 @@ The Service Operator may remove Content or restrict access if needed to enforce
## Uploads, storage, and delivery
-Uploaded files may be stored on the Service Operator's infrastructure and may be served back to you (and, if your resume is public, to others). Depending on configuration, storage may be local filesystem storage or S3-compatible object storage.
+Uploaded files may be stored on the Service Operator's infrastructure and may be served back to you (and, if your resume is public, to others). Depending on configuration, storage may be local filesystem storage, S3-compatible object storage, or Vercel Blob.
You represent that you have the rights necessary to upload and use any files and that doing so does not violate any law or third-party rights.
@@ -90,7 +90,7 @@ You represent that you have the rights necessary to upload and use any files and
## Exports (PDF)
-When you request a PDF export, the Service renders the document in your own browser using the Forme PDF engine. Your resume content is processed locally by the JavaScript running on your device; it is not transmitted to a separate rendering service for the purpose of generating the PDF.
+When you download a PDF from the editor, the Service renders the document in your own browser using the Forme PDF engine; your resume content is processed locally on your device. PDFs of public resumes and PDFs requested through the API are rendered by the Service's own server with the same engine. In neither case is your content transmitted to a separate third-party rendering service.
---
@@ -98,9 +98,9 @@ When you request a PDF export, the Service renders the document in your own brow
The Service may integrate with third parties depending on configuration and your choices, including:
-- OAuth providers (e.g., Google/GitHub/custom OAuth) for sign-in
-- Storage providers (S3-compatible)
-- AI providers (OpenAI, Google, Anthropic) if you enable AI features and provide your own API key
+- OAuth providers (e.g., Google/GitHub/LinkedIn/custom OAuth) for sign-in
+- Storage providers (S3-compatible or Vercel Blob)
+- AI providers (for example, OpenAI, Anthropic, or Google Gemini) if you enable AI features and provide your own API key
Your use of third-party services may be subject to their own terms and policies. The Service Operator is not responsible for third-party services outside its control.
diff --git a/docs/self-hosting/docker.mdx b/docs/self-hosting/docker.mdx
index bfd9db7c3..37c4b9f2b 100644
--- a/docs/self-hosting/docker.mdx
+++ b/docs/self-hosting/docker.mdx
@@ -1,623 +1,369 @@
---
title: "Self-hosting with Docker"
-description: "How to self-host Reactive Resume with Docker (Postgres only), with an environment variable reference and troubleshooting tips."
+description: "Run your own Reactive Resume server with Docker Compose and PostgreSQL: set up, create the first account, check health, update and back up."
---
-
- **From v5.1.0 onwards** — PDF generation now runs entirely client-side with the Forme PDF engine (WebAssembly). New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
-
+This guide sets up your own Reactive Resume server with Docker Compose. You end up with two containers, the app and a
+PostgreSQL database, reachable at an address you choose. It's the same app that runs on `https://rxresu.me`, and your data stays on
+your hardware.
-## Overview
+## Before you start
-Reactive Resume can be self-hosted with Docker. These are the services you'll need:
+You need:
-The official image runs one application container that serves both the web app and API. PostgreSQL must run as a
-separate service and the app connects to it through `DATABASE_URL`; no all-in-one image with an embedded database is
-planned. Follow the [Docker Compose quickstart](#quickstart-using-docker-compose) below for the supported setup.
+- A Linux, macOS or Windows host with Docker Engine and the Docker Compose plugin (or Docker Desktop).
+- At least 1 vCPU and 1 GB of memory for the app. Allow 2 GB when PostgreSQL runs on the same host.
+- Disk space for the database and uploaded pictures. 10 GB is plenty to start.
+- For anything beyond a test on your own machine: a domain name and a reverse proxy that serves it over HTTPS. See
+ [Deployment examples](/self-hosting/examples) for Traefik, Caddy and nginx.
-
- Stores accounts, resumes, and application data.
-
- SMTP for verification emails, password reset, etc. If not configured, emails are logged to the server console.
-
-
- Use S3-compatible storage, or local persistent storage via /app/data.
-
-
+## How the pieces fit
-You can pull the latest app image from:
+The official image runs a single Node.js process on port `3000`. It serves the web app, the API, the MCP server and
+uploaded files from one address. PDFs are rendered by the app itself, in the browser or on the server, so there is no
+separate printer service.
-- Docker Hub: `amruthpillai/reactive-resume:latest`
-- GitHub Container Registry: `ghcr.io/reactive-resume/reactive-resume:latest`
+| Service | Required | Purpose |
+| --- | --- | --- |
+| PostgreSQL | Yes | Stores accounts, resumes, cover letters and applications. Runs as its own container or managed database. |
+| Upload storage | Yes | A folder mounted at `/app/data`, or an S3-compatible bucket instead. |
+| SMTP server | No | Sends verification and password reset emails. Without it, emails are written to the app log. |
+| Redis | No | Shares state between several app processes and lets Assistant replies survive a page reload. |
-## Minimum requirements
+The image is published to two registries with the same tags:
-
- Docker Engine + Docker Compose plugin (or Docker Desktop).
- 1 vCPU / 1 GB RAM minimum (2 GB recommended if Postgres runs on the same host).
- Enough for Postgres + uploads (start with 10-20 GB and scale as needed).
-
+- Docker Hub: `amruthpillai/reactive-resume`
+- GitHub Container Registry: `ghcr.io/reactive-resume/reactive-resume`
-## Smallest supported setup
+| Tag | Contents |
+| --- | --- |
+| `latest` | The newest release. |
+| `v6`, `v6.0`, `v6.0.0` | A major, minor or exact release. Pin `v6` to get fixes without jumping to the next major version. |
+| `nightly` | The current `main` branch. For testing only. |
-1. Provide a separate, healthy PostgreSQL service. In the example below, its service name is `postgres`.
-2. Put `APP_URL`, `DATABASE_URL`, and `AUTH_SECRET` in a private `.env` file. Set the database host in `DATABASE_URL`
- to a name or address reachable from the app container.
-3. If S3 is disabled, mount persistent storage for app uploads at `/app/data`.
-4. Attach the `reactive-resume` app service and PostgreSQL service to the intended private container network. Do not
- expose PostgreSQL to the public internet.
-5. Launch the services with the [Docker Compose quickstart](#quickstart-using-docker-compose) below.
-6. Wait for PostgreSQL, automatic migrations, and the app health check before opening the UI.
+Images are built for `linux/amd64` and `linux/arm64`, so they run on most servers, Apple silicon, and a Raspberry
+Pi 4 or newer running a 64-bit operating system.
-The repository's full `compose.yml` also defines optional Redis and S3-compatible storage services. Those services are
-not required for the core resume workflow; use the two-service example below when you only need the app and PostgreSQL.
-The repository file is a broader source-build stack and publishes administration ports for local use. Before using it
-on an internet-facing host, remove those host port mappings, bind them to loopback, or restrict them with a firewall.
-
-## Quickstart using Docker Compose
-
-Create a new folder (for example `reactive-resume/`) with:
-
-- `compose.yml`
-- `.env`
-- a persistent data directory for uploads (for example `./data`)
+## Set up with Docker Compose
-
- Start by creating a `.env` file next to your `compose.yml`.
-
- The Compose example below reads `.env` directly. If you use the repository's `compose.yml` instead, copy its `.env.example` into the same folder. That file supplies defaults before your `.env` overrides are applied.
-
-```bash .env
-# --- Server ---
-TZ="Etc/UTC"
-APP_URL="http://localhost:3000"
-
-# --- Database (PostgreSQL) ---
-DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
-
-# --- Authentication ---
-# Generated using `openssl rand -hex 32`
-AUTH_SECRET=""
-# Better Auth dashboard API key (optional)
-BETTER_AUTH_API_KEY=""
-
-# Social Auth (Google, optional)
-GOOGLE_CLIENT_ID=""
-GOOGLE_CLIENT_SECRET=""
-
-# Social Auth (GitHub, optional)
-GITHUB_CLIENT_ID=""
-GITHUB_CLIENT_SECRET=""
-
-# Social Auth (LinkedIn, optional)
-LINKEDIN_CLIENT_ID=""
-LINKEDIN_CLIENT_SECRET=""
-
-# Custom OAuth Provider
-OAUTH_PROVIDER_NAME=""
-OAUTH_CLIENT_ID=""
-OAUTH_CLIENT_SECRET=""
-# Use EITHER discovery URL (preferred for OIDC-compliant providers):
-OAUTH_DISCOVERY_URL=""
-# OR manual URLs (all three required if not using discovery):
-OAUTH_AUTHORIZATION_URL=""
-OAUTH_TOKEN_URL=""
-OAUTH_USER_INFO_URL=""
-# Custom scopes (space-separated, defaults to "openid profile email")
-OAUTH_SCOPES=""
-
-# --- Email (optional) ---
-# If all keys are disabled, the app logs the email to be sent to the console instead.
-SMTP_HOST=""
-SMTP_PORT="587"
-SMTP_USER=""
-SMTP_PASS=""
-SMTP_FROM="Reactive Resume "
-SMTP_SECURE="false"
-
-# --- Storage (optional) ---
-# If all S3 keys are disabled, the app uses local filesystem storage instead.
-# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
-S3_ACCESS_KEY_ID=""
-S3_SECRET_ACCESS_KEY=""
-S3_REGION="us-east-1"
-S3_ENDPOINT=""
-S3_BUCKET=""
-# Set to "true" for path-style URLs (https://endpoint/bucket), common with MinIO, SeaweedFS, etc.
-# Set to "false" for virtual-hosted-style URLs (https://bucket.endpoint), common with AWS S3, Cloudflare R2, etc.
-S3_FORCE_PATH_STYLE="false"
-
-# --- AI features (optional) ---
-# ENCRYPTION_SECRET is required for saved AI providers. REDIS_URL is also required for the AI Agent workspace.
-# The rest of Reactive Resume can run without these.
-REDIS_URL=""
-# Generated using `openssl rand -hex 32`
-ENCRYPTION_SECRET=""
-
-# --- Feature Flags ---
-FLAG_DISABLE_SIGNUPS="false"
-FLAG_DISABLE_EMAIL_AUTH="false"
-FLAG_DISABLE_IMAGE_PROCESSING="false"
-FLAG_DISABLE_API_RATE_LIMIT="false"
-# Allows any parseable dynamic OAuth redirect URI. Keep false unless this is a trusted self-hosted deployment.
-FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI="false"
-# Allows unsafe/private/non-public AI provider base URLs. Keep false unless this is a trusted self-hosted deployment.
-FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
-```
+
+ ```bash
+ mkdir reactive-resume && cd reactive-resume
+ ```
+ The next steps create two files in it: `.env` for settings and `compose.yml` for the containers.
-
- Generate a strong secret and paste it into `AUTH_SECRET`.
+
+ Create `.env` with the three required settings:
+
+ ```bash .env
+ # The address people will use to reach your instance.
+ APP_URL="http://localhost:3000"
+
+ # "postgres" is the database service name in compose.yml.
+ POSTGRES_PASSWORD="replace-with-a-database-password"
+ DATABASE_URL="postgresql://postgres:replace-with-a-database-password@postgres:5432/postgres"
+
+ # Generate with: openssl rand -hex 32
+ AUTH_SECRET=""
+ ```
+
+ Generate a value for `AUTH_SECRET` and a database password, and paste them in. Use the same password in
+ `POSTGRES_PASSWORD` and `DATABASE_URL`.
+
- ```bash Linux/macOS
- openssl rand -hex 32
- ```
-
- ```bash Linux/macOS (alternative)
- head -c 32 /dev/urandom | hexdump -v -e '/1 "%02x"'
- ```
-
- ```powershell Windows
- [byte[]]$bytes = New-Object byte[] 32; (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes); $bytes | ForEach-Object { "{0:x2}" -f $_ } | Out-String -Stream | ForEach-Object { $_.Trim() } | Write-Host -NoNewline
- ```
+ ```bash Linux and macOS
+ openssl rand -hex 32
+ ```
+ ```powershell Windows
+ # PowerShell 7 or newer
+ [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLower()
+ ```
+ If you will serve the instance from a domain, set `APP_URL` to that address now, for example
+ `https://resume.example.com`. Every other setting is optional; the
+ [environment variable reference](/self-hosting/environment-variables) lists them all.
- This setup runs Postgres and Reactive Resume on a private Docker network.
-
-
-
-```yaml compose.yml
-services:
- postgres:
- image: postgres:17
- restart: unless-stopped
- environment:
- POSTGRES_DB: postgres
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- volumes:
- - postgres_data:/var/lib/postgresql
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
- interval: 10s
- timeout: 5s
- retries: 10
-
- reactive-resume:
- image: amruthpillai/reactive-resume:latest
- # image: ghcr.io/reactive-resume/reactive-resume:latest
- restart: unless-stopped
- ports:
- - "3000:3000"
- env_file:
- - .env
- volumes:
- # Used when S3 is not configured; keeps uploads persistent
- - ./data:/app/data
- depends_on:
+ ```yaml compose.yml
+ services:
postgres:
- condition: service_healthy
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
- interval: 30s
- timeout: 10s
- retries: 3
+ image: postgres:18
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: postgres
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ volumes:
+ - postgres_data:/var/lib/postgresql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
-volumes:
- postgres_data:
-```
+ reactive-resume:
+ image: amruthpillai/reactive-resume:latest
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ env_file: .env
+ volumes:
+ - reactive_resume_data:/app/data
+ depends_on:
+ postgres:
+ condition: service_healthy
-
+ volumes:
+ postgres_data:
+ reactive_resume_data:
+ ```
+
+ PostgreSQL is only reachable from the app container, never from outside. Uploads live in the
+ `reactive_resume_data` volume, so they survive when the container is recreated. The image has a built-in health
+ check, so Compose reports the app as `healthy` once it's ready.
- Prefer pulling from Docker Hub? Keep amruthpillai/reactive-resume:latest. Prefer GHCR? Swap it to ghcr.io/reactive-resume/reactive-resume:latest.
+ To use GitHub Container Registry instead of Docker Hub, change the image to
+ `ghcr.io/reactive-resume/reactive-resume:latest`.
-
-
- In Docker, the Reactive Resume server listens on PORT and serves both the API and the built web app.
- The default image uses PORT=3000, so the example maps 3000:3000. If you change
- PORT, update the container-side port mapping and health check to match.
-
-
-
-
+
+ ```bash
+ docker compose up -d
+ docker compose logs -f reactive-resume
+ ```
-```bash
-docker compose up -d
-```
+ On the first start the app creates its database tables. When the log shows a line containing
+ `Up and running on`, press Ctrl C to stop following the log. Within a minute, `docker compose ps` shows
+ the app as `healthy`.
+
-```bash
-docker compose ps
-```
+
+ Open your `APP_URL` in a browser and create an account; see [Creating an account](/guides/creating-an-account).
-```bash
-docker compose logs -f reactive-resume
-```
-
-
-
- Reactive Resume should now be available at your `APP_URL` (for the example above: `http://localhost:3000`).
+ You're signed in right away. Reactive Resume also sends a verification email, but you don't have to open it to
+ use the app. Without SMTP settings the email isn't sent; its link is written to the app log instead:
+ ```bash
+ docker compose logs reactive-resume | grep verify-email
+ ```
-## Unraid and other homelab platforms
+Your instance is running. Next, you might want to:
-Use your platform's generic container configuration to create two separately managed containers: one for Reactive
-Resume and one for PostgreSQL. No official Unraid Community Applications template is provided.
-
-- Use the official `amruthpillai/reactive-resume:latest` or `ghcr.io/reactive-resume/reactive-resume:latest` image for the
- app container.
-- Map the app's container port `3000` to the host port you want to use.
-- Connect both containers to a private container network. Set the host in `DATABASE_URL` to the PostgreSQL container or
- service name reachable on that network.
-- Set `APP_URL`, `DATABASE_URL`, and `AUTH_SECRET` as private environment variables.
-- When S3 is disabled, map persistent app upload storage to `/app/data`.
-- Give PostgreSQL its own persistent data volume and manage it independently from the app container.
+- **Keep it private.** After your own account exists, add `FLAG_DISABLE_SIGNUPS="true"` to `.env` and run
+ `docker compose up -d`. Nobody else can sign up; existing accounts keep working.
+- **Send real emails.** Add the `SMTP_*` settings from the [reference](/self-hosting/environment-variables#email).
+- **Turn on AI features.** Add `ENCRYPTION_SECRET` (another `openssl rand -hex 32` value). People can then connect
+ their own AI provider and use the Assistant. See [Add Redis and turn on AI](/self-hosting/examples#add-redis-and-turn-on-ai).
+- **Serve it over HTTPS.** Put a reverse proxy in front and set `APP_URL` to the public `https://` address. See
+ [Deployment examples](/self-hosting/examples).
- `localhost` inside the Reactive Resume container refers to that app container. It cannot reach a separate PostgreSQL
- container. Use the PostgreSQL container or service name on the private network instead.
+ `APP_URL` must match the address in the browser's address bar exactly, including `https://`. If it doesn't, sign-in
+ redirects go to the wrong place and session cookies don't stick.
-After starting both containers, wait for PostgreSQL to become healthy and check the app logs while automatic migrations
-run. Open the UI only after the app health check succeeds.
+## What happens at startup
-## How startup works (database migrations)
+Each time the app container starts, it:
-
- On every start, the server automatically runs database migrations before serving traffic. If migrations fail
- (usually due to a DB connection issue), the container will exit with an error.
-
+1. Applies any new database migrations. Several containers starting at once take turns, so only one migrates.
+2. Compares the database schema with what the migrations expect. A mismatch is logged; set `STRICT_SCHEMA_CHECK=true`
+ to stop instead.
+3. With local storage, checks that `/app/data` (or `LOCAL_STORAGE_PATH`) is writable, and stops if it isn't.
+4. Starts serving on `PORT`.
-## Environment variables
+If step 1 or 3 fails, the container exits with the reason in its log. This is almost always a wrong `DATABASE_URL`, a
+database that isn't reachable yet, or a storage folder the container can't write to.
-
-
-
- -
-
APP_URL
-
- -
-
DATABASE_URL
-
- -
-
AUTH_SECRET
-
-
-
-
-
- -
- SMTP (
SMTP_*)
-
- -
- Social auth (
GOOGLE_*, GITHUB_*, LINKEDIN_*,{" "}
- OAUTH_*)
-
- -
- S3 storage (
S3_*)
-
- -
- AI providers and AI Agent workspace (
ENCRYPTION_SECRET, REDIS_URL)
-
- -
- Feature flags (
FLAG_*)
-
-
-
-
+## Check the health endpoint
-
-
- - **`TZ`**: Sets the container timezone (affects logs and server-side timestamps). Recommended: `Etc/UTC`.
- - **`APP_URL`**: Canonical/public URL for your instance (used for absolute URLs, redirects, and auth flows). If behind a reverse proxy, set this to your public HTTPS URL (for example, `https://resume.example.com`).
- - **`PORT`**: Port the production Docker container listens on. Defaults to `3000` in the official image. If you change it, update your Compose port mapping and health check from `3000` to the new container port.
- - **`SERVER_PORT`**: Used only for local development when the Vite web app and Hono server run as separate processes. It is ignored by the production Docker image.
-
-
-
- - **`DATABASE_URL`**: Postgres connection string in the format `postgresql://USER:PASSWORD@HOST:PORT/DATABASE`. - In
- Docker Compose, set `HOST` to the Postgres service name (e.g. `postgres`), not `localhost`. - If your password
- contains special characters (`@`, `#`, `:`), URL-encode it. - For managed Postgres, add provider-specific params (for
- example `?sslmode=require`) when needed.
-
-
-
- **`AUTH_SECRET`**: Secret used to secure authentication. Changing it invalidates existing sessions.
-
- Generate with:
-
-
+`GET /api/health` reports whether the app can reach its database, storage and, when configured, Redis. It answers
+`200` when everything is healthy and `503` when any check fails. The image's built-in health check calls it every 30
+seconds.
```bash
-openssl rand -hex 32
+curl http://localhost:3000/api/health
```
-
+```json
+{
+ "service": "reactive-resume",
+ "version": "6.0.0",
+ "status": "healthy",
+ "timestamp": "2026-09-30T09:00:00.000Z",
+ "uptime": "3600.12s",
+ "database": { "status": "healthy", "latencyMs": 2 },
+ "storage": {
+ "type": "local",
+ "status": "healthy",
+ "message": "Local filesystem storage is accessible and has read/write permission.",
+ "latencyMs": 1
+ }
+}
+```
- **`GOOGLE_CLIENT_ID`** / **`GOOGLE_CLIENT_SECRET`** (optional): Enables Google sign-in.
+The `redis` field appears only when `REDIS_URL` is set. When a check fails, its entry shows `"status": "unhealthy"`
+and the app log has the details. Each check times out after 1.5 seconds.
- **`GITHUB_CLIENT_ID`** / **`GITHUB_CLIENT_SECRET`** (optional): Enables GitHub sign-in.
+Use this endpoint for load balancer or orchestrator readiness checks. Restarting the app doesn't fix a database or
+storage outage, so avoid using it to kill containers.
- **`LINKEDIN_CLIENT_ID`** / **`LINKEDIN_CLIENT_SECRET`** (optional): Enables LinkedIn sign-in.
+## Update your instance
- **`BETTER_AUTH_API_KEY`** (optional): Enables Better Auth dashboard integrations.
+
+
+ Back up the database and uploads before every update. See [Back up your data](#back-up-your-data).
+
- **Custom OAuth provider** (optional):
- - **`OAUTH_PROVIDER_NAME`**: Display name in the UI
- - **`OAUTH_CLIENT_ID`** / **`OAUTH_CLIENT_SECRET`**: Required for any custom OAuth provider
- - **`OAUTH_SCOPES`**: Space-separated scopes (defaults to `openid profile email`)
+
+ ```bash
+ docker compose pull reactive-resume
+ ```
+
- Configure endpoints using **one** of these methods:
- - **Option A (OIDC Discovery, preferred)**: Set `OAUTH_DISCOVERY_URL` to your provider's `.well-known/openid-configuration` URL
- - **Option B (manual URLs)**: Set all three: `OAUTH_AUTHORIZATION_URL`, `OAUTH_TOKEN_URL`, and `OAUTH_USER_INFO_URL`
+
+ ```bash
+ docker compose up -d reactive-resume
+ docker compose logs -f reactive-resume
+ ```
-
+ The new version applies its migrations on startup. PostgreSQL keeps running.
+
+
-
- If SMTP is not configured, the app logs emails to the server console instead of sending them.
+Updating from v5? Read [Upgrading to v6](/self-hosting/upgrading-to-v6) first. It covers a one-time command for resumes
+that still use the old style editor.
- - Email delivery is enabled only when **all** of `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, and `SMTP_FROM` are set.
- - **`SMTP_HOST`**: SMTP host (if empty, email sending is disabled).
- - **`SMTP_PORT`**: Defaults to `587` in the app.
- - **`SMTP_USER`** / **`SMTP_PASS`**: SMTP credentials.
- - **`SMTP_FROM`**: Default from address (for example, `Reactive Resume `).
- - **`SMTP_SECURE`**: `"true"` or `"false"` (string). Match your provider settings.
+PostgreSQL is updated separately. Pulling a new app image never changes the database server's major version. To move
+to a new major PostgreSQL version, dump the database, start the new version with an empty volume, and restore.
-
+## Back up your data
-
- - **Default (local)**: If all `S3_*` values are empty, uploads are stored under `/app/data` in the official image.
- - Mount local uploads to persistent storage (for example `./data:/app/data`) or uploads can be lost on container recreation.
- - **`LOCAL_STORAGE_PATH`** (optional): Overrides the local data directory. Defaults to `/app/data` in the official Docker image and `/data` in development. The container validates this path is writable at startup and refuses to start otherwise.
- - **Rootless Docker**: `/app/data` remains the container path. Prefer the named volume from the example Compose file, or make sure a bind-mounted host directory is writable by the container's `node` user mapping.
- - **S3/S3-compatible**: Configure `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`, `S3_ENDPOINT`, and `S3_BUCKET`.
- - **Agent attachments/private objects**: The AI Agent workspace requires S3-compatible storage for private objects. Local storage rejects private objects.
- - **`S3_FORCE_PATH_STYLE`** controls bucket addressing (defaults to `"false"`):
- - `"true"` for path-style URLs (`https://endpoint/bucket`) common with MinIO/SeaweedFS.
- - `"false"` for virtual-hosted-style URLs (`https://bucket.endpoint`) common with AWS S3 / Cloudflare R2.
-
+Your data lives in two places. Back up both, on a schedule, and test that you can restore them.
-
- Saved AI provider management is usable only when **`ENCRYPTION_SECRET`** is configured. The AI Agent workspace also requires **`REDIS_URL`**. The rest of Reactive Resume can run without them.
+- **Database.** Dump it with `pg_dump`:
- - **`REDIS_URL`**: Redis connection string used by the AI Agent workspace.
- - **`ENCRYPTION_SECRET`**: Secret used to encrypt saved AI provider credentials. Generate with `openssl rand -hex 32`.
- - Live web research depends on the selected AI provider/model supporting native web search. The app does not run its own URL crawler.
+ ```bash
+ docker compose exec -T postgres pg_dump -U postgres -d postgres --format=custom > reactive-resume.dump
+ ```
- If you use the Postgres-only Compose example above and want the AI Agent workspace, add a Redis service or use managed Redis, then set `REDIS_URL`.
-
+ Restore into an empty database with `pg_restore`:
-
- - **`FLAG_DISABLE_SIGNUPS`**: Disables new signups (web app and server). Useful for private instances.
- - **`FLAG_DISABLE_EMAIL_AUTH`**: Disables email/password login entirely. Also disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/LinkedIn/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. Useful when only SSO is required.
- - **`FLAG_DISABLE_IMAGE_PROCESSING`**: Disables image processing. This is useful if you are using a machine with limited resources, like a Raspberry Pi.
- - **`FLAG_DISABLE_API_RATE_LIMIT`**: Disables API rate limiting for authentication endpoints. Rate limiting is enabled by default in production to prevent abuse.
- - **`FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI`**: Allows dynamic OAuth client registration to use any parseable redirect URI, including custom schemes, private hosts, and non-loopback `http://` URLs. **Warning: enabling this on a public or multi-tenant deployment can enable phishing or token exfiltration.** Only enable on trusted, self-hosted deployments.
- - **`FLAG_ALLOW_UNSAFE_AI_BASE_URL`**: Allows AI providers to be configured with unsafe, private, or non-public base URLs, including `http://` and private/loopback addresses (for example, a local Ollama instance at `http://192.168.1.10:11434`). Public HTTPS provider URLs remain the safe default. **Warning: enabling this on a multi-tenant deployment is an SSRF risk.** Only enable on trusted, self-hosted deployments.
-
-
+ ```bash
+ docker compose exec -T postgres pg_restore -U postgres -d postgres --clean --if-exists < reactive-resume.dump
+ ```
-## Updating your installation
+- **Uploads.** With local storage, copy the contents of the `reactive_resume_data` volume (or your bind-mounted
+ folder). With S3, turn on bucket versioning or replication in your provider.
-To update an installation created from the image-based quickstart above to the latest version, follow only the numbered
-steps below. If you use the repository's full `compose.yml`, use the separate source-build path after these steps.
+Keep a copy of your `.env` too. Without the same `AUTH_SECRET` and `ENCRYPTION_SECRET`, a restored instance signs
+everyone out, breaks two-factor authentication and loses saved AI provider keys.
-1. **Back up your database and uploads first.** Do this before every update.
+## Build from the repository
- The database and upload storage are independent resources. Recreating the app container must preserve both the
- PostgreSQL data volume or managed database and the `/app/data` mount or S3 bucket.
-
-2. **Pull the latest app image.** Leave the PostgreSQL service unchanged.
-
- ```bash
- docker compose pull reactive-resume
- ```
-
-3. **Recreate only the app container** to run the new image.
-
- ```bash
- docker compose up -d --no-deps reactive-resume
- ```
-
-4. **Check migration/startup logs** after deploy.
-
- ```bash
- docker compose logs -f reactive-resume
- ```
-
-5. **(Optional) Remove old, unused Docker images** to free up disk space.
- ```bash
- docker image prune -f
- ```
-
-### Update from the repository Compose file
-
-The repository's full `compose.yml` names its build-only app service `reactive_resume`. After confirming its dependencies
-are healthy, rebuild that service and follow its migration/startup logs with:
+The repository's own `compose.yml` builds the image from source and also starts Redis and SeaweedFS (S3-compatible
+storage). It reads `.env.example` first and then your `.env`.
```bash
+git clone https://github.com/reactive-resume/reactive-resume.git
+cd reactive-resume
+cp .env.example .env # then set APP_URL, AUTH_SECRET and ENCRYPTION_SECRET
+docker compose up -d --build
+```
+
+To update, pull the latest code and rebuild only the app service:
+
+```bash
+git pull
docker compose up -d --build --no-deps reactive_resume
-docker compose logs -f reactive_resume
```
-Do not run `docker compose pull` for this build-only service.
+
+ The repository file publishes PostgreSQL (`5432`) and SeaweedFS (`8333`) on the host, with default passwords. Remove
+ those `ports` entries or bind them to `127.0.0.1` before using it on a server reachable from the internet.
+
-This process updates the app container and automatically runs DB migrations on startup. If migration fails, restore from backup and fix configuration before retrying.
+## Unraid, Synology and other homelab platforms
-Update PostgreSQL separately from the app. Choose a supported, major-pinned PostgreSQL image or select the target version
-through your managed provider, then follow that image's, host's, or provider's upgrade procedure. Back up the database
-and verify that the backup can be restored before a major-version upgrade. Pulling a new app image and running app
-migrations do not upgrade the PostgreSQL server.
+There is no official app template. Use your platform's generic container settings:
-### Converting styles from the old style editor (one time)
+- Create two containers: one from `amruthpillai/reactive-resume:latest` and one from `postgres:18`. Give PostgreSQL its
+ own persistent volume at `/var/lib/postgresql`.
+- Put both containers on the same private network, and use the PostgreSQL container's name as the host in
+ `DATABASE_URL`.
+- Map the app's container port `3000` to any free host port.
+- Map a persistent folder to `/app/data`. The app runs as the `node` user (UID 1000), so that user must be able to
+ write to it.
+- Set `APP_URL`, `DATABASE_URL` and `AUTH_SECRET` as environment variables.
-Resumes and cover letters styled with the old style editor (before Custom Styles became CSS) keep those styles only once
-they're converted to Custom Styles. The conversion isn't run automatically: after updating to the first version without
-the old editor, run it once from the app container. It uses the container's `DATABASE_URL`.
-
-1. **Dry run.** Converts every affected resume in memory and reports the counts; nothing is written.
-
- ```bash
- docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs
- ```
-
-2. **Convert.** Every replaced stylesheet is saved to the backup file first. Only the stylesheet of each resume or letter
- changes, and running it again (for example after an interruption) skips what's already converted.
-
- ```bash
- docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs --apply --backup /app/data/legacy-styles-backup.ndjson
- ```
-
-3. **Undo, if needed.** Puts back the recorded stylesheets, except on resumes edited since.
-
- ```bash
- docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs --restore /app/data/legacy-styles-backup.ndjson
- ```
-
-## Backups (recommended)
-
-Reactive Resume stores data in two places: the PostgreSQL database and file uploads (either local storage or S3). Back up both on a regular schedule.
-
-Test restores for both resources. An app container backup alone does not include the separate database or uploads, and
-recreating the app container must not replace either persistent resource.
-
-### Database backups
-
-Your PostgreSQL database holds all user accounts, resumes, and application data. Use `pg_dump` to take periodic backups and store them somewhere secure. Many providers of managed PostgreSQL also offer automated backups that handle scheduling, retention, and restores for you.
-
-### Upload backups
-
-If you're using local storage (the `./data` directory), include this directory in your regular backup routine. A simple approach is to use `rsync` or a similar tool to copy the directory to a remote server or cloud storage.
-
-If you're using S3-compatible storage, consider enabling versioning on your bucket to protect against accidental deletions. Most S3 providers also support lifecycle rules for automatic cleanup of old versions and cross-region replication for disaster recovery.
-
-## Health checks
-
-Reactive Resume exposes a health check endpoint at `/api/health` that verifies the application and its dependencies. It checks **database**, **storage**, and **Redis** when configured; if a configured dependency is unhealthy, the endpoint returns HTTP `503`.
-
-### How it works
-
-The Docker Compose configuration includes a health check that periodically calls the `/api/health` endpoint:
-
-```yaml
-healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
- interval: 30s
- timeout: 10s
- retries: 3
-```
-
-When the health check fails, Docker marks the container as **unhealthy**. This status is visible when running `docker compose ps` or `docker ps`.
-
-### Reverse proxy integration
-
-Most reverse proxies (such as **Traefik**, **Caddy**, or **nginx** with upstream health checks) can use Docker's health status to make routing decisions:
-
-- **Healthy containers** receive traffic as normal
-- **Unhealthy containers** are automatically removed from the load balancer pool
-
-This is particularly useful in high-availability setups where you have multiple instances of Reactive Resume. If one instance becomes unhealthy (for example, it loses database or storage connectivity), the reverse proxy will stop routing traffic to it until it recovers.
-
-
- If you're using **Traefik**, it automatically respects Docker health checks when using the Docker provider. Unhealthy
- containers are excluded from routing without any additional configuration.
-
-
-### Manually checking health
-
-To check your instance yourself:
-
-```bash
-# From outside the container
-curl -f http://localhost:3000/api/health
-
-# Check Docker's health status
-docker compose ps
-```
-
-A healthy response returns HTTP 200. If you get a different status code, the JSON response body says what failed. If the connection is refused or times out there is no response to read, so check the container and reverse-proxy logs instead.
+
+ Inside the app container, `localhost` means the app container itself. It never reaches a database in another
+ container, so don't use `localhost` in `DATABASE_URL`.
+
## Troubleshooting
-
- - **Common cause**: database migrations failed (often a bad `DATABASE_URL`).
- - **What to do**:
- Check logs for migration errors and database connectivity details:
- ```bash
- docker compose logs -f reactive-resume
- ```
+
+ Read the log with `docker compose logs reactive-resume`. The last lines name the problem:
+
+ - `Invalid environment variables`: a required variable is missing or malformed. The message names it.
+ - `Database migrations failed` or `ECONNREFUSED`: `DATABASE_URL` is wrong or PostgreSQL isn't reachable. Check the
+ host name, password (URL-encode special characters) and that PostgreSQL is healthy.
+ - `Local storage path is not writable`: the folder mounted at `/app/data` isn't writable by UID 1000. Fix its
+ owner, or use a named volume.
-
- - **Common cause**: `APP_URL` doesn't match the URL you're actually using (especially behind a reverse proxy), or
- you're serving HTTPS while `APP_URL` is `http://...`. - **Fix**: set `APP_URL` to your canonical public HTTPS URL and
- restart the container.
-
+
+ `APP_URL` doesn't match the address you're using, or you're on `https://` while `APP_URL` says `http://`. Set
+ `APP_URL` to the exact public address and run `docker compose up -d`.
+
-
- - **Common cause**: PDFs are now rendered in the browser with the Forme PDF engine (WebAssembly), so failures usually come from a
- blocked download, an extreme browser memory limit, or a custom CSP that blocks WebAssembly (it needs `'wasm-unsafe-eval'` in `script-src`). - **Checks**: confirm
- the browser is up to date, the page hasn't been opened in a restricted iframe, and that no extension is intercepting
- the download. There is no server-side printer to inspect.
-
+
+ Emails are only sent when `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` are all set. Until then they're
+ written to the app log. If they're set, check `SMTP_PORT` and `SMTP_SECURE` against your provider's settings and
+ look for SMTP errors in the log.
+
-
- - **Common cause**: storage health failed (not only database). - **Fix**: inspect the endpoint response payload and
- check the `storage` field: http://127.0.0.1:3000/api/health
-
+
+ Look at which entry says `unhealthy`. For `storage`, check the `/app/data` mount or your S3 settings. For `redis`,
+ check that Redis is running and `REDIS_URL` is correct.
+
-
- - **Cause**: local upload storage wasn't mounted to a persistent volume. - **Fix**: add a volume mount like
- `./data:/app/data` and redeploy.
-
+
+ Nothing persistent was mounted at `/app/data`, so uploads lived inside the old container. Mount a volume there as
+ in the example above. Pictures uploaded before the change are gone.
+
-
- - **Expected behavior**: if SMTP isn't fully configured, the app logs emails to the console. - **Fix**: set
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, and `SMTP_FROM`, then verify `SMTP_PORT` and `SMTP_SECURE`.
-
+
+ The app is using virtual-hosted addresses, which put the bucket name in front of the endpoint. MinIO, SeaweedFS
+ and most self-hosted services need path-style addresses. Set `S3_FORCE_PATH_STYLE="true"`.
+
-
- - **Common cause**: redirect URI is not the app origin or a local loopback callback. - **Fix**: use an app-origin or loopback redirect URI, or enable `FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI` only on a trusted self-hosted deployment that needs arbitrary redirect URIs.
-
+
+ `ENCRYPTION_SECRET` isn't set. Set it to at least 32 characters and recreate the container. A shorter value
+ stops the app at startup with `Invalid environment variables`.
+
-
- - **Common cause**: The S3 client is using virtual-hosted-style addressing (prepending the bucket name to the endpoint), but your S3-compatible storage expects path-style addressing.
- - **Symptom**: Error message like `getaddrinfo ENOTFOUND mybucket.s3-server.com` when your endpoint is `s3-server.com`.
- - **Fix**: Set `S3_FORCE_PATH_STYLE="true"` in your environment. This is required for most self-hosted S3-compatible services like MinIO, SeaweedFS, etc.
+
+ The editor renders PDFs in the browser with WebAssembly. If you add your own Content Security Policy at the proxy,
+ allow `'wasm-unsafe-eval'` in `script-src`. Also check for browser extensions that block downloads.
-## Serve a public resume at the instance root
+## Related pages
-To display one public resume at `/` instead of the marketing home, set the optional server environment variable `ROOT_RESUME_ID` on the application service:
-
-```yaml
-environment:
- APP_URL: https://resume.example.com
- ROOT_RESUME_ID: your-resume-id
-```
-
-Find the resume ID in its owner's builder URL: `/builder/`. The resume must already have **Allow Public Access** enabled in Sharing. This setting does not change its visibility. Password protection and the download-button preference still apply, and the ordinary `//` URL continues to work. Renaming the username or slug does not change the configured ID.
-
-Restart the application after setting or changing `ROOT_RESUME_ID`. With Docker Compose, run `docker compose up -d` to recreate the application with the new environment. Unset the variable or leave it blank, then restart, to restore the marketing home. A missing, deleted, or private target shows an unavailable page, including when its owner visits `/`.
-
-Keep `APP_URL` set to the public origin and proxy the whole application normally, including API, uploads, fonts, and assets. Root mode uses that configured origin for its canonical URL; it does not infer a domain from request headers. A successful password challenge returns visitors to `/`.
-
-This is a single-resume setting for one self-hosted instance. It does not register custom domains, manage DNS or TLS, or hide the rest of the application. Login and the dashboard remain available at their usual paths.
-
-## AI agent run duration
-
-Each individual agent run has a four-minute execution timeout, reserving up to one minute for saving and cleanup within the shared five-minute budget. This is the same on Docker and Vercel Hobby. Normal answers return as soon as they finish; the limit never applies to an entire conversation. Follow-up questions get a fresh timer. On timeout, completed edits and saved history remain available, and you can ask the agent to continue.
-
-Multiple application instances should share `REDIS_URL` and `DEPLOYMENT_NAMESPACE` for rate limits, resumable streams, cancellation, resume notifications, and view deduplication. Without Redis, all core resume features work on a single instance. Private agent attachments need S3-compatible storage or private Blob; local disk supports public uploads only.
+- [Environment variables](/self-hosting/environment-variables): every setting, with defaults.
+- [Deployment examples](/self-hosting/examples): reverse proxies, S3 storage, Redis and local AI.
+- [Self-hosting with Kubernetes](/self-hosting/kubernetes): the same setup as Kubernetes manifests.
+- [Checking service status](/guides/checking-service-status): the status of the hosted instance.
diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx
new file mode 100644
index 000000000..3fda26c1f
--- /dev/null
+++ b/docs/self-hosting/environment-variables.mdx
@@ -0,0 +1,210 @@
+---
+title: "Environment variables"
+description: "Every environment variable a self-hosted Reactive Resume server reads: required settings, database, sign-in, email, storage, AI, Redis and feature flags."
+---
+
+Reactive Resume is configured entirely through environment variables. This page lists every variable the server reads,
+grouped by what it controls. Only three are required: `APP_URL`, `DATABASE_URL` and `AUTH_SECRET`.
+
+For a working setup, start with [Self-hosting with Docker](/self-hosting/docker) and come back here when you want to
+turn on an optional feature.
+
+## How values are read
+
+- The server reads variables from its process environment. In a source checkout it also loads a `.env` file from the
+ workspace root. A variable already set in the environment always wins over the file.
+- An empty value counts as unset. `SMTP_HOST=""` is the same as not setting `SMTP_HOST` at all.
+- Values are validated at startup. If one is missing or malformed, the server stops and names the variable in its logs.
+- Boolean variables accept `true` or `false`. `1`/`0`, `yes`/`no` and `on`/`off` also work; any other value stops the
+ server.
+- Changes take effect after a restart. With Docker Compose, run `docker compose up -d` to recreate the container with
+ the new environment.
+
+
+ PDFs are rendered by the app itself, so there is no printer service to configure. The v4 and early v5 variables
+ `PRINTER_*`, `BROWSERLESS_*` and `CHROME_*` are no longer read; you can remove them.
+
+
+## Required
+
+| Variable | Description |
+| --- | --- |
+| `APP_URL` | The public address people use to reach your instance, for example `https://resume.example.com`. Must start with `http://` or `https://`. Used for sign-in redirects, OAuth callbacks, links in emails, public resume links, social previews and upload URLs. With an `https://` address, session cookies are marked secure. |
+| `DATABASE_URL` | PostgreSQL connection string: `postgresql://USER:PASSWORD@HOST:5432/DATABASE`. Must start with `postgres://` or `postgresql://`. URL-encode special characters in the password. Add provider options such as `?sslmode=require` when your database needs them. |
+| `AUTH_SECRET` | Secret used to sign sessions and encrypt sign-in data. Generate one with `openssl rand -hex 32`. |
+
+
+ Keep `AUTH_SECRET` the same for the life of your instance. Changing it signs everyone out and makes data encrypted with
+ it unreadable, including two-factor authentication secrets and the keys that sign API and MCP access tokens.
+
+
+## Server
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `PORT` | `3000` | Port the production server listens on. The official image sets it to `3000`. If you change it, update your port mapping and health check to match. |
+| `NODE_ENV` | `production` in the image | When `production`, the server listens on `PORT` and turns on rate limiting. Leave it as the image sets it. |
+| `SERVER_PORT` | `3001` | Port the server listens on when `NODE_ENV` is not `production`, which is the local development setup. Ignored by the image. |
+| `ROOT_RESUME_ID` | unset | Shows one public resume at `/` instead of the home page. See [Show one resume at your root address](/self-hosting/examples#show-one-resume-at-your-root-address). |
+
+## Database
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `DATABASE_MIGRATION_URL` | `DATABASE_URL` | Separate connection string for the migrations that run at startup. Set it when `DATABASE_URL` goes through a connection pooler (such as PgBouncer or a provider's pooled endpoint) that cannot run migrations. |
+| `DATABASE_POOL_MAX` | `10` | Maximum number of database connections each server process opens, from `1` to `100`. |
+| `STRICT_SCHEMA_CHECK` | `false` | After migrations, the server compares the live schema with what the migrations expect. By default a mismatch is logged and the server keeps starting. Set `true` to refuse to start instead. |
+
+## Sign-in
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `BETTER_AUTH_API_KEY` | unset | Connects the instance to the Better Auth dashboard service. Most instances leave it empty. |
+| `BETTER_AUTH_INTERNAL_URL` | `http://127.0.0.1:$PORT` | Address the server uses to reach itself when it verifies OAuth access tokens from API and MCP clients. Set it only if the server cannot reach itself on `127.0.0.1` at `PORT`. |
+
+### Social sign-in
+
+Each provider appears on the sign-in page once both of its variables are set. See [Single sign-on](/self-hosting/sso)
+for callback URLs and provider setup.
+
+| Variables | Provider |
+| --- | --- |
+| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google |
+| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | GitHub |
+| `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET` | LinkedIn |
+
+### Custom OAuth or OpenID Connect provider
+
+Use these to sign in through your own identity provider, such as Authentik, Keycloak or Authelia. The provider is
+turned on when the client ID and secret are set together with either a discovery URL or all three manual endpoints.
+Its callback URL is `APP_URL` followed by `/api/auth/callback/custom`.
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `OAUTH_PROVIDER_NAME` | `Custom OAuth` | Name shown on the sign-in button. |
+| `OAUTH_CLIENT_ID` | unset | Client ID issued by your provider. |
+| `OAUTH_CLIENT_SECRET` | unset | Client secret issued by your provider. |
+| `OAUTH_DISCOVERY_URL` | unset | The provider's `.well-known/openid-configuration` address. Preferred for OpenID Connect providers. |
+| `OAUTH_AUTHORIZATION_URL` | unset | Authorization endpoint. Use with the next two instead of a discovery URL. |
+| `OAUTH_TOKEN_URL` | unset | Token endpoint. |
+| `OAUTH_USER_INFO_URL` | unset | User info endpoint. |
+| `OAUTH_SCOPES` | `openid profile email` | Space-separated scopes to request. |
+
+## Email
+
+Reactive Resume sends email for account verification, password resets and email changes. Sending turns on only when
+`SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` and `SMTP_FROM` are all set. Until then, each email is written to the server log
+instead, so you can still copy verification links from there.
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `SMTP_HOST` | unset | SMTP server hostname. |
+| `SMTP_PORT` | `587` | SMTP server port. |
+| `SMTP_USER` | unset | SMTP username. |
+| `SMTP_PASS` | unset | SMTP password. |
+| `SMTP_FROM` | unset | Sender address, for example `Reactive Resume `. |
+| `SMTP_SECURE` | `false` | `true` connects with TLS from the start (usually port `465`). `false` upgrades the connection with STARTTLS when the server offers it (usually port `587`). |
+
+## Storage
+
+Uploads such as profile pictures, files attached to applications and Assistant attachments are stored in one of three
+backends.
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `STORAGE_BACKEND` | automatic | `local`, `s3` or `blob`. When unset: `s3` if `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` and `S3_BUCKET` are all set; otherwise `blob` on Vercel and `local` everywhere else. |
+| `LOCAL_STORAGE_PATH` | `/app/data` in the image | Folder for local uploads. Must be an absolute path. In a source checkout it defaults to `data/` in the repository. The server checks that it is writable at startup and refuses to start if it is not. |
+| `S3_ACCESS_KEY_ID` | unset | Access key for S3 or an S3-compatible service. |
+| `S3_SECRET_ACCESS_KEY` | unset | Secret key. |
+| `S3_BUCKET` | unset | Bucket name. The bucket can stay private: the app reads objects with its own credentials and serves them itself. |
+| `S3_REGION` | `us-east-1` | Bucket region. |
+| `S3_ENDPOINT` | AWS | Endpoint for non-AWS services, for example `https://.r2.cloudflarestorage.com` or `http://seaweedfs:8333`. |
+| `S3_FORCE_PATH_STYLE` | `false` | `true` for path-style addresses (`https://endpoint/bucket`), which MinIO and SeaweedFS need. `false` for virtual-hosted addresses (`https://bucket.endpoint`), used by AWS S3 and Cloudflare R2. |
+| `BLOB_READ_WRITE_TOKEN` | unset | Vercel Blob token. On Vercel it is injected when you connect a Blob store. |
+| `BLOB_STORE_ID` | unset | Vercel Blob store ID, when the token alone does not identify the store. |
+| `DEPLOYMENT_NAMESPACE` | `default` | Prefix for Redis keys and Blob paths, so several instances can share one Redis or Blob store without mixing data. Letters, numbers, `.`, `_` and `-` only. On Vercel it is `production`, or the branch address on previews. |
+
+
+ Local storage keeps only public uploads. Files people attach in the Assistant are private and need S3-compatible
+ storage or Vercel Blob; on local storage, attaching a file fails.
+
+
+Switching backends does not move files that are already stored. Copy them yourself before you switch.
+
+## AI and Redis
+
+AI features are off until `ENCRYPTION_SECRET` is set. People then add their own provider and key in
+**Settings → AI & developer**; see [Connecting an AI provider](/guides/using-ai).
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `ENCRYPTION_SECRET` | unset | Encrypts the AI provider keys people save. At least 32 characters; generate one with `openssl rand -hex 32` and keep it different from `AUTH_SECRET`. A shorter value stops the server at startup. Without it, adding a provider fails and the Assistant says it isn't set up on this server. |
+| `REDIS_URL` | unset | Redis connection string, `redis://` or `rediss://`. Optional. |
+| `AI_TEST_TIMEOUT_MS` | `30000` | How long **Save and test** waits for a provider to answer, in milliseconds. Raise it for local models that load slowly on first use. |
+
+
+ Changing `ENCRYPTION_SECRET` makes every saved provider key unreadable. People then need to enter their keys again.
+
+
+Redis is optional on a single server. Without it, everything works, with these limits:
+
+- An Assistant reply that is interrupted by a page reload can't be picked up again.
+- Rate limits, **Stop** on a running Assistant reply, live updates between open tabs and the check that counts each
+ public resume view once an hour are kept in the memory of one server process.
+
+Set `REDIS_URL` when you run more than one server process, or when you want replies to survive a reload. When Redis is
+configured, the [health endpoint](/self-hosting/docker#check-the-health-endpoint) also checks it.
+
+## Feature flags
+
+All flags default to `false`.
+
+| Variable | When set to `true` |
+| --- | --- |
+| `FLAG_DISABLE_SIGNUPS` | No new accounts can be created, by email or by social sign-in. Existing accounts keep working. Create your own account first. |
+| `FLAG_DISABLE_EMAIL_AUTH` | Turns off email and password sign-in and sign-up, along with forgot password and reset password. People sign in with social or custom OAuth providers only, so configure at least one first. |
+| `FLAG_DISABLE_IMAGE_PROCESSING` | Stores uploaded pictures as they are. Normally pictures are resized to fit 800 × 800 pixels and saved as JPEG. Useful on low-powered hardware such as a Raspberry Pi. |
+| `FLAG_DISABLE_API_RATE_LIMIT` | Turns off rate limiting on sign-in, sign-up, the OAuth endpoints and requests made with API keys. Other limits, such as PDF export and AI requests, stay on. Intended for test installations. |
+| `FLAG_ALLOW_UNSAFE_AI_BASE_URL` | Lets AI providers use `http://` addresses and private or local network addresses, such as an Ollama server on your network. Without it, a provider's base URL must be public `https://`. |
+| `FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI` | Lets MCP and OAuth clients that register themselves use any redirect address, including custom schemes and private hosts. By default a redirect must go to your instance's own address, an `http://` loopback address (`localhost`, `127.0.0.1`, `::1`) or a public `https://` address. |
+
+
+ Only turn on the two `FLAG_ALLOW_UNSAFE_*` flags on an instance where you trust every user. On a shared instance, an
+ unsafe AI base URL lets users make your server call internal network addresses, and an unsafe redirect URI can be used
+ for phishing or to steal access tokens.
+
+
+## Deployment aliases
+
+On Vercel (when `VERCEL=1`), the server fills in some variables from the ones Vercel's integrations inject. A value
+you set yourself always wins.
+
+| Variable | Filled from |
+| --- | --- |
+| `APP_URL` | `https://` plus `VERCEL_PROJECT_PRODUCTION_URL` in production, or `VERCEL_URL` on previews |
+| `DATABASE_URL` | `POSTGRES_URL` |
+| `DATABASE_MIGRATION_URL` | `DATABASE_URL_UNPOOLED`, then `POSTGRES_URL_NON_POOLING` |
+| `REDIS_URL` | `KV_URL` |
+| `STORAGE_BACKEND` | `blob`, unless S3 credentials are set |
+| `DEPLOYMENT_NAMESPACE` | `production`, or `VERCEL_BRANCH_URL` on previews |
+
+Vercel builds also read `ALLOW_PREVIEW_MIGRATIONS`: preview builds refuse to run migrations unless it is `true`, so a
+preview can't change your production database by accident. See [Self-hosting on Vercel](/self-hosting/vercel).
+
+## Development and tooling only
+
+These appear in `.env.example` but are not used by a running server:
+
+| Variable | Used by |
+| --- | --- |
+| `GOOGLE_CLOUD_API_KEY` | The script that regenerates the font list. |
+| `CROWDIN_PROJECT_ID`, `CROWDIN_API_TOKEN` | Translation sync tooling. |
+| `COVER_LETTER_TEST_DATABASE_URL`, `OAUTH_TEST_DATABASE_URL` | Test suites that need their own database. |
+
+See [Development setup](/contributing/development) for working on the code.
+
+## Related pages
+
+- [Self-hosting with Docker](/self-hosting/docker): a complete setup with PostgreSQL.
+- [Deployment examples](/self-hosting/examples): reverse proxies, S3 storage, Redis and local AI.
+- [Single sign-on](/self-hosting/sso): provider setup for the sign-in variables.
diff --git a/docs/self-hosting/examples.mdx b/docs/self-hosting/examples.mdx
index 98042aa6a..1e0132ba0 100644
--- a/docs/self-hosting/examples.mdx
+++ b/docs/self-hosting/examples.mdx
@@ -1,174 +1,55 @@
---
-title: "Docker Compose examples"
-description: "Ready-to-use Docker Compose examples for self-hosting Reactive Resume with Postgres, Traefik, Caddy, Nginx Proxy Manager, and other common deployment stacks."
+title: "Deployment examples"
+description: "Copy-ready setups for self-hosted Reactive Resume: Caddy, Traefik and nginx with HTTPS, S3 storage, Redis, local AI with Ollama, and more."
---
-
- **From v5.1.0 onwards** — PDF generation now runs entirely client-side with the Forme PDF engine (WebAssembly). None of the examples below require a Browserless or Chromium service. Older configurations that still define a `printer` service or set `BROWSERLESS_TOKEN` / `PRINTER_*` will continue to start, but those services are inert and can be removed.
-
+Each section on this page solves one common self-hosting task and builds on the two-container setup from
+[Self-hosting with Docker](/self-hosting/docker). Replace `resume.example.com` with your own domain, and keep the
+`.env` file from that guide unless a section says otherwise.
-## Overview
+## What every reverse proxy needs
-Self-hosted setups vary. You might run on a single VPS or a Kubernetes cluster, behind Cloudflare Tunnel, or behind a reverse proxy like Traefik or nginx. This page collects Docker Compose configurations for those cases.
+Whatever proxy you use, configure it so that:
-They go further than the basic setup in the [Self-hosting with Docker](/self-hosting/docker) guide, with reverse proxies, SSL termination, and other common patterns.
+- **The whole site goes to the app.** The app serves pages, the API (`/api/`), the MCP server (`/mcp`), uploads and
+ assets from one address. Don't split or rewrite paths.
+- **`APP_URL` is the public address**, for example `APP_URL="https://resume.example.com"`.
+- **The client's IP address is passed on, and clients can't fake it.** Rate limits (sign-in attempts, public resume
+ passwords) are counted per IP address. The app uses the first of these headers it finds, as sent: `CF-Connecting-IP`,
+ `CF-Connecting-IPv6`, `True-Client-IP`, `X-Forwarded-For` (its first entry), then `X-Real-IP`. Set
+ `X-Forwarded-For` to the address the proxy sees, replacing any value the client sent, and remove the other three
+ headers unless a CDN in front of your proxy sets them. The examples below do both.
+- **Streaming responses aren't buffered**, and idle reads are allowed for at least 5 minutes. Assistant replies and
+ live updates stream from the server, and a single Assistant reply can take up to 4 minutes.
+- **Request bodies of at least 50 MB are accepted.** People can attach files of up to 25 MB to the Assistant, and
+ the browser sends them base64-encoded, which makes them about a third larger.
-
- **Share your setup.** If you have a working configuration that isn't covered here, I'd love to include it. [Open a
- pull request](https://github.com/reactive-resume/reactive-resume) with your example added to this page.
-
+
+ Once a proxy is in front, don't publish the app's port `3000` on a public
+ interface, or clients can bypass the proxy and fake their IP address to get around rate limits. Remove the `ports`
+ entry, or bind it to `127.0.0.1:3000:3000`.
+
----
+## Caddy
-## Reuse an existing PostgreSQL service
+[Caddy](https://caddyserver.com/) gets and renews HTTPS certificates on its own and streams responses without extra
+settings, so it needs the least configuration.
-Reactive Resume always uses a separate PostgreSQL service; do not embed another database server in the app container.
-If your homelab or hosting platform already manages PostgreSQL, reuse it by setting `DATABASE_URL` to that service and
-allowing the app container to reach it over the intended private network. Keep database credentials private and do not
-expose PostgreSQL to the public internet.
-
-When the database connection crosses a host or network boundary, require TLS with certificate and hostname verification.
-Use `sslmode=verify-full` in `DATABASE_URL` where the provider supports it, or the provider's equivalent verified-TLS
-configuration. Private routing limits exposure, but does not itself verify the database server's identity.
-
-For generic Unraid and homelab container fields, including the `localhost` networking warning, see
-[Unraid and other homelab platforms](/self-hosting/docker#unraid-and-other-homelab-platforms). The core resume workflow
-does not require Redis or S3. Redis is a separate optional dependency for the AI Agent workspace, while S3-compatible
-storage is optional unless you need features that require private object storage; see the
-[environment variable reference](/self-hosting/docker#environment-variables) for those boundaries.
-
----
-
-## Docker with Traefik
-
-This example uses [Traefik](https://traefik.io/) as a reverse proxy with automatic SSL certificate management via Let's Encrypt. Postgres stays on an internal network. The Traefik dashboard is also routed, at `traefik.${DOMAIN}` behind basic auth — drop those labels if you do not want it reachable.
-
-
- Traefik discovers services through Docker labels and handles SSL certificates, so it needs very little configuration.
-
-
-```yaml compose-traefik.yml lines expandable
+```yaml compose.yml
services:
- traefik:
- image: traefik:v3.2
- restart: unless-stopped
- command:
- - "--api.dashboard=true"
- - "--providers.docker=true"
- - "--providers.docker.exposedbydefault=false"
- - "--entrypoints.web.address=:80"
- - "--entrypoints.websecure.address=:443"
- - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- ports:
- - "80:80"
- - "443:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - traefik_letsencrypt:/letsencrypt
- networks:
- - reactive_resume_network
- labels:
- - "traefik.enable=true"
- # Dashboard (optional, remove if not needed)
- - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
- - "traefik.http.routers.traefik.entrypoints=websecure"
- - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- - "traefik.http.routers.traefik.service=api@internal"
- - "traefik.http.routers.traefik.middlewares=auth"
- - "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
-
- postgres:
- image: postgres:latest
- restart: unless-stopped
- environment:
- - POSTGRES_DB=postgres
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- volumes:
- - postgres_data:/var/lib/postgresql
- networks:
- - reactive_resume_network
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- reactive_resume:
- image: amruthpillai/reactive-resume:latest
- restart: unless-stopped
- environment:
- - APP_URL=https://resume.${DOMAIN}
- - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
- - AUTH_SECRET=${AUTH_SECRET}
- # Add other optional env vars as needed (SMTP, S3, OAuth, etc.)
- volumes:
- - reactive_resume_data:/app/data
- networks:
- - reactive_resume_network
- depends_on:
- postgres:
- condition: service_healthy
- labels:
- - "traefik.enable=true"
- - "traefik.http.routers.reactive-resume.rule=Host(`resume.${DOMAIN}`)"
- - "traefik.http.routers.reactive-resume.entrypoints=websecure"
- - "traefik.http.routers.reactive-resume.tls.certresolver=letsencrypt"
- - "traefik.http.services.reactive-resume.loadbalancer.server.port=3000"
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
- interval: 30s
- timeout: 10s
- retries: 3
-
-networks:
- reactive_resume_network:
- driver: bridge
-
-volumes:
- traefik_letsencrypt:
- postgres_data:
- reactive_resume_data:
-```
-
-**Environment variables (`.env`):**
-
-```bash .env
-DOMAIN="example.com"
-ACME_EMAIL="admin@example.com"
-POSTGRES_PASSWORD="your-secure-postgres-password"
-AUTH_SECRET="your-auth-secret-from-openssl-rand-hex-32"
-# Optional: Traefik dashboard auth (generate with: htpasswd -nb admin password)
-TRAEFIK_DASHBOARD_AUTH="admin:$$apr1$$..."
-```
-
----
-
-## Docker with nginx
-
-This example uses [nginx](https://nginx.org/) as a reverse proxy with SSL certificates (you'll need to provide your own certificates or use certbot separately).
-
-```yaml compose-nginx.yml lines expandable
-services:
- nginx:
- image: nginx:alpine
+ caddy:
+ image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
+ - "443:443/udp"
volumes:
- - ./nginx.conf:/etc/nginx/nginx.conf:ro
- - ./certs:/etc/nginx/certs:ro
- networks:
- - reactive_resume_network
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - caddy_data:/data
postgres:
- image: postgres:latest
+ image: postgres:18
restart: unless-stopped
environment:
POSTGRES_DB: postgres
@@ -176,294 +57,414 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql
- networks:
- - reactive_resume_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
- retries: 5
+ retries: 10
- reactive_resume:
+ reactive-resume:
image: amruthpillai/reactive-resume:latest
restart: unless-stopped
- environment:
- - APP_URL=https://resume.${DOMAIN}
- - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
- - AUTH_SECRET=${AUTH_SECRET}
- # Add other optional env vars as needed (SMTP, S3, OAuth, etc.)
+ env_file: .env
volumes:
- reactive_resume_data:/app/data
- networks:
- - reactive_resume_network
depends_on:
postgres:
condition: service_healthy
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
- interval: 30s
- timeout: 10s
- retries: 3
-
-networks:
- reactive_resume_network:
- driver: bridge
volumes:
+ caddy_data:
postgres_data:
reactive_resume_data:
```
-**nginx configuration (`nginx.conf`):**
-
-```nginx nginx.conf lines expandable
-events {
- worker_connections 1024;
-}
-
-http {
- upstream reactive_resume {
- server reactive_resume:3000;
- }
-
- # Redirect HTTP to HTTPS
- server {
- listen 80;
- server_name _;
- return 301 https://$host$request_uri;
- }
-
- # HTTPS server
- server {
- listen 443 ssl http2;
- server_name resume.example.com;
-
- ssl_certificate /etc/nginx/certs/fullchain.pem;
- ssl_certificate_key /etc/nginx/certs/privkey.pem;
-
- # SSL configuration
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_prefer_server_ciphers on;
- ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
- ssl_session_cache shared:SSL:10m;
- ssl_session_timeout 10m;
-
- # Security headers
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-XSS-Protection "1; mode=block" always;
-
- # Proxy settings
- location / {
- proxy_pass http://reactive_resume;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_cache_bypass $http_upgrade;
-
- # Reasonable timeouts for app requests
- proxy_connect_timeout 60s;
- proxy_send_timeout 60s;
- proxy_read_timeout 60s;
- }
-
- # Increase max body size for resume uploads
- client_max_body_size 10M;
- }
+```text Caddyfile
+resume.example.com {
+ request_body {
+ max_size 50MB
+ }
+ reverse_proxy reactive-resume:3000 {
+ header_up -CF-Connecting-IP
+ header_up -CF-Connecting-IPv6
+ header_up -True-Client-IP
+ }
}
```
-
- For automatic SSL certificates with nginx, consider using [certbot](https://certbot.eff.org/) with the `--nginx`
- plugin, or a companion container like [nginx-proxy-acme](https://github.com/nginx-proxy/acme-companion).
-
+Point your domain's DNS at the server, set `APP_URL="https://resume.example.com"` in `.env`, and run
+`docker compose up -d`. Caddy replaces any `X-Forwarded-For` header a client sends with the real client address, and
+the `header_up` lines drop the other IP headers.
----
+## Traefik
-## Docker Swarm
+[Traefik](https://traefik.io/) reads its routes from Docker labels and gets certificates from Let's Encrypt.
-This example is a Docker Swarm deployment with health checks, rolling updates, and Traefik integration. It includes SeaweedFS for S3-compatible storage and a PostgreSQL database with custom configuration.
-
-
- Docker Swarm suits multi-node deployments that need high availability and simple scaling. Every service here starts
- at one replica; raise `deploy.replicas` on `reactive_resume` once you have more than one node.
-
-
-```yaml compose-swarm.yml lines expandable
+```yaml compose.yml lines expandable
services:
- postgres:
- image: postgres:latest
- networks:
- - reactive_resume_network
+ traefik:
+ image: traefik:v3
+ restart: unless-stopped
+ command:
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--entrypoints.web.address=:80"
+ - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
+ - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
+ - "--entrypoints.websecure.address=:443"
+ - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
+ - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
+ - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
+ - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
+ ports:
+ - "80:80"
+ - "443:443"
volumes:
- - reactive_resume_postgres_data:/var/lib/postgresql
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - traefik_letsencrypt:/letsencrypt
+
+ postgres:
+ image: postgres:18
+ restart: unless-stopped
environment:
- - POSTGRES_DB=$POSTGRES_DB
- - POSTGRES_USER=$POSTGRES_USER
- - POSTGRES_PASSWORD=$POSTGRES_PASSWORD
+ POSTGRES_DB: postgres
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ volumes:
+ - postgres_data:/var/lib/postgresql
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
+ test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
- retries: 5
- start_period: 30s
- deploy:
- mode: replicated
- replicas: 1
+ retries: 10
- seaweedfs:
- image: chrislusf/seaweedfs:latest
- command: server -s3 -filer -dir=/data -ip=0.0.0.0
- networks:
- - reactive_resume_network
- volumes:
- - reactive_resume_seaweedfs_data:/data
- environment:
- - AWS_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID
- - AWS_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
- healthcheck:
- test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8888"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 30s
- deploy:
- mode: replicated
- replicas: 1
-
- seaweedfs_create_bucket:
- image: amazon/aws-cli:latest
- environment:
- - AWS_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID
- - AWS_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
- - AWS_DEFAULT_REGION=us-east-1
- entrypoint: >
- /bin/sh -c "
- until aws --endpoint-url http://seaweedfs:8333 s3api head-bucket --bucket $S3_BUCKET 2>/dev/null ||
- aws --endpoint-url http://seaweedfs:8333 s3 mb s3://$S3_BUCKET; do
- echo 'Waiting for SeaweedFS...';
- sleep 2;
- done;
- "
- networks:
- - reactive_resume_network
- deploy:
- mode: replicated
- replicas: 1
-
- reactive_resume:
- image: ghcr.io/reactive-resume/reactive-resume:latest
- networks:
- - traefik_network
- - reactive_resume_network
+ reactive-resume:
+ image: amruthpillai/reactive-resume:latest
+ restart: unless-stopped
+ env_file: .env
volumes:
- reactive_resume_data:/app/data
- environment:
- - APP_URL=$APP_URL
- - DATABASE_URL=$DATABASE_URL
- - AUTH_SECRET=$AUTH_SECRET
- - GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID
- - GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET
- - GITHUB_CLIENT_ID=$GITHUB_CLIENT_ID
- - GITHUB_CLIENT_SECRET=$GITHUB_CLIENT_SECRET
- - LINKEDIN_CLIENT_ID=$LINKEDIN_CLIENT_ID
- - LINKEDIN_CLIENT_SECRET=$LINKEDIN_CLIENT_SECRET
- - SMTP_HOST=$SMTP_HOST
- - SMTP_PORT=$SMTP_PORT
- - SMTP_USER=$SMTP_USER
- - SMTP_PASS=$SMTP_PASS
- - SMTP_FROM=$SMTP_FROM
- - SMTP_SECURE=$SMTP_SECURE
- - S3_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID
- - S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
- - S3_REGION=$S3_REGION
- - S3_ENDPOINT=$S3_ENDPOINT
- - S3_BUCKET=$S3_BUCKET
- healthcheck:
- test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 30s
- deploy:
- mode: replicated
- replicas: 1
- labels:
- - "traefik.enable=true"
- - "traefik.http.routers.app.rule=Host(`rxresu.me`)"
- - "traefik.http.routers.app.entrypoints=websecure"
- - "traefik.http.routers.app.tls=true"
- - "traefik.http.services.app.loadbalancer.server.port=3000"
-
-configs:
- reactive_resume_postgres_config:
- name: reactive_resume_postgres_config
- external: true
-
-networks:
- traefik_network:
- external: true
- reactive_resume_network:
- name: reactive_resume_network
- driver: overlay
- attachable: true
+ depends_on:
+ postgres:
+ condition: service_healthy
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.reactive-resume.rule=Host(`resume.example.com`)"
+ - "traefik.http.routers.reactive-resume.entrypoints=websecure"
+ - "traefik.http.routers.reactive-resume.tls.certresolver=letsencrypt"
+ - "traefik.http.services.reactive-resume.loadbalancer.server.port=3000"
+ # Drop client-supplied IP headers; Traefik sets X-Forwarded-For itself.
+ - "traefik.http.middlewares.reactive-resume-ip.headers.customrequestheaders.CF-Connecting-IP="
+ - "traefik.http.middlewares.reactive-resume-ip.headers.customrequestheaders.CF-Connecting-IPv6="
+ - "traefik.http.middlewares.reactive-resume-ip.headers.customrequestheaders.True-Client-IP="
+ - "traefik.http.routers.reactive-resume.middlewares=reactive-resume-ip"
volumes:
- reactive_resume_postgres_data:
- name: reactive_resume_postgres_data
- reactive_resume_seaweedfs_data:
- name: reactive_resume_seaweedfs_data
+ traefik_letsencrypt:
+ postgres_data:
reactive_resume_data:
- name: reactive_resume_data
```
-**Deploy the stack:**
+Add `ACME_EMAIL="you@example.com"` to `.env` for Let's Encrypt notices. Traefik skips containers whose health check
+fails, so it only routes to the app once `/api/health` answers `200`.
-```bash
-docker stack deploy -c compose-swarm.yml reactive_resume
+## nginx
+
+With [nginx](https://nginx.org/) you manage certificates yourself, for example with
+[Certbot](https://certbot.eff.org/). Add this service to the compose file from the Docker guide, and remove the
+`ports` entry from the `reactive-resume` service:
+
+```yaml compose.yml
+ nginx:
+ image: nginx:stable-alpine
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./certs:/etc/nginx/certs:ro
+ depends_on:
+ - reactive-resume
```
-**Useful commands:**
+```nginx nginx.conf lines expandable
+server {
+ listen 80;
+ server_name resume.example.com;
+ return 301 https://$host$request_uri;
+}
-```bash
-# Check service status
-docker stack services reactive_resume
+server {
+ listen 443 ssl;
+ http2 on;
+ server_name resume.example.com;
-# View logs for the app
-docker service logs -f reactive_resume_reactive_resume
+ ssl_certificate /etc/nginx/certs/fullchain.pem;
+ ssl_certificate_key /etc/nginx/certs/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
-# Scale the app
-docker service scale reactive_resume_reactive_resume=3
+ # Assistant attachments can be up to 25 MB, sent base64-encoded.
+ client_max_body_size 50m;
-# Remove the stack
-docker stack rm reactive_resume
+ location / {
+ proxy_pass http://reactive-resume:3000;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Don't pass client-supplied IP headers on (an empty value removes them).
+ proxy_set_header CF-Connecting-IP "";
+ proxy_set_header CF-Connecting-IPv6 "";
+ proxy_set_header True-Client-IP "";
+
+ # Assistant replies and live updates stream; don't hold them back.
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ }
+}
```
+## Use an existing PostgreSQL server
+
+If you already run PostgreSQL, or use a managed database, leave out the `postgres` service and point `DATABASE_URL`
+at your server. Create an empty database and a user that owns it; the app creates its tables on first start.
+
+```bash .env
+DATABASE_URL="postgresql://reactive_resume:password@db.example.com:5432/reactive_resume?sslmode=verify-full"
+```
+
+- Use `sslmode=verify-full` (or your provider's equivalent) whenever the connection leaves the host, so the app checks
+ the server's certificate.
+- If `DATABASE_URL` points at a connection pooler, add a direct connection in `DATABASE_MIGRATION_URL` for startup
+ migrations.
+- Never expose PostgreSQL to the internet without TLS and a firewall.
+
+## Store uploads in S3-compatible storage
+
+S3 storage lets you drop the `/app/data` volume, and it's required if people attach files in the Assistant. The app
+switches to S3 once the access key, secret key and bucket are all set. The bucket can stay private: the app reads
+objects with its own credentials and serves them itself.
+
+
+
+ ```bash .env
+ S3_ACCESS_KEY_ID="AKIA..."
+ S3_SECRET_ACCESS_KEY="..."
+ S3_BUCKET="my-reactive-resume"
+ S3_REGION="eu-central-1"
+ ```
+
+
+ ```bash .env
+ S3_ACCESS_KEY_ID="..."
+ S3_SECRET_ACCESS_KEY="..."
+ S3_BUCKET="reactive-resume"
+ S3_REGION="auto"
+ S3_ENDPOINT="https://.r2.cloudflarestorage.com"
+ ```
+
+
+ ```bash .env
+ S3_ACCESS_KEY_ID="..."
+ S3_SECRET_ACCESS_KEY="..."
+ S3_BUCKET="reactive-resume"
+ S3_ENDPOINT="http://minio:9000"
+ S3_FORCE_PATH_STYLE="true"
+ ```
+
+
+
+The bucket must exist before the app starts. Check `/api/health`: its `storage` entry should show `"type": "s3"` and
+`"status": "healthy"`. Files already in `/app/data` aren't moved; copy them into the bucket, keeping their paths, if you
+switch an existing instance.
+
+## Add Redis and turn on AI
+
+AI features need `ENCRYPTION_SECRET`. Redis is optional on a single server, but with it an Assistant reply keeps
+streaming after a page reload. Add a Redis service to your compose file:
+
+```yaml compose.yml
+ redis:
+ image: redis:8
+ restart: unless-stopped
+ command: redis-server --appendonly yes
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+```
+
+Add `redis_data:` under `volumes:`, and add `redis` to the app's `depends_on` with `condition: service_healthy`. Then
+add to `.env`:
+
+```bash .env
+# Generate with: openssl rand -hex 32. Keep it different from AUTH_SECRET.
+ENCRYPTION_SECRET="..."
+REDIS_URL="redis://redis:6379"
+```
+
+Run `docker compose up -d`. People can now add their own AI provider; see [Connecting an AI provider](/guides/using-ai)
+and [Using the Assistant](/guides/using-the-assistant).
+
- This example assumes you have an external Traefik network already set up. Adjust the `traefik_network` reference and
- labels based on your Traefik configuration.
+ Redis holds streaming Assistant replies, which include people's messages. Keep it on the private Docker network and
+ don't publish its port.
----
+## Use a local AI model with Ollama
-## Contributing your setup
+To keep AI requests on your own hardware, run [Ollama](https://ollama.com/) next to the app. By default the app only
+accepts public `https://` provider addresses, so you turn on a flag that allows local ones.
-Have a different deployment setup that works well? Consider contributing it here. Some examples:
+
+ `FLAG_ALLOW_UNSAFE_AI_BASE_URL` lets every user make your server send requests to any address on your network. Only
+ turn it on for an instance where you trust every user, such as a personal or family server.
+
-- Kubernetes / Helm charts
-- Cloudflare Tunnel
-- Caddy reverse proxy
-- Docker with Portainer
-- Podman configurations
-- Cloud-specific deployments (AWS ECS, Google Cloud Run, Azure Container Apps)
+
+
+ ```yaml compose.yml
+ ollama:
+ image: ollama/ollama
+ restart: unless-stopped
+ volumes:
+ - ollama_data:/root/.ollama
+ ```
-To contribute, [open a pull request](https://github.com/reactive-resume/reactive-resume) with your example added to this page. Include:
+ Add `ollama_data:` under `volumes:`, start it, and download a model:
-1. A brief description of when/why someone would use this setup
-2. The complete Docker Compose (or equivalent) configuration
-3. Any additional configuration files (nginx.conf, etc.)
-4. Required environment variables
+ ```bash
+ docker compose up -d ollama
+ docker compose exec ollama ollama pull llama3.1
+ ```
+
+
+
+ Add to `.env`, next to the `ENCRYPTION_SECRET` from the previous section:
+
+ ```bash .env
+ FLAG_ALLOW_UNSAFE_AI_BASE_URL="true"
+ # Local models can take a while to load on first use.
+ AI_TEST_TIMEOUT_MS="120000"
+ ```
+
+ Run `docker compose up -d` to apply it.
+
+
+
+ Open **Settings**, then **AI & developer**, and select **Add provider**. Choose **Ollama Cloud** as the provider and
+ fill in:
+
+ - **API key**: any text, for example `ollama`. A local Ollama server ignores it.
+ - **Model**: the model you pulled, for example `llama3.1`.
+ - **Base URL**: `http://ollama:11434/api`
+
+ Select **Save and test**. The app saves the provider once Ollama answers.
+
+
+
+
+
+
+
+Any server with an OpenAI-compatible API works the same way: choose **OpenAI-compatible** and enter its address, such
+as `http://llama-server:8080/v1`.
+
+## Run a private instance
+
+For a personal or team server that nobody else can join:
+
+1. Create your own account (and your team's) first.
+2. Add `FLAG_DISABLE_SIGNUPS="true"` to `.env` and run `docker compose up -d`.
+
+New sign-ups are then refused, by email and by social sign-in. People who already have accounts sign in as usual.
+
+To allow sign-in only through your company's identity provider, set up a custom OAuth provider (see
+[Single sign-on](/self-hosting/sso)), then add `FLAG_DISABLE_EMAIL_AUTH="true"`. This removes email and password
+sign-in, along with password resets.
+
+## Show one resume at your root address
+
+To use your instance as a personal resume site, set `ROOT_RESUME_ID`. Visitors to `/` then see that resume's public
+page instead of the home page.
+
+
+
+ In the editor, select **Share**, and on the **Link** tab turn on **Public link**. See
+ [Sharing your resume publicly](/guides/sharing-your-resume-publicly).
+
+
+
+ The ID is the last part of the editor's address: `https://resume.example.com/builder/`.
+
+
+
+ ```bash .env
+ ROOT_RESUME_ID=""
+ ```
+
+ Run `docker compose up -d`.
+
+
+
+- The resume's own settings still apply: a password still protects it, and **Visitors can download the PDF** still
+ controls the download button. Its usual `//` address keeps working.
+- Renaming the address or username doesn't break it; the ID stays the same.
+- If the resume is deleted or its public link is turned off, `/` shows an unavailable page, even to you.
+- Sign-in and your documents stay at their usual addresses. The root page asks search engines not to index it.
+
+Remove the variable, or leave it empty, and restart to bring back the home page.
+
+## Run several app containers
+
+To run more than one app container, for high availability or Docker Swarm, every container must share state:
+
+- **The same database, `AUTH_SECRET` and `ENCRYPTION_SECRET`.**
+- **Redis**, set in `REDIS_URL`, so rate limits, stopping an Assistant reply and live updates work across containers.
+- **S3-compatible storage** instead of `/app/data`, so every container sees the same uploads.
+- **The same `DEPLOYMENT_NAMESPACE`** (the default, `default`, is fine), or leave it unset everywhere.
+
+Migrations are safe to start in parallel: containers wait for each other, and only one applies changes. A Docker
+Swarm service then looks like this:
+
+```yaml compose-swarm.yml
+services:
+ reactive-resume:
+ image: amruthpillai/reactive-resume:v6
+ env_file: .env
+ networks:
+ - reactive_resume
+ deploy:
+ replicas: 2
+ update_config:
+ order: start-first
+ failure_action: rollback
+
+networks:
+ reactive_resume:
+ driver: overlay
+```
+
+Deploy it with `docker stack deploy -c compose-swarm.yml reactive-resume`, next to your PostgreSQL, Redis and S3
+services, and route traffic to it with your proxy.
+
+## Share your setup
+
+Have a working setup that isn't covered here, such as Podman, Portainer or Cloudflare Tunnel?
+[Open a pull request](https://github.com/reactive-resume/reactive-resume) that adds it to this page. Include when
+someone would use it, the complete configuration, and the environment variables it needs.
+
+## Related pages
+
+- [Environment variables](/self-hosting/environment-variables): every setting, with defaults.
+- [Self-hosting with Kubernetes](/self-hosting/kubernetes): the same setup as Kubernetes manifests.
+- [Single sign-on](/self-hosting/sso): Google, GitHub, LinkedIn and custom OAuth providers.
diff --git a/docs/self-hosting/kubernetes.mdx b/docs/self-hosting/kubernetes.mdx
index ab949786f..be9f88206 100644
--- a/docs/self-hosting/kubernetes.mdx
+++ b/docs/self-hosting/kubernetes.mdx
@@ -1,52 +1,28 @@
---
title: "Self-hosting with Kubernetes"
-description: "How to self-host Reactive Resume on Kubernetes with plain manifests: PostgreSQL, persistent uploads, Secrets, ingress and verification steps."
+description: "Deploy Reactive Resume on Kubernetes with plain manifests: PostgreSQL, a Secret, persistent uploads, health probes, Ingress, scaling and updates."
---
-
- **From v5.1.0 onwards** — the builder generates PDFs in the browser with the Forme PDF engine (WebAssembly). New deployments no
- longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and
- `BROWSERLESS_*` environment variables are no longer read and can be removed from your configuration.
-
+This guide deploys Reactive Resume to a Kubernetes cluster with plain manifests. The result matches the
+[Docker setup](/self-hosting/docker): one app Deployment serving everything on port `3000`, a separate PostgreSQL
+database, and persistent storage for uploads. Adapt the storage class and Ingress settings to your cluster.
-## Overview
+## Before you start
-Reactive Resume runs on Kubernetes as a single Deployment that serves both the web app and the API on port `3000`, the
-same way the official Docker image does. The rest of the stack matches the [Self-hosting with Docker](/self-hosting/docker)
-guide:
+You need:
-- **PostgreSQL** must run as a separate service. The app connects to it through `DATABASE_URL`; no all-in-one image with
- an embedded database is planned.
-- **Persistent storage** for uploads. Without S3, uploads live under `/app/data`, so a PersistentVolumeClaim must be
- mounted there.
-- **Secrets** for `APP_URL`, `DATABASE_URL`, and `AUTH_SECRET`. Optional features (SMTP, S3, OAuth, AI) use the same
- environment variables as the Docker guide's [environment variable reference](/self-hosting/docker#environment-variables).
+- A cluster with `kubectl` access and a default StorageClass for PersistentVolumeClaims.
+- An Ingress controller and a way to issue TLS certificates, such as cert-manager.
+- A DNS name for your instance, for example `resume.example.com`.
+- About 250m CPU and 512 MiB of memory for the app Pod, plus room for PostgreSQL if it runs in the cluster.
-Everything below uses plain Kubernetes manifests for a Linux cluster. Adapt the storage and Ingress settings to your
-cluster. A community Helm chart is linked at the end of the page; it is maintained outside this repository.
+The image is `amruthpillai/reactive-resume` on Docker Hub or `ghcr.io/reactive-resume/reactive-resume` on GitHub
+Container Registry, built for `amd64` and `arm64`. The examples pin the `v6` tag, which gets fixes and new features
+without moving to the next major version.
-
-
- Use ghcr.io/reactive-resume/reactive-resume:latest or amruthpillai/reactive-resume:latest.
-
- Stores accounts, resumes, and application data. Runs separately, never embedded in the app image.
-
+## Create the namespace and Secret
-## Minimum requirements
-
-
-
- A running cluster with kubectl access and a default StorageClass for PersistentVolumeClaims.
-
-
- An Ingress controller (nginx, Traefik, …) and a way to issue TLS certificates, for example cert-manager.
-
- 1 vCPU / 1 GB RAM minimum for the app Pod (2 GB recommended when PostgreSQL runs in the same cluster).
-
-
-## Create the namespace
-
-Save this as `namespace.yaml`. Apply it before any of the namespaced resources below.
+Save both manifests. The Secret holds every setting and is passed to the app as environment variables.
```yaml namespace.yaml
apiVersion: v1
@@ -55,11 +31,6 @@ metadata:
name: reactive-resume
```
-## Required Secrets
-
-Configuration is passed to the Pod as environment variables. Store the values in a Secret and reference it from the
-Deployment with `envFrom`:
-
```yaml secret.yaml
apiVersion: v1
kind: Secret
@@ -68,66 +39,42 @@ metadata:
namespace: reactive-resume
type: Opaque
stringData:
- # Canonical public URL of your instance. Must match the HTTPS URL users actually visit.
+ # The public HTTPS address people use. Must match the Ingress host.
APP_URL: "https://resume.example.com"
- # "postgres" is the Service name from the PostgreSQL section below.
+ # "postgres" is the Service name from postgres.yaml.
DATABASE_URL: "postgresql://postgres:REPLACE_WITH_DATABASE_PASSWORD@postgres:5432/postgres"
- # Used by the example PostgreSQL Deployment. Must match the password in DATABASE_URL.
+ # Read by the example PostgreSQL Deployment. Same password as in DATABASE_URL.
POSTGRES_PASSWORD: "REPLACE_WITH_DATABASE_PASSWORD"
- # Generate with: openssl rand -hex 32
- AUTH_SECRET: "REPLACE_WITH_A_RANDOM_64_CHAR_HEX_STRING"
-
- # --- Optional (see the Docker guide's environment variable reference) ---
- # SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_SECURE
- # S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_ENDPOINT, S3_BUCKET, S3_FORCE_PATH_STYLE
- # ENCRYPTION_SECRET, REDIS_URL (AI features)
- # FLAG_DISABLE_SIGNUPS, FLAG_DISABLE_EMAIL_AUTH (feature flags)
+ # openssl rand -hex 32
+ AUTH_SECRET: "REPLACE_WITH_A_RANDOM_SECRET"
+ # Optional: turns on AI features. openssl rand -hex 32, different from AUTH_SECRET.
+ # ENCRYPTION_SECRET: ""
+ # Any other variable from the environment variable reference goes here too.
```
-
-
- Generate a strong secret and paste it into `AUTH_SECRET`.
-
- ```bash
- openssl rand -hex 32
- ```
-
-
-
-
- Set `APP_URL` to the public HTTPS URL you will reach through the Ingress. If it does not match the URL you actually
- use, sign-in redirects and cookies will misbehave.
-
-
-
- Point `DATABASE_URL` at your PostgreSQL instance. Inside the cluster the host is the Service DNS name (for example
- `postgres` in the same namespace) — never `localhost`, which resolves to the app Pod itself.
- For the PostgreSQL example below, generate a separate password with `openssl rand -hex 32` and use it in both
- `POSTGRES_PASSWORD` and `DATABASE_URL`. URL-encode special characters in connection-string passwords.
-
-
+Generate the secrets and the database password with `openssl rand -hex 32`, and paste them in. See
+[Environment variables](/self-hosting/environment-variables) for everything else you can add, such as SMTP, S3 and
+single sign-on.
- `stringData` keeps the example readable. Base64-encoded `data` is not encryption. Keep files containing real secrets
- out of version control; for GitOps, use encrypted Secrets or an External Secrets mapping. Retain `AUTH_SECRET`
- across Pod restarts and upgrades.
+ `stringData` keeps the example readable, but a Secret is only base64-encoded, not encrypted. Keep this file out of
+ version control. For GitOps, use Sealed Secrets, SOPS or External Secrets. Never change `AUTH_SECRET` or
+ `ENCRYPTION_SECRET` once people use the instance.
-## PostgreSQL dependency
+## Run PostgreSQL
-PostgreSQL is the only required service next to the app. You can use a managed database outside the cluster, an operator
-such as CloudNativePG, or a chart such as the HelmForge or Bitnami PostgreSQL charts. The minimal example below is
-enough for a small single-node cluster:
+You can use a managed database, an operator such as CloudNativePG, or this minimal single-instance Deployment. If you
+use your own, skip this file and set `DATABASE_URL` to it.
-```yaml postgres.yaml
+```yaml postgres.yaml lines expandable
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: reactive-resume
spec:
- accessModes:
- - ReadWriteOnce
+ accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
@@ -139,7 +86,7 @@ metadata:
namespace: reactive-resume
spec:
replicas: 1
- # Stop the old database Pod before another one mounts the same data directory.
+ # Stop the old Pod before a new one mounts the same data.
strategy:
type: Recreate
selector:
@@ -152,7 +99,7 @@ spec:
spec:
containers:
- name: postgres
- image: postgres:17
+ image: postgres:18
ports:
- containerPort: 5432
env:
@@ -165,15 +112,12 @@ spec:
secretKeyRef:
name: reactive-resume
key: POSTGRES_PASSWORD
- - name: PGDATA
- value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
- mountPath: /var/lib/postgresql/data
+ mountPath: /var/lib/postgresql
readinessProbe:
exec:
command: ["pg_isready", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres"]
- initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: data
@@ -190,34 +134,28 @@ spec:
app.kubernetes.io/name: postgres
ports:
- port: 5432
- targetPort: 5432
```
-- Keep the PostgreSQL Service a ClusterIP. Do not expose PostgreSQL to the public internet.
-- Keep the image pinned to a PostgreSQL major version. `PGDATA` uses a subdirectory so filesystem entries such as
- `lost+found` at the volume root do not prevent initialization.
-- `POSTGRES_PASSWORD` initializes a new database only. Changing the Secret does not change an existing database's password.
-- The app runs database migrations automatically on every start, and needs to reach PostgreSQL before it becomes ready.
+- Keep this Service a ClusterIP. Don't expose PostgreSQL outside the cluster.
+- `POSTGRES_PASSWORD` only applies when the database is first created. Changing it later doesn't change the password.
+- Inside the cluster, use the Service name as the host in `DATABASE_URL`, never `localhost`.
-## Deploy the application
+## Deploy the app
-With the namespace and Secret above, this file adds the uploads PersistentVolumeClaim, Deployment, Service, and Ingress.
+This file adds the uploads volume, the Deployment, a Service and an Ingress.
-```yaml reactive-resume.yaml
-# Persistent storage for uploads, used when S3 is not configured
+```yaml reactive-resume.yaml lines expandable
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reactive-resume-data
namespace: reactive-resume
spec:
- accessModes:
- - ReadWriteOnce
+ accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
-# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -225,7 +163,7 @@ metadata:
namespace: reactive-resume
spec:
replicas: 1
- # One replica at a time: migrations run on startup and the PVC is ReadWriteOnce.
+ # The uploads volume is ReadWriteOnce, so the old Pod must stop first.
strategy:
type: Recreate
selector:
@@ -236,7 +174,7 @@ spec:
labels:
app.kubernetes.io/name: reactive-resume
spec:
- # The official image runs as the non-root `node` user (UID/GID 1000).
+ # The image runs as the non-root "node" user (UID and GID 1000).
securityContext:
runAsNonRoot: true
runAsUser: 1000
@@ -244,22 +182,27 @@ spec:
fsGroup: 1000
containers:
- name: reactive-resume
- image: ghcr.io/reactive-resume/reactive-resume:latest
+ image: ghcr.io/reactive-resume/reactive-resume:v6
imagePullPolicy: Always
- # Docker Hub alternative: amruthpillai/reactive-resume:latest
ports:
- - containerPort: 3000
+ - name: http
+ containerPort: 3000
envFrom:
- secretRef:
name: reactive-resume
volumeMounts:
- name: data
mountPath: /app/data
+ startupProbe:
+ httpGet:
+ path: /api/health
+ port: http
+ periodSeconds: 5
+ failureThreshold: 60
readinessProbe:
httpGet:
path: /api/health
- port: 3000
- initialDelaySeconds: 30
+ port: http
periodSeconds: 10
timeoutSeconds: 5
resources:
@@ -273,7 +216,6 @@ spec:
persistentVolumeClaim:
claimName: reactive-resume-data
---
-# Service
apiVersion: v1
kind: Service
metadata:
@@ -285,9 +227,8 @@ spec:
ports:
- name: http
port: 80
- targetPort: 3000
+ targetPort: http
---
-# Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -295,6 +236,11 @@ metadata:
namespace: reactive-resume
annotations:
cert-manager.io/cluster-issuer: letsencrypt
+ # For ingress-nginx. Other controllers have equivalent settings.
+ nginx.ingress.kubernetes.io/proxy-body-size: "50m"
+ nginx.ingress.kubernetes.io/proxy-buffering: "off"
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
spec:
ingressClassName: nginx
rules:
@@ -307,204 +253,147 @@ spec:
service:
name: reactive-resume
port:
- number: 80
+ name: http
tls:
- hosts:
- resume.example.com
secretName: reactive-resume-tls
```
-
- Replace `resume.example.com`, `ingressClassName`, and the `cluster-issuer` name with your own host, controller class,
- and configured issuer. Point your hostname's DNS at the Ingress controller. The app listens on `PORT`
- and serves both the API and the built web app; the default image uses `PORT=3000`, so the example targets container
- port `3000`. If you change `PORT`, update the container port, Service `targetPort`, and readiness probe to match.
-
+Replace `resume.example.com`, `ingressClassName` and the `cluster-issuer` with your own values, and point your DNS at
+the Ingress controller. The annotations let the Ingress accept Assistant attachments of up to 25 MB and stream replies
+that take up to 4 minutes; [What every reverse proxy needs](/self-hosting/examples#what-every-reverse-proxy-needs)
+explains why. That section also covers client IP headers: make sure your controller doesn't pass on
+`CF-Connecting-IP`, `CF-Connecting-IPv6` or `True-Client-IP` headers sent by clients, or they can get around per-IP
+rate limits.
-Apply the four files in order and wait for PostgreSQL before starting the app:
-
-```bash
-kubectl apply -f namespace.yaml
-kubectl apply -f secret.yaml
-kubectl apply -f postgres.yaml
-kubectl -n reactive-resume rollout status deployment/postgres --timeout=300s
-kubectl apply -f reactive-resume.yaml
-kubectl -n reactive-resume rollout status deployment/reactive-resume --timeout=300s
-kubectl -n reactive-resume get pods -w
-```
-
-If you use an external database, skip `postgres.yaml` and its rollout check, and ensure the database is reachable first.
-
-The app Pod becomes `Ready` only after automatic migrations succeed and the `/api/health` endpoint reports the database
-and storage healthy. If the Pod exits or stays in `CrashLoopBackOff`, check the logs:
-
-```bash
-kubectl -n reactive-resume logs -f deployment/reactive-resume
-```
-
-## Storage: uploads and persistence
-
-Uploads are stored in one of two ways, exactly as in the Docker guide:
-
-- **Local storage (default)**. Unless all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are set,
- the app writes uploads under `/app/data`. The `reactive-resume-data` PVC is mounted there; `fsGroup: 1000` requests
- group write access from storage drivers that support it. Otherwise, configure volume permissions for UID/GID `1000`.
- Without that mount, uploads are lost when the Pod is replaced.
-- **S3-compatible storage**. Set `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` in the Secret. Set
- `S3_REGION` for your bucket (default: `us-east-1`) and `S3_ENDPOINT` for non-AWS services. Set
- `S3_FORCE_PATH_STYLE: "true"` for path-style services such as MinIO or SeaweedFS. You can then omit the app's uploads
- PVC, volume, and volume mount. Private AI Agent attachments require S3-compatible storage.
-
-
- Switching between local storage and S3 does not move existing uploads. Export or back them up before changing the
- storage driver.
-
-
-Back up the PostgreSQL database and the upload storage (the `reactive-resume-data` PVC or the S3 bucket) together, on a
-regular schedule. Recreating the Deployment must preserve both.
-
-## Ingress and the public URL
-
-The Ingress above routes `resume.example.com` to the Service and terminates TLS with cert-manager. Two rules apply:
-
-- `APP_URL` must equal the public HTTPS URL users visit. A mismatch (or serving HTTPS while `APP_URL` says `http://…`)
- causes sign-in redirects and cookies that do not stick.
-- The app serves the web app, the API, uploads, and assets from one origin. Proxy the whole application; do not rewrite
- or filter paths such as `/api/`.
-
-
- HTTPS is strongly recommended. Authentication cookies and the first-user signup flow depend on a correct public
- origin.
-
-
-## Health checks and startup
-
-Reactive Resume exposes a health endpoint at `/api/health` that verifies the **database** and **storage**; if either is
-unhealthy it returns HTTP `503`, and `200` when both are healthy.
-
-The Deployment uses this endpoint for **readiness**, keeping the Pod out of Service rotation until both dependencies
-are healthy. It deliberately omits a liveness probe against this dependency check: restarting the app does not repair
-a database or storage outage, and a slow migration should not be interrupted by a probe. Kubernetes restarts the
-container if the server process exits.
-
-To check the endpoint manually:
-
-```bash
-kubectl -n reactive-resume port-forward service/reactive-resume 3000:80
-```
-
-```bash
-curl -f http://localhost:3000/api/health
-```
-
-
- On every start the server **automatically runs database migrations** before serving traffic. If migrations fail
- (usually a database connection issue), the container exits with an error — check `kubectl logs`.
-
-
-## Verify the installation
+## Apply and verify
-
- Open `APP_URL` and sign up for the first account. Without SMTP configured, verification emails are logged to the
- server console instead of being sent: `kubectl -n reactive-resume logs -f deployment/reactive-resume`.
+
+ ```bash
+ kubectl apply -f namespace.yaml
+ kubectl apply -f secret.yaml
+ kubectl apply -f postgres.yaml
+ kubectl -n reactive-resume rollout status deployment/postgres --timeout=300s
+ kubectl apply -f reactive-resume.yaml
+ kubectl -n reactive-resume rollout status deployment/reactive-resume --timeout=600s
+ ```
+
+ The app Pod becomes `Ready` once its startup migrations finish and `/api/health` answers `200`. Follow along with
+ `kubectl -n reactive-resume logs -f deployment/reactive-resume`.
-
- Create a resume from the dashboard, add a few sections, and upload a profile picture. Reload the page and confirm
- the saved content and picture are present.
+
+ Open your `APP_URL` and create an account. You're signed in right away. Without SMTP, the verification email is
+ written to the app log instead of being sent.
-
- Open **Download** in the builder header and choose **PDF**. Builder PDF rendering happens in the browser with
- the Forme PDF engine. Open the downloaded file and check its text, fonts, and picture.
-
-
-
- Replace the Pod and verify nothing is lost:
+
+ Create a resume from a sample and upload a profile picture. Then replace the Pod:
```bash
kubectl -n reactive-resume rollout restart deployment/reactive-resume
- kubectl -n reactive-resume rollout status deployment/reactive-resume --timeout=300s
+ kubectl -n reactive-resume rollout status deployment/reactive-resume
```
- After the new Pod is `Ready`, sign in again and confirm the resume and any uploaded picture are still there.
- If you deployed the example PostgreSQL Deployment, also restart it with `kubectl -n reactive-resume rollout restart
- deployment/postgres`, wait for its rollout to complete, and confirm the same data remains. Expect downtime during
- these single-replica restarts.
+ Reload the page. The resume and picture should still be there. Expect a short outage while a single-replica
+ Deployment restarts.
-
- For a private single-user instance, add `FLAG_DISABLE_SIGNUPS: "true"` under `stringData` in `secret.yaml`, apply it
- with `kubectl apply -f secret.yaml`, and restart the app Deployment **after** your account exists.
+
+ For a private instance, add `FLAG_DISABLE_SIGNUPS: "true"` to `secret.yaml` after your account exists, apply it,
+ and restart the Deployment. Pods only read Secret changes when they start.
-## Community Helm chart (HelmForge)
+## How the probes work
-A community-maintained Helm chart for Reactive Resume is available in the HelmForge charts repository:
+- The **startup probe** gives the first start up to 5 minutes to run migrations before other probes begin.
+- The **readiness probe** calls `/api/health`, which checks the database, storage and, when configured, Redis. It
+ answers `503` if any of them fails, and Kubernetes stops sending traffic to that Pod until it recovers.
+- There is deliberately no liveness probe on `/api/health`. Restarting the app doesn't fix a database or storage
+ outage. If the server process itself exits, Kubernetes restarts the container anyway.
-- Chart source: [helmforgedev/charts — charts/reactive-resume](https://github.com/helmforgedev/charts/tree/main/charts/reactive-resume)
-- Chart documentation: [helmforge.dev — Reactive Resume](https://helmforge.dev/docs/charts/reactive-resume)
+See [Check the health endpoint](/self-hosting/docker#check-the-health-endpoint) for the response format. To call it
+yourself:
-
- This chart is **community-maintained and lives outside this repository**. It is not part of the Reactive Resume
- project, and chart support is handled in the HelmForge repository, not here. The manifests above work without it.
-
+```bash
+kubectl -n reactive-resume port-forward service/reactive-resume 3000:80
+curl http://localhost:3000/api/health
+```
-## Updating
+## Run several replicas
-1. **Back up the database and uploads first.** Do this before every update.
-2. **Restart the app to pull the current `latest` image.** The example explicitly sets `imagePullPolicy: Always`;
- setting the image to the same `latest` string does not trigger a rollout.
+The example runs one replica because uploads live on a `ReadWriteOnce` volume. To run more:
+
+1. Switch uploads to S3-compatible storage: add the `S3_*` variables to the Secret, then remove the PVC, the volume and
+ the `/app/data` mount. See [Store uploads in S3-compatible storage](/self-hosting/examples#store-uploads-in-s3-compatible-storage).
+2. Add Redis and set `REDIS_URL` in the Secret, so rate limits, stopping Assistant replies and live updates work
+ across Pods.
+3. Change `strategy` to `RollingUpdate` and raise `replicas`.
+
+Pods that start together are safe: they take turns on migrations, and only one applies changes.
+
+## Update the app
+
+1. Back up the database and uploads.
+2. Restart the Deployment to pull the newest `v6` image:
```bash
kubectl -n reactive-resume rollout restart deployment/reactive-resume
- ```
-
-3. **Wait for the rollout**, then check the startup logs while migrations run:
-
- ```bash
kubectl -n reactive-resume rollout status deployment/reactive-resume
- kubectl -n reactive-resume logs -f deployment/reactive-resume
```
-
- For reproducible deployments, pin a specific version tag or digest instead of `latest`, and update PostgreSQL
- separately from the app, following your operator's or provider's upgrade procedure. For a pinned app image, change
- `image` in `reactive-resume.yaml` and run `kubectl apply -f reactive-resume.yaml` to deploy the new version.
-
+3. Check the logs while the new version applies its migrations.
+
+For fully reproducible deployments, pin an exact tag such as `v6.0.0` (or an image digest), change it in
+`reactive-resume.yaml`, and run `kubectl apply -f reactive-resume.yaml`. Coming from v5? Read
+[Upgrading to v6](/self-hosting/upgrading-to-v6) first.
+
+Back up PostgreSQL and the uploads (the PVC or the S3 bucket) on a schedule, and keep a copy of the Secret. Update
+PostgreSQL separately from the app, following your operator's or provider's upgrade process.
+
+## Community Helm chart
+
+A community-maintained Helm chart is available from HelmForge:
+
+- Chart source: [helmforgedev/charts](https://github.com/helmforgedev/charts/tree/main/charts/reactive-resume)
+- Documentation: [helmforge.dev](https://helmforge.dev/docs/charts/reactive-resume)
+
+
+ The Helm chart is maintained outside the Reactive Resume project. Report chart problems in the HelmForge repository.
+ Check that it supports v6 before using it; the manifests above work without it.
+
## Troubleshooting
- - **Common cause**: database migrations failed (often a bad `DATABASE_URL`).
- - **What to do**: check logs with `kubectl -n reactive-resume logs -f deployment/reactive-resume` and confirm the
- PostgreSQL Pod is running and the Service is reachable. URL-encode special characters in the password.
+ Read the logs with `kubectl -n reactive-resume logs deployment/reactive-resume --previous`. `Invalid environment
+ variables` names a missing or malformed setting. `Database migrations failed` usually means a wrong
+ `DATABASE_URL` or PostgreSQL isn't ready. `Local storage path is not writable` means the volume isn't writable by
+ UID 1000; keep `fsGroup: 1000` or fix the volume's permissions.
-
- - **Common cause**: `APP_URL` does not match the URL you actually use, or you serve HTTPS while `APP_URL` says
- `http://…`.
- - **Fix**: set `APP_URL` to the canonical public HTTPS URL in the Secret, then restart the Deployment.
+
+ Port-forward and call `/api/health`. The entry marked `unhealthy` points at the problem: the database, the
+ uploads volume or S3 settings, or Redis.
-
- - **Common cause**: storage health failed (not only the database).
- - **Fix**: inspect the endpoint response payload and check the `storage` field; confirm the PVC is mounted and not
- full, and that the S3 settings (if used) are valid.
+
+ `APP_URL` doesn't match the address in the browser, or it says `http://` while you use `https://`. Fix it in the
+ Secret, apply, and restart the Deployment.
-
- - **Cause**: local upload storage was not mounted to a persistent volume.
- - **Fix**: add the `reactive-resume-data` PVC mount at `/app/data` (with `fsGroup: 1000`) and redeploy.
-
-
-
- - **Checks**: for builder exports, inspect the browser console and failed network requests, including fonts and
- images. Check download permissions, browser memory limits, extensions, and custom CSP rules.
- - No external Browserless or Chromium service is needed. API PDF downloads and the public viewer's server fallback
- render in the app process; inspect the app logs if one of those requests fails.
+
+ The Ingress is buffering responses, timing out after 60 seconds, or rejecting large bodies. Apply the annotations
+ from the example, or your controller's equivalents.
+
+## Related pages
+
+- [Environment variables](/self-hosting/environment-variables): every setting you can put in the Secret.
+- [Self-hosting with Docker](/self-hosting/docker): the same setup with Docker Compose, plus backups.
+- [Deployment examples](/self-hosting/examples): S3 storage, Redis, local AI and private instances.
diff --git a/docs/self-hosting/migration.mdx b/docs/self-hosting/migration.mdx
index 0c70f47f3..be7efc34a 100644
--- a/docs/self-hosting/migration.mdx
+++ b/docs/self-hosting/migration.mdx
@@ -1,363 +1,210 @@
---
-title: "Migrating from v4 to v5"
-description: "Step-by-step guide to migrate a self-hosted Reactive Resume instance from v4 to v5, covering database schema changes, manual and automated options."
+title: "Migrating from v4"
+description: "Move users and resumes from a self-hosted Reactive Resume v4 instance to the current version, by importing resumes one by one or with the migration scripts."
---
-## Overview
-
-To move a Reactive Resume installation from **v4 to v5**, you set up a new v5 instance alongside your existing v4 instance, then transfer your users and resumes across.
-
-
- This page is for **v4 → v5 data migration** only. For normal v5 upgrades, use the [Self-Hosting with
- Docker](/self-hosting/docker) guide. v5 schema migrations run automatically on app startup.
-
+Reactive Resume v4 used a different database schema, so you can't upgrade a v4 installation in place. Instead, you set up a new installation next to it and copy your users and resumes across. This page covers that move. To upgrade an existing v5 installation, see [Upgrading to v6](/self-hosting/upgrading-to-v6) instead.
- This guide applies only to infrastructure and backups you are authorized to operate. It does not grant access to
- hosted Reactive Resume data. Only the hosted service operator can verify whether a hosted snapshot exists and
- authorize access to it.
+ Keep your v4 instance running until every user and resume is in the new installation and you've checked the result. It's your fallback if something goes wrong. Work only on infrastructure and backups you're authorized to operate.
-
- **Keep your v4 instance running** until you have migrated all data to v5 and checked that everything works. That way
- you have a fallback if the migration goes wrong.
-
+## Before you start
-## Prerequisites
+You need:
-Before starting the migration, ensure you have:
+- Your running v4 instance, and a recent backup of its PostgreSQL database.
+- Access to the v4 database (the source) and to a new, empty PostgreSQL database for the new installation (the target).
+- A plan for the new installation. [Self-hosting with Docker](/self-hosting/docker) is the usual choice.
-
- Your existing Reactive Resume v4 instance should be running and accessible.
-
- A fresh Reactive Resume v5 instance set up and running. Follow the [Self-hosting with Docker](/self-hosting/docker)
- guide if you haven't done this yet.
-
-
- Access to both your v4 PostgreSQL database (source) and v5 PostgreSQL database (target).
-
- A recent backup of your v4 database. Always back up before any migration.
-
+## Choose a method
-## Choosing a migration method
+| Method | Best for | How it works |
+| --- | --- | --- |
+| [Import resumes one by one](#import-resumes-one-by-one) | A handful of resumes | Each person exports their v4 resumes as JSON and imports them into the new installation. |
+| [Run the migration scripts](#run-the-migration-scripts) | Many users and resumes | Scripts copy users, sign-in methods, resumes, and statistics straight from database to database. |
-The best migration approach depends on the size of your instance:
+## Import resumes one by one
-
-
- **Best for**: Small instances with a handful of resumes. Uses the built-in Import Dialog to manually convert resumes
- one at a time.
-
-
- **Best for**: Large instances with many users and resumes. Uses migration scripts to batch-process all users and
- resumes automatically.
-
-
-
-## Recover one owner's resumes without overwriting v5
-
-Use a recovery case when an owner changed resumes after an earlier migration. Keep the case record private and outside
-Git because it may connect account and resume identifiers. Record these fields before inspecting resume content:
-
-- Recovery case ID
-- Source snapshot capture time
-- Owner verification status
-- Source resume ID and mapped target resume ID, if one exists
-- Source and target content hashes
-- Proposed outcome: `no-op`, `export-copy`, or `blocked`
-
-Case IDs, source resume IDs, and non-null target resume IDs must contain a non-whitespace character after trimming and
-must not contain Unicode control or format characters. Valid identifiers are preserved verbatim.
-
-These hashes prove content equality only; they never prove ownership, source authenticity, or recipient identity.
-
-An authorized operator should follow this order:
-
-
-
- Confirm that a source snapshot exists and record when it was captured. If no source snapshot exists, report that
- factual limit. Recovery tooling cannot reconstruct records that are absent from every available source.
-
-
-
- Verify the requester using the operator's approved account-ownership process. Confirm the old-to-new owner mapping;
- a matching email address, resume title, or username alone is not ownership proof. Stop if either check is incomplete.
-
-
-
- Build one serialized JSON comparison request containing the case IDs, safety flags, and source and target values. The
- comparator accepts only this request string, not an object argument. Source and target data must already conform
- exactly to the current v5 resume-data schema before content hashes are calculated. Raw v4 exports are unsupported,
- and the comparator performs no format conversion. Identical content is a `no-op`. Source-only or divergent content
- is an `export-copy`. Missing identity evidence, mapping, valid current-v5 data, or a source snapshot is `blocked`.
-
-
-
- Default to a private JSON export. Never overwrite the current v5 resume. After the recipient and delivery channel are
- approved, deliver the recovered JSON privately so the owner can import it as a separate resume. Record source and
- delivered hashes outside Git and confirm they match.
-
-
-
-Repository contributors can rehearse this decision with the pure comparator in
-`tooling/recovery/compare-resume.ts`. It accepts one serialized JSON comparison request and rejects object arguments.
-The request's source and target values must already conform exactly to the current v5 resume-data schema. It does not
-accept raw v4 exports or perform legacy conversion; review the historical converter at tag `v5.0.20` separately before
-processing legacy-format data. The comparator produces a deterministic dry-run manifest and has no database, network,
-or write path. Use synthetic IDs and content only; keep any operational manifest outside the repository.
-
-## Manual migration (small instances)
-
-If you have only a few resumes to migrate, the simplest approach is to use the **Import Dialog** feature in v5.
+The current version reads v4 JSON exports directly and converts them.
- In your v4 instance, go to each resume and export it as JSON. This creates a portable file containing all your resume data.
+ In your v4 instance, open each resume and export it as JSON.
-
-
- In your new v5 instance: 1. Sign in or create a new account 2. Click **Create Resume** or use the **Import** option 3.
- Select the **Reactive Resume v4** format 4. Upload your exported JSON file The import process automatically converts
- the v4 format to v5.
-
-
-
- Review the imported resume to ensure all data transferred correctly. Repeat for each resume you need to migrate.
+
+ Sign in to the new installation (create an account first if needed), select **New**, then **Import a resume**, and choose the JSON file. Reactive Resume recognizes the v4 format on its own. See [Importing resumes](/guides/importing-resumes) for details.
+
+
+ Look through the imported resume, especially dates and layout, then repeat for the next one.
-
- The Import Dialog handles the schema conversion automatically, so you don't need to worry about format differences
- between v4 and v5.
-
+## Run the migration scripts
-## Automated migration (large instances)
+The scripts live in the repository at tag `v5.0.20`, the last release that includes them. They were written against the database schema of that release, so you create the target database with that release first, run the scripts, and only then start the current version, which brings the data forward with its own migrations.
-For instances with many users and resumes, use the migration scripts to automate the process. The migration happens in two phases: first users, then resumes.
+
+ Don't run the scripts against a database that a newer release has already set up. They don't fill in columns added later (such as the sign-in `issuer` added in v5.2.8), so migrated users could fail to sign in. Starting the current version after the scripts fills those columns in.
+
-### Requirements
+### Prepare the scripts
-To run the migration scripts, you need the following installed on your host machine:
+Install `tsx` and a `.env` loader such as `dotenvx` on the machine that runs the scripts, then check out the tag:
-
-
- **tsx** - TypeScript execution environment. Install globally with: ```bash npm install -g tsx ```
-
-
- **dotenvx** (or any tool to load `.env` files). Install globally with: ```bash npm install -g @dotenvx/dotenvx ```
- Alternatively, you can use `dotenv`, `direnv`, or export the variables manually.
-
-
- Clone the Reactive Resume repository and check out the last tag that includes the migration scripts:
- ```bash
- git clone https://github.com/reactive-resume/reactive-resume.git reactive-resume
- cd reactive-resume
- git checkout tags/v5.0.20
- pnpm install
- ```
-
-
+```bash
+npm install -g tsx @dotenvx/dotenvx
+git clone https://github.com/reactive-resume/reactive-resume.git reactive-resume-migration
+cd reactive-resume-migration
+git checkout tags/v5.0.20
+pnpm install
+```
-
- The v4 migration scripts live in the `v5.0.20` tag. Use that checkout only to run the migration scripts against your
- v4 and v5 databases; keep your actual v5 deployment on the latest version.
-
+Use this checkout only to run the migration. Your actual installation runs the current release.
-### Environment setup
-
-Create a `.env` file in the root of the repository with the following variables:
+Create a `.env` file at the root of the checkout:
```bash .env
-# Connection string to your NEW v5 PostgreSQL database (target)
-DATABASE_URL="postgresql://user:password@localhost:5432/reactive_resume_v5"
+# The NEW, empty database (target)
+DATABASE_URL="postgresql://user:password@localhost:5432/reactive_resume"
-# Connection string to your OLD v4 PostgreSQL database (source)
+# The OLD v4 database (source)
PRODUCTION_DATABASE_URL="postgresql://user:password@localhost:5432/reactive_resume_v4"
```
- Double-check your connection strings! `DATABASE_URL` should point to your **new v5 database** and
- `PRODUCTION_DATABASE_URL` should point to your **old v4 database**. Mixing these up could cause data loss.
+ Double-check both connection strings. `DATABASE_URL` is the new target and `PRODUCTION_DATABASE_URL` is the old v4 source. Swapping them can destroy data. `PRODUCTION_DATABASE_URL` is used only by these scripts, never by the app.
-`PRODUCTION_DATABASE_URL` is used only by these migration scripts. It is not a runtime app variable.
+### Create the target schema
-### Step 1: Migrate users
+Apply the `v5.0.20` migrations to the empty target database:
-The user migration script transfers all user accounts, authentication data, and two-factor settings from v4 to v5.
+```bash
+dotenvx run -- pnpm db:migrate
+```
+
+### Migrate users
```bash
dotenvx run -- tsx scripts/migration/user.ts
```
-**What this script does:**
+The script:
-- Fetches users in batches from the v4 database
-- Creates corresponding user accounts in the v5 database
-- Migrates authentication providers (email, Google, GitHub, custom OAuth)
-- Preserves two-factor authentication settings and backup codes
-- Creates a mapping file (`scripts/migration/user-id-map.json`) that links old user IDs to new ones
+- Reads v4 users in batches and creates matching users in the target.
+- Copies sign-in methods (email and password, Google, GitHub, custom OAuth). Two-factor authentication isn't copied: every migrated user starts with it turned off.
+- Writes `scripts/migration/user-id-map.json`, which links each v4 user ID to its new ID. The resume script needs it.
+- Saves progress to `scripts/migration/user-progress.json` after each batch. If the run stops, run it again to continue from the last saved batch.
-
- The script saves progress automatically. If interrupted (Ctrl+C), you can run it again and it will resume from where
- it left off.
-
+It ends with a summary of users created, accounts created, and users skipped.
-**Expected output:**
-
-```
-⌛ Starting user migration...
-📥 Fetching users batch from production database (OFFSET 0)...
-📋 Found 1000 users in this batch.
-📝 Preparing to bulk insert 1000 users...
-✅ Bulk inserted 1000 users in 245.3 ms (avg 0.2 ms/user)
-💾 Progress saved at offset 1000
-📦 Processed 1000 users so far...
-
-📊 Migration Summary:
- Users created: 1000
- Accounts created: 1000
- Two-factor entries created: 50
- Skipped (already exist): 0
-⏱️ Total migration time: 1234.5 ms (1.23 seconds)
-✅ User migration complete!
-```
-
-### Step 2: Migrate resumes
-
-After users are migrated, run the resume migration script. This script depends on the user ID mapping created in the previous step.
+### Migrate resumes
```bash
dotenvx run -- tsx scripts/migration/resume.ts
```
-**What this script does:**
+The script:
-- Fetches resumes in batches from the v4 database
-- Converts each resume from v4 format to v5 format automatically
-- Links resumes to the correct users using the ID mapping
-- Migrates resume statistics (views, downloads)
-- Preserves visibility settings (public/private) and lock status
+- Reads v4 resumes in batches, converts each to the new format, and assigns it to the right user through the ID map.
+- Copies view and download statistics, public or private visibility, and the locked state.
+- Saves progress to `scripts/migration/resume-progress.json` after each batch, and continues from there if you run it again.
-Like the user script, the resume migration also saves progress and can be resumed if interrupted.
-
-**Expected output:**
-
-```
-⌛ Starting resume migration...
-📥 Fetching resumes batch from production database (OFFSET 0)...
-📋 Found 2500 resumes in this batch.
-📝 Preparing to bulk insert 2500 resumes...
-✅ Bulk inserted 2500 resumes in 892.1 ms (avg 0.4 ms/resume)
-💾 Progress saved at offset 2500
-📦 Processed 2500 resumes so far...
-
-📊 Migration Summary:
- Resumes created: 2500
- Statistics created: 2500
- Skipped (userId not found or already exist): 0
- Errors: 0
-⏱️ Total migration time: 5678.9 ms (5.68 seconds)
-✅ Resume migration complete!
-```
-
-### Progress and recovery
-
-Both migration scripts support graceful shutdown and resume:
-
-- **Progress files**: `scripts/migration/user-progress.json` and `scripts/migration/resume-progress.json` track the current migration state
-- **User ID mapping**: `scripts/migration/user-id-map.json` maps v4 user IDs to v5 user IDs
-- **Graceful shutdown**: Press `Ctrl+C` to stop the migration safely. Progress is saved before exit.
-- **Resume migration**: Run the script again to continue from where you left off
+It ends with a summary of resumes created, resumes skipped, and errors.
- Preserve progress files and the user ID mapping as recovery evidence. Do not delete them or replay the historical
- scripts against a populated target as a recovery shortcut. Review the `v5.0.20` scripts, source backup, mapping, and
- target state before any rerun.
+ Each script deletes its progress file when it finishes. Keep `user-id-map.json` as the record of which v4 user became which new user. Don't re-run the scripts against a populated target to "fix" a problem; review the source backup, the map, and the target first.
-## Post-migration steps
+### Start the current version
-After completing the migration:
+Point a current Reactive Resume installation at the target database and start it. On startup it applies every migration since `v5.0.20`, which also fills in the new columns for the migrated users. Watch the logs for `Database migrations completed`.
+
+## After the migration
-
- Sign in to your v5 instance and spot-check several user accounts and resumes to ensure data transferred correctly.
+
+ Sign in as a few users and spot-check their resumes.
-
-
- - Create a test resume and export it as PDF - Verify social sign-in works (if configured) - Check that two-factor
- authentication works for migrated users
-
-
-
- Once verified, update your DNS records or reverse proxy to point to the new v5 instance.
-
-
-
- After confirming everything works and allowing a grace period, you can safely shut down your v4 instance.
+
+ Download a resume as PDF, and sign in with email and password and with each social provider you use.
+
+
+ Point your DNS or reverse proxy at the new installation.
+
+
+ After a grace period with no problems, shut down the v4 instance.
-## Important notes
+## What carries over
-
- Users who signed up with email/password can continue using their existing passwords. No password reset is required after migration.
+
+ People who signed up with email and password keep their password. No reset is needed.
+
+
+ The database stores references to pictures, not the files. Give the new installation access to the same storage (for example the same S3 bucket), or people re-upload their pictures.
+
+
+ Configure the same providers with the same client IDs in the new installation. Custom OAuth now uses a different callback URL, so update your provider as described in [Single sign-on (SSO)](/self-hosting/sso). On the first sign-in, the provider account is linked to the migrated user by email. For custom OAuth, that works only if the user's email was verified in v4; otherwise the sign-in stops with an "account not linked" error.
+
+
+ Not carried over. Users who had two-factor authentication in v4 sign in with their password alone, and can set it up again in **Settings → Account**.
-
-
- User profile pictures (avatars) are stored as references in the database. If you were using S3 storage, ensure your v5
- instance has access to the same bucket, or users may need to re-upload their avatars.
-
-
-
- Similar to profile pictures, any images embedded in resumes need to be accessible from your v5 instance. Consider
- migrating your storage bucket or updating references as needed.
-
-
-
- If you're using custom OAuth providers, ensure the same providers are configured in v5 with matching client IDs. Users
- authenticate with the same provider ID, so mismatched configurations will cause login failures.
-
-
- The v5 schema has some changes from v4:
- - `visibility` (public/private) is now `isPublic` (boolean)
- - Resume `title` is now `name`
- - Some resume data fields have been reorganized
-
- The migration scripts handle these conversions automatically.
+ v4's `visibility` became a public/private flag, a resume's `title` became its `name`, and resume data was reorganized. The scripts and the importer handle these conversions.
+## Recover one person's resumes later
+
+Sometimes a person changes a resume in v4 after the main migration, or a resume didn't come across. Handle this as a recovery case rather than re-running the scripts, and never overwrite the resume in the new installation.
+
+Keep a private record for each case, outside Git, because it links account and resume identifiers:
+
+- Case ID and the capture time of the source snapshot.
+- How you verified the person owns the account.
+- Source resume ID and mapped target resume ID, if there is one.
+- Source and target content hashes, and the outcome: `no-op`, `export-copy`, or `blocked`.
+
+
+
+ Check that a source snapshot exists and note when it was taken. Records missing from every available source can't be reconstructed.
+
+
+ Verify the requester through your approved account-ownership process, and confirm the old-to-new user mapping. A matching email, resume title, or username alone isn't proof. Stop if either check is incomplete.
+
+
+ Identical content is a `no-op`. Content that exists only in the source, or differs, is an `export-copy`. Missing evidence, mapping, valid data, or source is `blocked`. Content hashes prove equality only, never ownership or authenticity.
+
+
+ Send the recovered JSON privately through an approved channel, so the person imports it as a new resume. Confirm the delivered hash matches the source.
+
+
+
+Contributors can rehearse the comparison with `tooling/recovery/compare-resume.ts`. It takes one serialized JSON request whose source and target already match the current resume schema, produces a dry-run manifest, and never touches a database or the network. It doesn't convert raw v4 data; review the converter at tag `v5.0.20` for that. Use synthetic data only.
+
## Troubleshooting
-
- Ensure your `.env` file contains both `DATABASE_URL` and `PRODUCTION_DATABASE_URL`, and that you're using a tool like `dotenvx` to load them before running the script.
+
+ Put both `DATABASE_URL` and `PRODUCTION_DATABASE_URL` in `.env` and run the scripts through `dotenvx run --` (or export the variables yourself).
-
-
- Users are skipped if: - Their email already exists in the v5 database - Their username already exists in the v5
- database - They were already migrated in a previous run Check the console output for skip reasons.
-
-
-
- Resumes are skipped if: - The associated user wasn't migrated (user ID not in mapping file) - A resume with the same
- slug already exists for that user - They were already migrated in a previous run
-
-
-
- Historical scripts can create default empty data when a v4 resume cannot be parsed. Treat that result as a failed
- conversion, not a recovered resume. Preserve the source export, review the `v5.0.20` converter, and use the Import
- Dialog only after valid source data is confirmed.
-
-
-
- The scripts process data in batches to avoid overwhelming the database. For very large instances:
- - Consider running the migration during off-peak hours
- - Ensure both databases have adequate resources
- - The batch size can be adjusted in the script files if needed
+
+ A user is skipped when their email or username already exists in the target, or when an earlier run already migrated them. The console output gives the reason.
+
+
+ A resume is skipped when its owner isn't in the user ID map, when the owner already has a resume with the same slug, or when an earlier run already migrated it.
+
+
+ When a v4 resume can't be parsed, the scripts can create empty default data. Treat that as a failed conversion, not a recovered resume. Keep the source export and import it by hand once you've confirmed it's valid.
+
+
+ Check that you started the current version against the target database after the scripts, so its migrations ran. The startup log shows `Database migrations completed`.
+
+
+ The scripts work in batches to spare the databases. Run them off-peak, give both databases enough resources, or adjust the batch size in the script files.
diff --git a/docs/self-hosting/sso.mdx b/docs/self-hosting/sso.mdx
index d908bc7cf..6f7e94e1a 100644
--- a/docs/self-hosting/sso.mdx
+++ b/docs/self-hosting/sso.mdx
@@ -1,373 +1,239 @@
---
-title: "Single Sign-On (SSO)"
-description: "Configure Single Sign-On for self-hosted Reactive Resume using custom OAuth providers like Authentik, Authelia, Keycloak, or any OIDC-compliant IdP."
+title: "Single sign-on (SSO)"
+description: "Let people sign in to your self-hosted Reactive Resume through Authentik, Authelia, Keycloak, or any OpenID Connect or OAuth 2.0 identity provider."
---
-## Overview
-
-Reactive Resume supports custom OAuth providers, so you can sign in through an enterprise identity provider or a self-hosted authentication service. This helps organizations that want to:
-
-- Use a centralized identity provider (Authentik, Authelia, Keycloak, etc.)
-- Enforce Single Sign-On (SSO) across all internal applications
-- Integrate with existing LDAP/Active Directory infrastructure
+This guide connects a self-hosted Reactive Resume to your own identity provider, so people sign in with their company or homelab account. It works with any OpenID Connect (OIDC) provider, and with plain OAuth 2.0 providers that expose a user-info endpoint. You configure it with environment variables and a restart; there's nothing to set up in the app.
- Custom OAuth is for **self-hosted instances**. If you're using the hosted version at
- [rxresu.me](https://rxresu.me), you can use the built-in Google and GitHub sign-in options.
+ This is for self-hosted installations. On [rxresu.me](https://rxresu.me), use the built-in sign-in options.
-## Environment variables
+## Before you start
-To enable a custom OAuth provider, you need to configure the following environment variables in your `.env` file:
+You need:
-### Required variables
+- Admin access to your identity provider, so you can register an application (client).
+- The public address of your installation, set as `APP_URL`, for example `https://resume.example.com`. Every callback URL is built from it.
+- A provider that returns an email address for each user. Reactive Resume identifies accounts by email and refuses sign-ins without one.
-| Variable | Description |
-| --------------------- | ------------------------------------------------- |
-| `OAUTH_CLIENT_ID` | The client ID provided by your OAuth provider |
-| `OAUTH_CLIENT_SECRET` | The client secret provided by your OAuth provider |
+## Register Reactive Resume with your provider
-### Endpoint configuration
+Create a confidential OAuth 2.0 / OIDC client in your provider with these settings:
-You must configure endpoints using **one** of these two methods:
+| Setting | Value |
+| --- | --- |
+| Redirect URI (callback URL) | `{APP_URL}/api/auth/callback/custom`, for example `https://resume.example.com/api/auth/callback/custom` |
+| Grant type | Authorization code |
+| Scopes | `openid profile email` |
+
+Copy the client ID and client secret. The callback must match exactly, including `https`, the port, and the absence of a trailing slash.
+
+
+ Upgrading from a release before v5.2.8? The callback path changed from `/api/auth/oauth2/callback/custom` to `/api/auth/callback/custom`. Update the redirect URI in your provider, or sign-in fails after the upgrade.
+
+
+## Configure the environment variables
+
+Set the client credentials and one way of finding the provider's endpoints, then restart Reactive Resume.
-
- For OIDC-compliant providers (most modern identity providers), you only need to set the discovery URL:
-
- | Variable | Description |
- |----------|-------------|
- | `OAUTH_DISCOVERY_URL` | Your provider's `.well-known/openid-configuration` URL |
-
- The discovery URL automatically provides the authorization, token, and userinfo endpoints.
-
- **Examples:**
- - Authentik: `https://auth.example.com/application/o/reactive-resume/.well-known/openid-configuration`
- - Keycloak: `https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration`
- - Authelia: `https://auth.example.com/.well-known/openid-configuration`
+
+ Most modern providers publish a discovery document. Point Reactive Resume at it and it reads the authorization, token, and user-info endpoints from there.
+ ```bash .env
+ OAUTH_PROVIDER_NAME="Company SSO"
+ OAUTH_CLIENT_ID="your-client-id"
+ OAUTH_CLIENT_SECRET="your-client-secret"
+ OAUTH_DISCOVERY_URL="https://sso.example.com/.well-known/openid-configuration"
+ ```
+
+ For providers without discovery, set all three endpoint URLs.
-
- For providers that don't support OIDC discovery, you must set all three URLs:
-
- | Variable | Description |
- |----------|-------------|
- | `OAUTH_AUTHORIZATION_URL` | The URL where users are redirected to authorize |
- | `OAUTH_TOKEN_URL` | The URL to exchange authorization codes for tokens |
- | `OAUTH_USER_INFO_URL` | The URL to fetch user profile information |
-
+ ```bash .env
+ OAUTH_PROVIDER_NAME="Company SSO"
+ OAUTH_CLIENT_ID="your-client-id"
+ OAUTH_CLIENT_SECRET="your-client-secret"
+ OAUTH_AUTHORIZATION_URL="https://sso.example.com/oauth/authorize"
+ OAUTH_TOKEN_URL="https://sso.example.com/oauth/token"
+ OAUTH_USER_INFO_URL="https://sso.example.com/oauth/userinfo"
+ ```
-### Optional variables
+| Variable | Required | Description |
+| --- | --- | --- |
+| `OAUTH_CLIENT_ID` | Yes | Client ID from your provider. |
+| `OAUTH_CLIENT_SECRET` | Yes | Client secret from your provider. |
+| `OAUTH_DISCOVERY_URL` | One of the two methods | The provider's `/.well-known/openid-configuration` URL. |
+| `OAUTH_AUTHORIZATION_URL`, `OAUTH_TOKEN_URL`, `OAUTH_USER_INFO_URL` | One of the two methods | All three, when the provider has no discovery document. |
+| `OAUTH_PROVIDER_NAME` | No | Label on the sign-in button and in **Settings**. Default `Custom OAuth`. |
+| `OAUTH_SCOPES` | No | Space-separated scopes. Default `openid profile email`. |
-| Variable | Description | Default |
-| ------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------- |
-| `OAUTH_PROVIDER_NAME` | Display name shown on the sign-in button | `Custom OAuth` |
-| `OAUTH_SCOPES` | Space-separated list of OAuth scopes | `openid profile email` |
-
-## Callback URL
-
-When configuring your OAuth provider, you'll need to set the **callback URL** (also called redirect URI). Use the following format:
-
-```
-{APP_URL}/api/auth/callback/custom
-```
-
-For example, if your `APP_URL` is `https://resume.example.com`, the callback URL would be:
-
-```
-https://resume.example.com/api/auth/callback/custom
-```
+All URLs must use `http` or `https`. On Docker, restart the container after changing `.env`; on Vercel, add the variables to the project and redeploy.
- Make sure the callback URL exactly matches what you configure in your OAuth provider. A mismatch will cause
- authentication to fail.
+ The sign-in button appears as soon as `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are set, but sign-in works only when `OAUTH_DISCOVERY_URL` or all three manual URLs are also set. If the button shows and does nothing useful, check the endpoint variables.
-
- **Upgrading an existing install:** the callback path changed from `/api/auth/oauth2/callback/custom` to
- `/api/auth/callback/custom`. Update the redirect URI registered with your identity provider, or custom OAuth
- sign-in will fail after the upgrade.
-
+## Check that it works
-
- **Upgrading an existing install with `OAUTH_DISCOVERY_URL`:** accounts are now identified by the issuer your
- provider advertises, and the automatic migration cannot know that value ahead of time. It backfills existing
- rows with the placeholder `local:oauth:custom`. After upgrading, run the following once, replacing the value
- with the `issuer` field from your discovery document (`{OAUTH_DISCOVERY_URL}` returns it as JSON):
+1. Open your installation's sign-in page. Under **or continue with**, a button with a key icon shows your `OAUTH_PROVIDER_NAME`.
+2. Select it, sign in at your provider, and approve access. You land on **Documents**.
+3. In **Settings → Account**, the **Sign-in & security** section lists your provider as **Connected**.
- ```sql
- UPDATE account SET issuer = 'https://auth.example.com/realms/main' WHERE provider_id = 'custom';
- ```
+People who already have an account can add or remove SSO from the same place with **Connect** and **Disconnect**. See [Linking social accounts](/guides/linking-social-accounts).
- Skipping this does not lose data, but existing users signing in through your provider will no longer match
- their account. If you configure the provider with explicit `OAUTH_AUTHORIZATION_URL` / `OAUTH_TOKEN_URL`
- endpoints instead of discovery, the placeholder is already correct and no action is needed.
-
+## How profiles are mapped
-
- Built-in providers (Google, GitHub, LinkedIn) use callback URLs in this format: `{APP_URL}/api/auth/callback/
- {provider}` (for example `.../google`, `.../github`, `.../linkedin`).
-
+When someone signs in for the first time, Reactive Resume creates their account from the provider's profile:
-## URL and proxy requirements
+| Reactive Resume field | Taken from, in order |
+| --- | --- |
+| Email (required) | `email` |
+| Name | `name`, then `preferred_username`, then the part of the email before `@` |
+| Username | `preferred_username`, then the part of the email before `@`. A number is added if it's taken. |
+| Picture | `image`, then `picture`, then `avatar_url` |
-- Set `APP_URL` to the exact public URL users access (prefer HTTPS in production).
-- Auth metadata, JWKS, and OAuth callback URLs are derived from `APP_URL`.
-- Behind a reverse proxy, forward `Host` and `X-Forwarded-Proto` correctly, or cookie/session behavior may break.
-- `trustedOrigins` are derived from `APP_URL`, so alternate domains are not automatically trusted.
+If the email already belongs to an account, the sign-in is linked to that account, which keeps its name and username. This works only when that account's email address is verified in Reactive Resume. If it isn't (common on installations without SMTP, where verification emails never arrive), the sign-in stops with an "account not linked" error.
-## Profile mapping
+## Make SSO the only way in
-Reactive Resume maps user profile data from the OAuth provider using these fields:
+Two feature flags turn Reactive Resume into an SSO-only installation:
-| Reactive Resume Field | OAuth Profile Fields (in order of preference) |
-| --------------------- | --------------------------------------------- |
-| **Email** (required) | `email` |
-| **Name** | `name` → `preferred_username` → email prefix |
-| **Username** | `preferred_username` → email prefix |
-| **Avatar** | `image` → `picture` → `avatar_url` |
+| Variable | Effect |
+| --- | --- |
+| `FLAG_DISABLE_EMAIL_AUTH=true` | Hides email-and-password sign-in and registration. |
+| `FLAG_DISABLE_SIGNUPS=true` | Blocks new accounts through every method, including SSO. Existing users can still sign in. |
-
- The OAuth provider **must** return an email address. If no email is provided, authentication will fail with an error.
-
+Set only `FLAG_DISABLE_EMAIL_AUTH` if new people should still get an account on their first SSO sign-in.
-## Provider-specific setup
+## Provider examples
-### Authentik
+Replace the hostnames with your own. The redirect URI is always `{APP_URL}/api/auth/callback/custom`.
-
-
- In the Authentik admin interface, navigate to **Applications → Providers** and create a new **OAuth2/OpenID Provider**.
+
+
+ 1. In the admin interface, open **Applications → Providers** and create an **OAuth2/OpenID Provider**. Set **Client type** to **Confidential** and add the redirect URI.
+ 2. Open **Applications → Applications**, create an application with the slug `reactive-resume`, and select the provider.
+ 3. Copy the client ID and secret from the provider.
- - **Name**: Reactive Resume
- - **Authorization flow**: Use your preferred authorization flow
- - **Client type**: Confidential
- - **Redirect URIs**: `https://resume.example.com/api/auth/callback/custom`
+ ```bash .env
+ OAUTH_PROVIDER_NAME="Authentik"
+ OAUTH_CLIENT_ID="your-client-id"
+ OAUTH_CLIENT_SECRET="your-client-secret"
+ OAUTH_DISCOVERY_URL="https://auth.example.com/application/o/reactive-resume/.well-known/openid-configuration"
+ ```
+
-
+
+ Add a client to Authelia's `configuration.yml`. Store a hashed secret there, generated with `authelia crypto hash generate pbkdf2 --variant sha512`.
-
- Navigate to **Applications → Applications** and create a new application:
+ ```yaml
+ identity_providers:
+ oidc:
+ clients:
+ - client_id: reactive-resume
+ client_name: Reactive Resume
+ client_secret: "the-hashed-secret"
+ public: false
+ authorization_policy: two_factor
+ redirect_uris:
+ - https://resume.example.com/api/auth/callback/custom
+ scopes:
+ - openid
+ - profile
+ - email
+ token_endpoint_auth_method: client_secret_post
+ ```
- - **Name**: Reactive Resume
- - **Slug**: `reactive-resume`
- - **Provider**: Select the provider you just created
+ Give Reactive Resume the **plain-text** secret, not the hash:
-
+ ```bash .env
+ OAUTH_PROVIDER_NAME="Authelia"
+ OAUTH_CLIENT_ID="reactive-resume"
+ OAUTH_CLIENT_SECRET="the-plain-text-secret"
+ OAUTH_DISCOVERY_URL="https://auth.example.com/.well-known/openid-configuration"
+ ```
+
-From the provider settings, copy the **Client ID** and **Client Secret**.
+
+ 1. In your realm, open **Clients → Create client** and set **Client ID** to `reactive-resume`.
+ 2. Turn **Client authentication** on and keep **Standard flow** enabled.
+ 3. Add the redirect URI under **Valid redirect URIs**.
+ 4. Copy the secret from the **Credentials** tab.
-
-```bash .env
-OAUTH_PROVIDER_NAME="Authentik"
-OAUTH_CLIENT_ID="your-client-id"
-OAUTH_CLIENT_SECRET="your-client-secret"
-OAUTH_DISCOVERY_URL="https://auth.example.com/application/o/reactive-resume/.well-known/openid-configuration"
-```
-
-
+ ```bash .env
+ OAUTH_PROVIDER_NAME="Keycloak"
+ OAUTH_CLIENT_ID="reactive-resume"
+ OAUTH_CLIENT_SECRET="your-client-secret"
+ OAUTH_DISCOVERY_URL="https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration"
+ ```
+
+
-### Authelia
+## Built-in providers
-
-
- Add a client configuration to your Authelia `configuration.yml`:
+Google, GitHub, and LinkedIn sign-in each turn on when both of their variables are set, and can run alongside your own provider:
-```yaml
-identity_providers:
- oidc:
- clients:
- - client_id: reactive-resume
- client_name: Reactive Resume
- client_secret: "your-hashed-secret" # Use authelia hash-password to generate
- public: false
- authorization_policy: two_factor # or one_factor
- redirect_uris:
- - https://resume.example.com/api/auth/callback/custom
- scopes:
- - openid
- - profile
- - email
- token_endpoint_auth_method: client_secret_post
+| Provider | Variables | Callback URL |
+| --- | --- | --- |
+| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | `{APP_URL}/api/auth/callback/google` |
+| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | `{APP_URL}/api/auth/callback/github` |
+| LinkedIn | `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET` | `{APP_URL}/api/auth/callback/linkedin` |
+
+## Upgrading an install that uses OIDC discovery
+
+Since v5.2.8, linked accounts are identified by the issuer your provider advertises. The database migration that introduced this can't know your issuer, so it filled existing SSO accounts with the placeholder `local:oauth:custom`. If you used `OAUTH_DISCOVERY_URL` before v5.2.8, run this once after upgrading, with the `issuer` value from your discovery document:
+
+```sql
+UPDATE account SET issuer = 'https://auth.example.com/realms/main' WHERE provider_id = 'custom';
```
-
- Generate the hashed secret using: `authelia crypto hash generate pbkdf2 --variant sha512`
-
+Without it, existing users who sign in through your provider no longer match their account. No data is lost. Installations that use the three manual URLs already have the right value and need nothing.
-
+## URLs and reverse proxies
-
-```bash .env
-OAUTH_PROVIDER_NAME="Authelia"
-OAUTH_CLIENT_ID="reactive-resume"
-OAUTH_CLIENT_SECRET="your-plain-secret"
-OAUTH_DISCOVERY_URL="https://auth.example.com/.well-known/openid-configuration"
-```
-
-
- Use the **plain text** secret in Reactive Resume's environment, not the hashed version used in Authelia's configuration.
-
-
-
-
-
-### Keycloak
-
-
-
- In the Keycloak admin console:
-
- 1. Select your realm
- 2. Navigate to **Clients → Create client**
- 3. Set **Client ID** (e.g., `reactive-resume`)
- 4. Set **Client authentication** to **On**
- 5. Enable **Standard flow**
-
-
-
-
- In the client settings, add the redirect URI:
-
- - **Valid redirect URIs**: `https://resume.example.com/api/auth/callback/custom`
-
-
-
-Go to the **Credentials** tab and copy the **Client secret**.
-
-
-```bash .env
-OAUTH_PROVIDER_NAME="Keycloak"
-OAUTH_CLIENT_ID="reactive-resume"
-OAUTH_CLIENT_SECRET="your-client-secret"
-OAUTH_DISCOVERY_URL="https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration"
-```
-
-
-
-### Generic OIDC provider
-
-For any other OIDC-compliant provider:
-
-```bash .env
-OAUTH_PROVIDER_NAME="My SSO"
-OAUTH_CLIENT_ID="your-client-id"
-OAUTH_CLIENT_SECRET="your-client-secret"
-OAUTH_DISCOVERY_URL="https://sso.example.com/.well-known/openid-configuration"
-```
-
-### Non-OIDC provider (manual configuration)
-
-For providers that don't support OIDC discovery:
-
-```bash .env
-OAUTH_PROVIDER_NAME="Custom Provider"
-OAUTH_CLIENT_ID="your-client-id"
-OAUTH_CLIENT_SECRET="your-client-secret"
-OAUTH_AUTHORIZATION_URL="https://provider.example.com/oauth/authorize"
-OAUTH_TOKEN_URL="https://provider.example.com/oauth/token"
-OAUTH_USER_INFO_URL="https://provider.example.com/oauth/userinfo"
-OAUTH_SCOPES="openid profile email"
-```
-
-## Complete example
-
-Here's a complete `.env` snippet showing custom OAuth alongside other authentication options:
-
-```bash .env
-# --- Authentication ---
-AUTH_SECRET="your-32-byte-hex-secret"
-
-# Built-in Social Auth (optional, can coexist with custom OAuth)
-# GOOGLE_CLIENT_ID=""
-# GOOGLE_CLIENT_SECRET=""
-# GITHUB_CLIENT_ID=""
-# GITHUB_CLIENT_SECRET=""
-# LINKEDIN_CLIENT_ID=""
-# LINKEDIN_CLIENT_SECRET=""
-
-# Custom OAuth Provider (e.g., Authentik)
-OAUTH_PROVIDER_NAME="Company SSO"
-OAUTH_CLIENT_ID="reactive-resume-client-id"
-OAUTH_CLIENT_SECRET="reactive-resume-client-secret"
-OAUTH_DISCOVERY_URL="https://auth.company.com/application/o/reactive-resume/.well-known/openid-configuration"
-# OAUTH_SCOPES="openid profile email" # Defaults to these scopes if not set
-```
+- Set `APP_URL` to the exact public address people use, with `https` in production. Callback URLs, secure cookies, and trusted origins all come from it.
+- Only the `APP_URL` origin (plus `localhost` and `127.0.0.1` on port 3000) is trusted. Other domains pointing at the same installation aren't.
+- Behind a reverse proxy, pass the `Host` and `X-Forwarded-Proto` headers through unchanged.
## Troubleshooting
-
- Your OAuth provider must return an email address for user creation. Ensure:
- - The `email` scope is included in your scopes
- - Your provider is configured to release the email claim
- - The user has an email address set in the identity provider
+
+ The sign-in fails, and the server logs "OAuth Provider provider did not return an email address". Include the `email` scope, make sure your provider releases the email claim, and check that the user has an email address in the provider.
-
-
- The callback URL configured in your OAuth provider must exactly match: ```
- {APP_URL}/api/auth/callback/custom ``` Common issues: - Trailing slash mismatch - HTTP vs HTTPS mismatch - Port
- number differences - Path case sensitivity
-
-
-
- Common cause: `APP_URL` does not match the real HTTPS public origin (for example, app is behind TLS but `APP_URL` is
- `http://...`). Fix: set `APP_URL` to the canonical HTTPS URL and restart the app.
-
-
-
- The custom OAuth option only appears if both `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are set, **and** either:
- - `OAUTH_DISCOVERY_URL` is set, **or**
- - All three manual URLs are set (`OAUTH_AUTHORIZATION_URL`, `OAUTH_TOKEN_URL`, `OAUTH_USER_INFO_URL`)
-
- Double-check your environment variables and restart the container.
-
+
+ The callback registered in your provider must equal `{APP_URL}/api/auth/callback/custom` exactly. Look for a trailing slash, `http` instead of `https`, a different port, or a different hostname.
-
-
- If running behind a reverse proxy: - Ensure `APP_URL` matches your public URL - Verify the proxy passes the correct
- headers (`X-Forwarded-Proto`, `X-Forwarded-Host`) - Check that your OAuth provider allows the redirect URI from your
- domain
-
-
-
- Dynamic OAuth client registration allows the app origin and local loopback callbacks by default. Trusted self-hosted
- deployments that need arbitrary redirect URIs can enable `FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI`, which permits any
- parseable redirect URI including custom schemes, private hosts, and non-loopback `http://` URLs. Do not enable it on
- public or multi-tenant deployments.
-
-
-
- The profile mapping depends on your provider returning standard claims:
- - `email` (required)
- - `name` or `preferred_username` for display name
- - `picture`, `image`, or `avatar_url` for avatar
-
- Check your provider's documentation to ensure these claims are included in the ID token or userinfo response.
-
+
+ Failed callbacks open the app's `/auth/error` page. The most common cause is an `APP_URL` that doesn't match the real public address, for example `http://` behind a TLS proxy. Set `APP_URL` to the canonical `https` address and restart.
+
+
+ Both `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` must be set and non-empty. Restart after changing them.
+
+
+ An account with the same email already exists, and its email address isn't verified in Reactive Resume. The person can verify their email (this needs SMTP) and try again. Accounts created through SSO are always treated as verified.
+
+
+ If you use `OAUTH_DISCOVERY_URL` and upgraded from before v5.2.8, run the issuer update in [Upgrading an install that uses OIDC discovery](#upgrading-an-install-that-uses-oidc-discovery).
+
+
+ This is a different feature: Reactive Resume is then the OAuth server, for tools that connect to it. Dynamic client registration accepts your app's own origin and local loopback callbacks. Trusted private installations that need other redirect URIs can set `FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI=true`, which accepts any parseable URI. Don't enable it on public or shared installations.
-## Security considerations
+## Security checklist
-
-
- Always use HTTPS for both your Reactive Resume instance and OAuth provider in production. OAuth tokens should never
- be transmitted over unencrypted connections.
-
-
- Never commit `OAUTH_CLIENT_SECRET` to version control. Use environment variables or a secrets manager.
-
-
- Configure your OAuth provider to only allow the exact redirect URI. Avoid wildcards in redirect URI configurations.
-
-
- Keep `AUTH_SECRET` and `BETTER_AUTH_API_KEY` private. Rotating `AUTH_SECRET` may invalidate active sessions.
-
-
- Only request the scopes you need. The default (`openid profile email`) is sufficient for Reactive Resume.
-
-
+- Use `https` for both Reactive Resume and your provider.
+- Keep `OAUTH_CLIENT_SECRET`, `AUTH_SECRET`, and `BETTER_AUTH_API_KEY` out of version control. Rotating `AUTH_SECRET` signs everyone out.
+- Register the exact redirect URI; avoid wildcards.
+- Request only the default scopes; Reactive Resume needs nothing more.
+
+## Related pages
+
+- [Environment variables](/self-hosting/environment-variables): every variable the server reads.
+- [Self-hosting with Docker](/self-hosting/docker): where to put these variables in a container setup.
+- [Self-hosting on Vercel](/self-hosting/vercel): adding variables to a Vercel project.
diff --git a/docs/self-hosting/upgrading-to-v6.mdx b/docs/self-hosting/upgrading-to-v6.mdx
new file mode 100644
index 000000000..3e43471c8
--- /dev/null
+++ b/docs/self-hosting/upgrading-to-v6.mdx
@@ -0,0 +1,245 @@
+---
+title: "Upgrading to v6"
+description: "Upgrade a self-hosted Reactive Resume v5 installation to v6: back up, run the startup migrations, convert old styles, and check API and MCP changes."
+---
+
+Reactive Resume v6 is a full redesign. For people using it, almost everything looks different; for you as the operator, the upgrade is a normal image or deployment update with a few things to plan for. This page walks through the upgrade and lists what changes in the database, the routes, the API, and the MCP server. For the user-facing changes, see [What's new in v6](/guides/whats-new-in-v6).
+
+This page is for installations already on v5. Coming from v4? Follow [Migrating from v4](/self-hosting/migration) first.
+
+## What to expect
+
+- **Migrations run on startup**, as in v5. Seven new migrations add columns and tables, move data, and drop two legacy columns. You don't run anything by hand.
+- **v5 can't run against the upgraded database.** The last migrations remove columns that v5 reads, so stop every v5 instance before the first v6 instance starts. There's no mixed v5 and v6 rolling update.
+- **Cover letters leave resumes.** Every letter stored inside a resume becomes a saved letter of its own.
+- **Old style-editor styles need a one-time manual conversion**, with a script you run yourself. Nothing converts them automatically.
+- **No new environment variables.** Everything you set for v5.3 keeps working. `REDIS_URL` is now optional for the assistant.
+- **Vercel projects must switch to the Services framework preset** before they deploy v6.
+
+## Before you upgrade
+
+
+
+ Take a full PostgreSQL backup (for example with `pg_dump`) and back up your uploads: the local storage directory, the S3 bucket, or the Blob store. Check that you can restore the backup. A backup is the simplest way back if anything goes wrong; see [Roll back to v5](#roll-back-to-v5).
+
+
+ If scripts, automations, or AI clients use the API or MCP server, read [API changes](#api-changes) and [MCP changes](#mcp-changes). Some fields and tools were removed.
+
+
+ Migrations usually finish in seconds, but the letter migration scans every resume. On large installations, allow a few minutes and upgrade when traffic is low.
+
+
+
+## Upgrade
+
+
+
+ Change the image tag to the v6 release (`v6`, a specific version such as `v6.0.0`, or `latest`), then recreate the container:
+
+ ```bash
+ docker compose pull
+ docker compose up -d
+ docker compose logs -f reactive-resume
+ ```
+
+ Use your own service name if it isn't `reactive-resume`. Watch the log for `Running database migrations...` followed by `Database migrations completed`. The server starts serving only after that.
+
+ Building from the repository's `compose.yml` instead? See [Self-hosting with Docker](/self-hosting/docker) for the build command.
+
+
+ Scale the v5 Deployment to zero, or set the Deployment's update strategy to `Recreate`, so no v5 pod runs next to a v6 pod. Then change the image tag and apply. The first v6 pod takes a database advisory lock and applies the migrations; other pods wait for it.
+
+ See [Self-hosting on Kubernetes](/self-hosting/kubernetes) for the manifests.
+
+
+ 1. In the Vercel project, open **Settings → Build and Deployment**, set **Framework Preset** to **Services**, and save.
+ 2. Sync your fork with the v6 release and redeploy.
+
+ Migrations run in the build step, before the new deployment receives traffic. Don't promote an older v5 deployment afterwards; it can't run against the migrated database. See [Self-hosting on Vercel](/self-hosting/vercel).
+
+
+
+## Check the upgrade
+
+1. Open `/api/health`. `status` should be `healthy` and `version` should start with `6`.
+2. Sign in. You land on **Documents**, which lists resumes and cover letters together.
+3. If a resume used to contain a cover letter, that letter now appears in **Documents** as a document of its own, named after the resume.
+4. If the server log shows `Database schema verification failed`, read [Schema check](#schema-check).
+
+## Convert styles from the old style editor
+
+Before Custom Styles became CSS, v5 had a visual style editor that stored its own style rules. v6 no longer renders those rules. Until you convert them, resumes and letters that still use them print with their template's default look.
+
+The conversion rewrites each affected document's stylesheet as Custom Styles (CSS) that reproduces the old look. It runs only when you start it, it works in four tables (`resume`, `resume_version`, `cover_letter`, `cover_letter_version`), and it changes only the stylesheet: the old rules stay in the data. Files imported later from old JSON exports are converted in the browser as they're imported, so they don't need the script.
+
+The script is `apps/server/dist/migrate-legacy-styles.mjs`, included in the Docker image. It connects with the server's `DATABASE_URL`.
+
+
+
+ Converts every affected row in memory and prints counts. Nothing is written.
+
+ ```bash
+ docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs
+ ```
+
+
+ Every stylesheet it replaces is appended to the backup file (one JSON object per line) before its row is written. Put the file on persistent storage, such as the `/app/data` volume.
+
+ ```bash
+ docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs --apply --backup /app/data/legacy-styles-backup.ndjson
+ ```
+
+ It's safe to run again, for example after an interruption: converted rows are skipped. You can reuse the same backup file or start a new one.
+
+
+ Puts back the stylesheets recorded in the backup file, except on documents whose stylesheet was edited since.
+
+ ```bash
+ docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs --restore /app/data/legacy-styles-backup.ndjson
+ ```
+
+
+
+Each run ends with a summary per table:
+
+| Count | Meaning |
+| --- | --- |
+| `candidates` | Rows that still use the old style rules. |
+| `migrated` | Rows converted (or, in a dry run, rows that would be). |
+| `changed` | Rows edited while the script ran. They were left alone; run the script again to pick them up. |
+| `failed` | Rows whose data couldn't be read. They were left as they are, and the log names each one. |
+
+On Kubernetes, run the same commands with `kubectl exec deploy/ -- node apps/server/dist/migrate-legacy-styles.mjs ...`. On Vercel there's no container: from a checkout of the same release, put the production variables in the root `.env` (for example with `vercel env pull .env --environment production`), set `DATABASE_URL` to the direct, unpooled connection, run `pnpm install` and `pnpm build --filter=server`, and then run the script with `node`.
+
+## What the migrations change
+
+Every migration runs once, in order, under an advisory lock. Three of them ship a `rollback.sql` next to `migration.sql` in the repository's `migrations/` folder.
+
+| Migration | What it does | Rollback script |
+| --- | --- | --- |
+| `20260928171330_resume_version_kinds` | Adds a `kind`, `name`, and session to resume versions (filled in from the old labels) and a `resume_slug_redirect` table, so a renamed public link keeps working for 30 days. | No (additive) |
+| `20260928175116_documents_trash_and_links` | Adds Trash (`trashed_at`) to resumes and letters, tags and locking to letters, and a link from a resume to the application it was made for. | No (additive) |
+| `20260928193941_applications_closed_and_sent` | Adds the **Closed** stage with a reason, and what was sent with an application. Turns `rejected` applications into closed (not selected) and archived ones into closed. Gives an application its letter when exactly one letter was written for it. | Yes |
+| `20260928201742_letters_structured_and_versions` | Adds structured recipient fields, links to a resume's details and design, and version history (`cover_letter_version`) for letters. Existing letters keep their free-form layout and their own copied details. | No (additive) |
+| `20260928211634_assistant_documents_and_outcomes` | Lets assistant conversations belong to a letter and count proposed and accepted edits. | No (additive) |
+| `20260929063245_contract_redesign_legacy_fields` | Closes anything still archived or rejected, then drops `application.archived` and `resume_version.label`. After this, v5 no longer works against the database. | Yes |
+| `20260929071322_letters_leave_resumes` | Saves every cover letter stored inside a resume as a letter of its own, then removes the cover-letter sections from resumes and their page layouts. | Yes |
+
+### Cover letters leaving resumes
+
+In v5, a resume could hold cover letters as a section. The last migration moves each of them, hidden ones included, into a saved letter:
+
+- The letter is named after the resume and the section, for example "Product Designer — Cover letter".
+- It's linked to the resume, and takes its sender details and design from it, so it looks as it did inside the resume.
+- If a resume held exactly one letter and exactly one application used that resume without a letter, the letter becomes that application's letter.
+- Resume version history keeps its older versions as they were. Restoring one saves its letters again as letters (once) instead of putting them back in the resume.
+
+From then on, any resume that reaches the server with a cover-letter section, through an old browser tab, an imported file, a restored version, or an API client, has the section turned into a saved letter in the same save. A letter with the same text from the same section isn't saved twice.
+
+### Structured dates
+
+Dates on entries (experience and its roles, education, projects, volunteering, awards, certifications, and publications) are now stored as structured `dates`: a start and end year or year-month, and whether it's ongoing. No migration changes stored data for this. Instead, every read fills in `dates` from the old text, and every save writes the text (`period` or `date`) from `dates`. Text that couldn't be read exactly, such as "Summer 2016", keeps printing as typed until someone edits the date. The date format is inferred from how the dates were typed. See [Entering dates](/guides/entering-dates).
+
+### Trash
+
+Deleting a resume or letter now moves it to Trash. Trashed documents stop being shared publicly, and their public address stays reserved. They're deleted for good, with their files, 30 days later. There's no scheduled job for this: it happens the next time the owner opens their documents, so storage is freed then. See [Using the Trash](/guides/using-the-trash).
+
+## Removed and redirected routes
+
+These v5 addresses redirect to their v6 place, so bookmarks and old links keep working through the 6.0 releases. Update any links you control.
+
+| v5 address | Now goes to |
+| --- | --- |
+| `/dashboard/resumes` | `/dashboard?type=resume` (tags, sort, and list view carry over) |
+| `/dashboard/cover-letters` | `/dashboard?type=letter` |
+| `/dashboard/settings/profile`, `/dashboard/settings/authentication` | `/dashboard/settings/account` |
+| `/dashboard/settings/integrations`, `/dashboard/settings/api-keys`, `/dashboard/settings/job-search` | `/dashboard/settings/ai` |
+| `/agent` | `/dashboard` |
+| `/agent/new?resumeId=` | `/builder/?assistant=new` (or `/dashboard` without a resume) |
+| `/agent/` | The conversation's document in the editor, with the assistant open. `/dashboard` if the document is gone. |
+
+Cover letters now open in their own editor at `/builder/letter/`. API, MCP, auth, and public resume addresses (`//`) are unchanged.
+
+## API changes
+
+Base paths, authentication, and API keys are unchanged. These changes can affect existing clients. The [API reference](/guides/using-the-api) has the full list.
+
+**Resume data**
+- Dated items carry `dates` (`start`, `end`, `present`, and `raw` for text that couldn't be read). Write `dates`. The text in `period` or `date` is rewritten from `dates` on every save, so a change to the text alone is overwritten. Items sent without `dates` get them from their text.
+- `metadata.page.dateFormat` (`short`, `long`, `numeric`, or `iso`) sets how dates print.
+- Resumes no longer hold `cover-letter` custom sections. Any you send are saved as letters and removed from the resume.
+
+**Resumes and documents**
+- `DELETE /resumes/{id}` and `DELETE /cover-letters/{id}` now move the document to Trash. New `documents` endpoints list, rename, tag, lock, trash, restore, and permanently delete (`/documents/purge`) resumes and letters together.
+- Creating or duplicating a resume no longer needs a `slug`; one is generated from the name. `GET /resumes/{resumeId}/slug-check` validates a slug (lowercase letters and numbers joined by single dashes) and suggests one.
+- Version entries have `kind` and `name` instead of `label`. New endpoints get, create (named), rename, and delete resume versions, and letters have the same set under `/cover-letters/{id}/versions`.
+- Resume PDF downloads accept only the resume. The `cover-letter` target is gone, and old signed links that ask for it return `404`. Download letters through `/cover-letters`.
+
+**Cover letters**
+- `POST /cover-letters/from-resume` (`copyEmbedded`) is removed, since resumes no longer contain letters.
+- Letters gain `layout`, `recipientName`, `recipientCompany`, `letterDate`, `senderLinked`, and `designLinked`, and a `draft` endpoint that drafts the letter body with the user's AI provider.
+
+**Applications**
+- The `rejected` stage and the `archived` flag are gone. Use `status: "closed"` with `closedReason` (`not-selected`, `withdrew`, `accepted-other`, or `no-response`). CSV import still reads `rejected` and `archived` from older exports as closed.
+- Listing applications returns closed ones too; `includeArchived` is removed. Filter by `status` instead.
+- New interview endpoints (`/applications/{id}/interviews`), and new fields for the linked letter, what was sent (`sentResumeVersionId`, `sentCoverLetterVersionId`, `sentCheckScore`), and `requirements`.
+- The account data export now includes applications.
+
+**Assistant**
+- `POST /agent/threads/for-resume`, `POST /agent/threads/{id}/archive`, and `POST /agent/actions/{id}/revert` are removed. Start a conversation with `POST /agent/threads`, passing either `resumeId` or `coverLetterId`.
+
+## MCP changes
+
+- Removed: `copy_embedded_cover_letter`.
+- `download_resume_pdf` no longer takes a `target`; it always returns the resume.
+- `delete_resume` and `delete_cover_letter` now move the document to Trash.
+- Added: `add_application_interview` and `update_application_interview`.
+- `list_applications` no longer takes `includeArchived`. `update_application` no longer takes `archived`, and accepts `closedReason` with the `closed` stage.
+- Resume patch paths document the new `dates` field. The resume schema resource, like `/schema.json`, is now generated from the live schema.
+
+See [Using the MCP server](/guides/using-the-mcp-server) and [Managing applications with MCP](/guides/managing-applications-with-mcp).
+
+## Environment variables
+
+v6 adds no environment variables and removes none. Two behaviors changed:
+
+- `REDIS_URL` is optional for the assistant. Without Redis, replies can't resume after a page reload, and **Stop** reaches only a reply running on the same server. `ENCRYPTION_SECRET` is still required for AI providers and the assistant. Vercel still requires Redis.
+- The web build now also produces `apps/web/dist-prerender` (localized marketing pages). The Docker image includes it. If you build and deploy the server yourself, copy it next to `apps/web/dist`; without it, those pages render in the browser instead.
+
+See [Environment variables](/self-hosting/environment-variables) for the full list.
+
+## Schema check
+
+After the migrations, the server compares the live database with the tables and columns it expects. If something is missing, for example after a partial restore, it logs `Database schema verification failed` and keeps running, which can lead to errors later.
+
+Set `STRICT_SCHEMA_CHECK=true` to make startup fail instead. That's a good choice for production: a failed start is easier to notice than errors in the middle of the day. To fix drift, restore a consistent backup or recreate the missing objects, then restart.
+
+## Roll back to v5
+
+Restoring the backup you took before the upgrade is the safest way back, and the only one that undoes everything. Stop v6 first, restore the database (and files, if they changed), then start v5.
+
+If you must keep data written since the upgrade, you can undo the migrations that v5 can't live with, by hand, before starting v5:
+
+1. Stop every v6 instance.
+2. Run the rollback scripts with `psql`, newest first:
+
+ ```bash
+ psql "$DATABASE_URL" -f migrations/20260929071322_letters_leave_resumes/rollback.sql
+ psql "$DATABASE_URL" -f migrations/20260929063245_contract_redesign_legacy_fields/rollback.sql
+ psql "$DATABASE_URL" -f migrations/20260928193941_applications_closed_and_sent/rollback.sql
+ ```
+
+ The first puts letters back into the resumes they came from (one section per resume per run; run it again for a resume that had several). The second restores `application.archived` and `resume_version.label`. The third turns closed applications back into `rejected` or archived.
+3. Start v5.
+
+Columns and tables added by v6 stay; v5 ignores them. The letters saved from resumes stay too, so in v5 each one shows both inside its resume and in the cover letter library. Documents in Trash show up again in v5, because v5 doesn't know about Trash. If you converted old styles, the converted stylesheets stay, and `--restore` with your backup file puts the originals back.
+
+
+ The rollback scripts don't change the migration ledger, so the v6 migrations stay recorded as applied. Upgrading the same database to v6 again won't re-run them. Take a fresh backup and ask in [GitHub Discussions](https://github.com/reactive-resume/reactive-resume/discussions) before you try.
+
+
+## Related pages
+
+- [What's new in v6](/guides/whats-new-in-v6): the user-facing changes.
+- [Self-hosting with Docker](/self-hosting/docker): updating and backing up a container installation.
+- [Custom styles](/applying-custom-styles): how converted styles look and how to edit them.
diff --git a/docs/self-hosting/vercel.mdx b/docs/self-hosting/vercel.mdx
index ceb4a155c..e10370539 100644
--- a/docs/self-hosting/vercel.mdx
+++ b/docs/self-hosting/vercel.mdx
@@ -1,11 +1,26 @@
---
title: "Self-hosting on Vercel"
-description: "Deploy Reactive Resume on Vercel Hobby with Neon PostgreSQL, private Vercel Blob, and Upstash Redis."
+description: "Deploy Reactive Resume to Vercel as two Vercel Services, with Neon PostgreSQL, private Vercel Blob, and Upstash Redis provisioned by the wizard."
---
-This guide deploys Reactive Resume to a Vercel project with the Deploy with Vercel wizard. The wizard provisions a database, file storage, and Redis for you. You supply two secrets.
+This guide deploys Reactive Resume to your own Vercel project with the **Deploy with Vercel** wizard. The wizard connects a database, file storage, and Redis for you; you supply two secrets. Use it when you want a managed deployment without running servers. If you'd rather run containers, see [Self-hosting with Docker](/self-hosting/docker). Both run the same application and data format.
-The project deploys as two [Vercel Services](https://vercel.com/docs/services): `frontend` serves the static web app from Vercel's CDN, and `backend` runs the shared Hono server in one Node.js 24 Function. Docker is also supported and uses the same API, authentication, templates, and data format. See [Self-hosting with Docker](/self-hosting/docker) for that option.
+## How the deployment is laid out
+
+The repository's `vercel.json` defines two [Vercel Services](https://vercel.com/docs/services) in one project:
+
+| Service | Source | What it does |
+| --- | --- | --- |
+| `frontend` | `apps/web` | Serves the built web app (`apps/web/dist`) from Vercel's CDN. |
+| `backend` | `apps/server` | Runs the Hono server in one Node.js Function (300-second budget). Its entrypoint, `apps/server/vercel.mjs`, re-exports the adapter the build emits. |
+
+Top-level rewrites decide which service answers a request. They're checked in order, and the first match wins:
+
+1. `/api/*`, `/uploads/*`, `/mcp`, `/.well-known/*`, and `/index.html`, `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/schema.json` go to `backend`.
+2. Any other path whose last segment has a file extension (`/assets/app.js`, `/favicon.ico`) goes to `frontend`.
+3. Everything else, including every page address such as `/dashboard` or `/alex/resume`, goes to `backend`, which returns the HTML shell with the right metadata.
+
+Keep the committed `vercel.json` as it is. The rewrites replace an SPA fallback, so don't add one.
## Before you start
@@ -18,37 +33,37 @@ You need:
openssl rand -hex 32
```
- Save both values in a password manager. You must keep the same values for the life of the installation. Losing `ENCRYPTION_SECRET` makes saved AI-provider API keys unreadable.
+ Keep both in a password manager. They must stay the same for the life of the installation: `AUTH_SECRET` signs sessions and tokens, and `ENCRYPTION_SECRET` encrypts the AI provider keys people save. Losing `ENCRYPTION_SECRET` makes those keys unreadable.
-The wizard connects three services through Vercel Marketplace:
+The wizard connects three Vercel Marketplace services:
| Service | Used for |
| --- | --- |
| Neon PostgreSQL | Application data |
-| Vercel Blob (**private**) | Uploaded pictures, files, and agent attachments |
-| Upstash Redis | Agent streaming and cancellation, shared rate limits, live resume updates |
+| Vercel Blob (**private**) | Pictures, uploaded files, and assistant attachments |
+| Upstash Redis | Shared rate limits, live document updates, and assistant replies that survive a reload |
-Quotas and permitted use depend on your Vercel, Neon, and Upstash plans. Review [Vercel limits](https://vercel.com/docs/functions/limitations), [Neon](https://vercel.com/marketplace/neon), and [Upstash](https://vercel.com/marketplace/upstash/upstash-kv) before you choose a plan. Turn off automatic paid upgrades if you want to stay inside a free allowance.
+Quotas and permitted use depend on your Vercel, Neon, and Upstash plans. Check [Vercel's Function limits](https://vercel.com/docs/functions/limitations), [Neon](https://vercel.com/marketplace/neon), and [Upstash](https://vercel.com/marketplace/upstash/upstash-kv) before you pick a plan, and turn off automatic paid upgrades if you want to stay inside a free allowance.
- Each AI agent run stops active work after four minutes. This keeps the run, plus saving and cleanup, inside Vercel Hobby's five-minute Function limit. The limit applies to each question, not to the whole conversation. Docker uses the same limit.
+ The assistant stops working on a reply after four minutes. This keeps the reply, plus saving and cleanup, inside Vercel Hobby's five-minute Function limit. The limit applies to each message, not to the whole conversation, and Docker uses the same limit.
## Deploy
- Click the button:
+ Select the button:
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D)
- On Hobby, select your **personal GitHub account**. Private repositories owned by a GitHub organization require Vercel Pro.
+ On Hobby, select your **personal GitHub account**. Private repositories owned by a GitHub organization need Vercel Pro.
- Approve Neon, Upstash, and Blob. Set Blob access to **private**. Pick nearby regions for all three, ideally close to the Function region (`iad1` by default).
+ Approve Neon, Upstash, and Blob. Set Blob access to **private**. Pick regions close to each other and to the Function region (`iad1` by default).
@@ -56,60 +71,60 @@ Quotas and permitted use depend on your Vercel, Neon, and Upstash plans. Review
- Set **Framework Preset** to **Services**. Keep the project root at the repository root and keep the committed `vercel.json`. Do not select the `apps/web` subdirectory and do not add an SPA fallback rewrite.
+ Set **Framework Preset** to **Services**. Keep the project root at the repository root and keep the committed `vercel.json`. Don't select the `apps/web` subdirectory.
- The build compiles both apps and applies database migrations before the deployment goes live. You do not run migrations yourself.
+ The `backend` build compiles both apps, then runs `apps/server/dist/prepare-deployment.mjs`, which checks the configuration and applies database migrations before the deployment goes live. You don't run migrations yourself.
- Do not paste Docker's `.env.example` into Vercel. Its local URLs and S3 settings select the wrong services.
+ Don't paste Docker's `.env.example` into Vercel. Its local URLs and S3 settings select the wrong services, and the build refuses any storage other than Blob.
## Check the deployment
-1. Open `https://.vercel.app/api/health`. `database`, `storage`, and `redis` should all report `healthy`. A sleeping Neon database can fail the first check; retry once.
+1. Open `https://.vercel.app/api/health`. `database`, `storage`, and `redis` should each report `"status": "healthy"`, and `version` shows the release you deployed. A sleeping Neon database can fail the first check; retry once.
2. Open the production domain and create an account.
-3. Optional: add an AI provider under **Settings** to enable AI features.
+3. Optional: to use AI features, each person adds their own provider under **Settings → AI**.
4. Optional: configure SMTP for verification and password-reset emails. Without SMTP, emails are written to the Function logs.
-5. Optional: add social or custom OAuth sign-in with the callback URLs in the [SSO guide](/self-hosting/sso).
+5. Optional: add Google, GitHub, LinkedIn, or your own identity provider. See [Single sign-on (SSO)](/self-hosting/sso) for the callback URLs.
## Use a custom domain
1. Add the domain to the Vercel project.
2. Set `APP_URL` to the full origin, for example `https://resume.example.com`.
-3. Update the callback URLs of every OAuth provider you configured.
+3. Update the callback URL of every OAuth provider you configured.
4. Redeploy.
-Changing the domain does not move stored files. Files stay under the same `DEPLOYMENT_NAMESPACE`.
+Changing the domain doesn't move stored files. They stay under the same `DEPLOYMENT_NAMESPACE`.
## Update the deployment
-Redeploy the same project. Keep its connected services and secrets unchanged.
+Redeploy the same project, for example by syncing your fork. Keep its connected services and secrets unchanged.
+
+- Migrations run in the build step, never in the runtime Function. A database advisory lock serializes concurrent deployments.
+- Rolling back to an older deployment doesn't roll back the database schema. Restore a database backup, or follow the rollback notes in the release's upgrade guide.
- Installations created before Reactive Resume used Vercel Services must switch first. Open **Settings → Build and Deployment**, set **Framework Preset** to **Services**, save, and then redeploy. Vercel builds the `services` configuration only when the project uses this preset.
+ Projects created before Reactive Resume used Vercel Services (v5.3 and earlier) must switch before they deploy v6. Open **Settings → Build and Deployment**, set **Framework Preset** to **Services**, save, and then redeploy. Vercel builds the `services` configuration only with this preset. Read [Upgrading to v6](/self-hosting/upgrading-to-v6) before you redeploy.
-- Migrations run in the build step, never in runtime Functions. A database advisory lock serializes concurrent deployments.
-- Rolling back to an older deployment does not roll back the database schema. Keep migrations backward-compatible, or restore a database backup.
-
## Preview deployments
-Preview builds refuse to run migrations by default, so untrusted preview code cannot change your production database.
+Preview builds refuse to run migrations by default, so untrusted preview code can't change your production database. The build fails with "Preview deployment needs an isolated database" until you opt in.
To enable previews:
1. Connect separate Neon, Upstash, and Blob resources to the **Preview** environment.
2. Set `ALLOW_PREVIEW_MIGRATIONS=true` for **Preview** only.
-A storage namespace does not isolate SQL rows. Never connect the production database to the Preview environment.
+A storage namespace doesn't isolate database rows. Never connect the production database to the Preview environment.
## Back up your data
Back up the Neon database and the Blob store. Store `AUTH_SECRET` and `ENCRYPTION_SECRET` separately from those backups.
-Moving between Docker and Vercel does not copy the database or files. Migrate them yourself.
+Moving between Docker and Vercel doesn't copy the database or files. Migrate both yourself.
## Build locally
@@ -120,45 +135,51 @@ vercel pull --environment production
APP_URL=https://your-project.vercel.app vercel build --prod
```
-Replace sensitive pulled values with local-only ones first. The CLI has no deployment hostname before publishing, so `APP_URL` is required here. Cloud builds set it automatically.
+Replace sensitive pulled values with local-only ones first. The CLI has no deployment hostname before publishing, so `APP_URL` is required here; cloud builds set it automatically.
-## Environment variables
+## Vercel-specific variables
-Explicit variables take precedence over the Marketplace aliases listed here.
+On Vercel, the server reads the variables that Marketplace integrations inject. A variable you set explicitly always wins over these fallbacks. For everything else (SMTP, OAuth providers, feature flags), see [Environment variables](/self-hosting/environment-variables).
-| Variable | Required | Behavior |
+| Variable | Required | Behavior on Vercel |
| --- | --- | --- |
| `AUTH_SECRET` | Yes | Signs sessions and tokens. Keep it constant. |
-| `ENCRYPTION_SECRET` | Yes | At least 32 characters. Encrypts saved AI-provider API keys. Keep it constant. |
-| `APP_URL` | No | Public origin. Defaults to the production domain in Production and to the deployment domain in Preview. Set it for a custom domain. |
+| `ENCRYPTION_SECRET` | Yes | At least 32 characters. Encrypts saved AI provider keys. The build fails without it. |
+| `APP_URL` | No | Public origin. Defaults to the production domain (`VERCEL_PROJECT_PRODUCTION_URL`) in Production and to the deployment domain in Preview. Set it for a custom domain. |
| `DATABASE_URL` | Injected | Pooled runtime connection. Falls back to `POSTGRES_URL`. |
| `DATABASE_MIGRATION_URL` | No | Direct connection for migrations. Falls back to `DATABASE_URL_UNPOOLED`, then `POSTGRES_URL_NON_POOLING`, then `DATABASE_URL`. |
| `DATABASE_POOL_MAX` | No | Maximum database connections per Function instance. Default `10`. |
-| `REDIS_URL` | Injected | Redis TCP/TLS URL. Falls back to Upstash's `KV_URL`. REST credentials alone do not work. |
-| `STORAGE_BACKEND` | No | Must resolve to `blob` on Vercel, which is the default. The build fails with any other value. |
+| `REDIS_URL` | Injected | Redis TCP/TLS URL. Falls back to Upstash's `KV_URL`. REST credentials alone don't work. The build fails without Redis. |
+| `STORAGE_BACKEND` | No | Resolves to `blob` on Vercel. The build fails with any other value, so remove any `S3_*` variables. |
| `BLOB_READ_WRITE_TOKEN` | Injected | Provided by the connected Blob store. `BLOB_STORE_ID` with Vercel OIDC also works. |
-| `DEPLOYMENT_NAMESPACE` | No | Prefix for Blob objects and Redis keys. Defaults to `production`, or to a per-branch value in Preview. Keep it constant after you store files. Set different values if two installations share one Blob store or Redis database. |
-| `ALLOW_PREVIEW_MIGRATIONS` | No | Set to `true` in Preview only after you connect isolated preview resources. |
-
-For SMTP, OAuth providers, and feature flags, use the same variables as Docker. See [Self-hosting with Docker](/self-hosting/docker).
+| `DEPLOYMENT_NAMESPACE` | No | Prefix for Blob objects and Redis keys. Defaults to `production`, or to the branch URL in Preview. Keep it constant once files exist. Set different values if two installations share one Blob store or Redis database. |
+| `ALLOW_PREVIEW_MIGRATIONS` | No | Set to `true` in Preview only, after you connect isolated preview resources. |
## Upload limits
-Vercel limits Function request bodies to 4.5 MB. The web app sends larger requests through private Blob staging, so these application limits still apply:
+Vercel limits Function request bodies to 4.5 MB. The web app sends larger requests through private Blob staging, so the application's own limits still apply:
-- General uploads: 10 MB per file.
-- Agent attachments: 25 MiB per file and 100 MiB per thread.
+- Uploads (pictures and files): 10 MB per file.
+- Assistant attachments: 25 MiB per file and 100 MiB per conversation.
-Blob objects are never public. The application serves public pictures and authorizes private files itself. API clients that send large RPC requests must follow the [large RPC requests](/guides/large-rpc-requests) protocol. REST (`/api/openapi`) and MCP request bodies stay subject to the 4.5 MB limit.
+Blob objects are never public. The server serves public pictures and checks access to private files itself. API clients that send large RPC requests must follow the [large RPC requests](/guides/large-rpc-requests) protocol. REST (`/api/openapi`) and MCP request bodies stay subject to the 4.5 MB limit.
## Troubleshooting
| Symptom | Fix |
| --- | --- |
-| Deployment does not build as services | Set **Framework Preset** to **Services** under **Settings → Build and Deployment**, then redeploy. |
-| First build fails on configuration | Check that all three services are connected to Production and both secrets are set. Remove any `S3_*` variables and any `STORAGE_BACKEND` value other than `blob`. |
-| Preview build refuses to migrate | Connect isolated preview resources, then set `ALLOW_PREVIEW_MIGRATIONS=true` for Preview. |
-| Large upload returns `413` | Use the web app or the [large RPC requests](/guides/large-rpc-requests) protocol. Vercel Pro does not raise the request body limit. |
-| Agent reconnect or stop fails | Check the Upstash TLS URL, the remaining Upstash quota, and that every environment uses the expected `DEPLOYMENT_NAMESPACE`. |
-| Sign-in redirects to another hostname | Set `APP_URL` to the domain you use and redeploy. Do not add wildcard trusted origins. |
+| The deployment doesn't build as services | Set **Framework Preset** to **Services** under **Settings → Build and Deployment**, then redeploy. |
+| The build fails with "Vercel requires private Blob storage" | Connect a private Blob store and remove any `S3_*` variables and any `STORAGE_BACKEND` value other than `blob`. |
+| The build fails with "Vercel requires Redis and ENCRYPTION_SECRET" | Connect Upstash Redis to the environment and set `ENCRYPTION_SECRET`. |
+| A preview build refuses to migrate | Connect isolated preview resources, then set `ALLOW_PREVIEW_MIGRATIONS=true` for Preview. |
+| Pages return 404 or the wrong content | Check that the project root is the repository root and `vercel.json` is unchanged. Don't add an SPA fallback rewrite. |
+| A large upload returns `413` | Use the web app or the [large RPC requests](/guides/large-rpc-requests) protocol. Vercel Pro doesn't raise the request body limit. |
+| Assistant replies don't resume after a reload, or **Stop** doesn't work | Check the Upstash TLS URL, the remaining Upstash quota, and that every environment uses the expected `DEPLOYMENT_NAMESPACE`. |
+| Sign-in redirects to another hostname | Set `APP_URL` to the domain you use and redeploy. |
| Files are missing after a configuration change | Restore the original `DEPLOYMENT_NAMESPACE` and Blob connection. |
+
+## Related pages
+
+- [Upgrading to v6](/self-hosting/upgrading-to-v6): what changes when an existing installation moves to v6.
+- [Single sign-on (SSO)](/self-hosting/sso): sign in through your own identity provider.
+- [Environment variables](/self-hosting/environment-variables): every variable the server reads.
diff --git a/docs/spec.json b/docs/spec.json
index b5d2b7589..f599c22d3 100644
--- a/docs/spec.json
+++ b/docs/spec.json
@@ -1,7 +1,7 @@
{
"info": {
"title": "Reactive Resume",
- "version": "5.3.2",
+ "version": "6.0.0",
"description": "Reactive Resume API",
"license": {
"name": "MIT",
diff --git a/docs/use-cases/ai-resume-builder.mdx b/docs/use-cases/ai-resume-builder.mdx
index 8d5972064..35166507a 100644
--- a/docs/use-cases/ai-resume-builder.mdx
+++ b/docs/use-cases/ai-resume-builder.mdx
@@ -1,40 +1,57 @@
---
title: "AI resume builder"
-description: "Use Reactive Resume as an AI resume builder with bring-your-own OpenAI, Anthropic, Gemini, OpenRouter, or Ollama providers for edits and drafts."
+description: "Use Reactive Resume as an AI resume builder: connect your own OpenAI, Anthropic, Gemini, Ollama or other provider to draft, review and tailor resumes."
---
-Reactive Resume works as an AI-assisted resume builder, but the AI is optional and bring-your-own-provider: you configure the provider, model, endpoint, and API key you want it to use.
+Reactive Resume is an AI resume builder that uses your own AI provider. You connect a provider and key in **Settings → AI**, and the assistant, the writing review, cover-letter drafts and file imports all run through it. Everything else in Reactive Resume works without AI.
-## What AI does in Reactive Resume
+## What AI can do in Reactive Resume
-AI can make assisted changes in the builder, review your writing in the ATS checker, produce agent drafts, and support import workflows. The builder and the ATS checker both work without it.
+Once a provider is connected, AI shows up in these places:
-Use these guides for the full setup:
+- **The assistant.** Open it from the editor bar or with ⌘ J (Ctrl J on Windows and Linux). It reads the resume or cover letter you have open and answers in a side panel. When it wants to change something, it proposes an edit that you accept or reject one at a time. You can attach files, and it can ask you a clarifying question before it writes.
+- **Writing review in Check.** The **Writing** tab in Check mode sends your resume's text to your provider and comes back with suggested rewrites for weak bullets.
+- **Tailoring for a job.** From a saved job application, **Tailor a resume** makes a copy of your resume linked to that job. The assistant and Check then work against its posting, so you can ask for changes aimed at that role.
+- **Cover-letter drafts.** In a new cover letter, **Draft from the posting** writes a first draft from the linked job application and your resume. Without an application, **Draft from your resume** drafts from your resume alone. You then edit the draft.
+- **Importing files.** With a provider connected, PDF resumes are read by the model for a more faithful import, and Word files can be imported at all. Without a provider, PDFs are read in your browser instead.
-- [Using artificial intelligence](/guides/using-ai)
-- [Using AI in the builder](/guides/using-ai-in-the-builder)
-- [Using the AI Agent workspace](/guides/using-ai-agent)
-- [AI Agent tools](/guides/ai-agent-tools)
+The resume checks in Check mode (readability score, issues, job match) and the public [ATS checker](/guides/using-the-ats-checker) run without AI.
## Bring your own provider
-You configure an AI provider from the Integrations settings: the provider type, the model, a base URL when one is needed, and the API key.
+Reactive Resume does not sell AI credits or run a model for you. You choose the provider, the model and the key. **Settings → AI** lists 16 options: OpenAI, Anthropic, Google Gemini, Vercel AI Gateway, OpenRouter, Mistral, Cohere, xAI, Groq, DeepSeek, Together AI, Fireworks AI, Cerebras, Perplexity, Ollama, and any OpenAI-compatible endpoint (such as a model you run yourself).
-This is not a hosted AI-writing product. You decide whether to enable AI, which provider to connect, and when to send resume content to it.
+That means:
-## Where AI fits in the workflow
+- Your provider bills you directly for what you use, at its own prices.
+- Your resume text goes only to the provider you picked, with your key, and only when you use an AI feature.
+- You can switch providers or remove the key at any time.
-AI helps most once you already have resume content or structured source material. Review its suggestions, apply changes in the builder, or use agent drafts as separate workspaces before deciding what belongs in the final resume.
+## When to use AI
+
+AI helps most once you have real content to work from: an existing resume, a job posting, or rough notes about your experience. Ask the assistant to tighten a summary, rewrite bullets around results, or check a draft against a posting, then review each proposed edit before you accept it.
## When not to use AI
-Do not use AI features if you do not want your resume content sent to the provider you configure. Do not treat AI output as final application material without reviewing it, and use the regular builder when you need exact control over the wording.
-
-## Next action
-
-Configure and test a provider with [Using artificial intelligence](/guides/using-ai). Then use [Using AI in the builder](/guides/using-ai-in-the-builder) for in-place help, or [Using the AI Agent workspace](/guides/using-ai-agent) for draft-based work.
+Skip the AI features if you do not want your resume content sent to a third-party provider, or if your instance has not configured AI (self-hosted servers need an encryption secret and Redis for saved providers and the assistant). Never send AI output to an employer without reading it: you are responsible for every claim on your resume.
- Review AI-generated resume content before using it in applications. You are responsible for the accuracy of the final
- resume.
+ Review every AI suggestion before you accept it. Models can invent details, dates or numbers that are not true.
+
+## Next steps
+
+
+
+ Add your provider and key in Settings, then test the connection.
+
+
+ Ask for changes and accept or reject each proposed edit.
+
+
+ Get a readability score, issues and an AI writing review.
+
+
+ Start a tailored copy or a cover letter from a saved application.
+
+
diff --git a/docs/use-cases/api-mcp-resume-automation.mdx b/docs/use-cases/api-mcp-resume-automation.mdx
index 2c9b5f6df..c937a3808 100644
--- a/docs/use-cases/api-mcp-resume-automation.mdx
+++ b/docs/use-cases/api-mcp-resume-automation.mdx
@@ -1,53 +1,59 @@
---
title: "API and MCP resume automation"
-description: "Automate resume workflows in Reactive Resume with API keys, the Patch API, the MCP server, AI agent tools, and the JSON resume schema."
+description: "Automate resumes, cover letters and job applications in Reactive Resume with API keys, the OpenAPI endpoints, the Patch API and the MCP server."
---
-Reactive Resume supports automation through authenticated API access and an MCP server, so compatible tools can work with your resumes and job applications. Agents can list, read, create, import, duplicate, and patch resumes. They can also track applications, move opportunities through stages, add notes and follow-ups, attach sent documents, and run Application Copilot actions.
+Reactive Resume has an authenticated API and an MCP server, so scripts, integrations and AI clients such as Claude or Cursor can work with your documents and job applications. Both use the same account data you see in the app.
-## Automation options
+## What you can automate
-Use the API for direct programmatic access from scripts, services, or integrations. Use MCP when you want an AI tool or agent that speaks the Model Context Protocol to work with your resumes through exposed tools.
+Through the API and the MCP server, a client acting as you can:
-Key docs:
+- **Resumes:** list, read, create, import, duplicate, patch, lock, delete, download as PDF, and read sharing statistics.
+- **Cover letters:** list, read, create, update, duplicate, export, import and delete.
+- **Job applications:** create and update applications, move them through stages, add notes and interviews, edit the activity timeline, attach the documents you sent, bulk-update or delete, and import rows from a spreadsheet.
+- **Job-focused actions:** fill in an application from a job posting, score a resume against it, create a tailored resume copy, and draft a message such as a follow-up.
-- [Using the API](/guides/using-the-api)
-- [Using the patch API](/guides/using-the-patch-api)
-- [Using the MCP server](/guides/using-the-mcp-server)
-- [AI Agent tools](/guides/ai-agent-tools)
-- [JSON Resume schema](/guides/json-resume-schema)
+The job-focused actions use your own AI provider, connected in **Settings → AI**.
-## Common automation workflows
+## API or MCP?
-You can build workflows that:
+Use the **API** for scripts, services and integrations you write yourself. It is described by an OpenAPI spec, and large requests are supported.
-- Create a resume from structured data.
-- Import a full resume JSON document.
-- Read resume data for review or transformation.
-- Patch targeted fields without replacing the whole resume.
-- Connect an MCP-compatible client to operate on resumes with authenticated tools.
-- Track job applications end to end from an MCP client.
-- Import existing application rows from a spreadsheet parser.
-- Move applications through stages and log timeline notes.
-- Attach the resume or cover-letter PDF sent for an application.
-- Score a linked resume against a job description and create a tailored resume copy.
-- Draft cover letters and recruiter follow-ups from saved application context.
+Use the **MCP server** when you want an AI client that speaks the Model Context Protocol to read and edit your resumes through ready-made tools. The address to paste into your client is shown under **MCP server** in **Settings → AI**.
-Start with the Patch API for targeted resume updates. It is the safest option because it is built around explicit changes to existing resume data.
+For targeted resume edits, prefer the Patch API (JSON Patch operations) over replacing the whole resume. It changes only the fields you name, so it is less likely to overwrite something by accident.
-## Authentication and scope
+## Authentication
-API requests use API keys. MCP can use OAuth2 in clients that support it, with API keys as a fallback. If you self-host, use your own instance URL for the API and MCP endpoints.
+Create an API key in **Settings → AI → API keys**. Keys act as you and can read and edit your documents. You choose an expiry of **30 days**, **90 days** or **Never**, the key is shown once, and you can revoke it at any time. MCP clients that support OAuth can sign in without a key.
+
+If you self-host, replace `https://rxresu.me` with your own instance's address in every API and MCP example.
- Automation changes affect the resumes available to the authenticated account or instance. Test workflows on a copy of a
- resume before using them for important application materials.
+ Automated changes are real changes to your account. Try a new workflow on a duplicate of a resume first, and use
+ History in the editor to go back if something goes wrong.
## When not to use automation
-Do not reach for API or MCP automation when you only need to edit one resume by hand. The builder is faster for one-off changes, template selection, and visual review. Avoid automation for important resume updates unless you can test the exact changes on a copy first.
+For editing one resume by hand, choosing a template or checking the layout, the editor is faster. Automation pays off when you repeat the same change across many documents, keep applications in sync with another tool, or want an AI client to do the typing for you.
-## Next action
+## Next steps
-For scripts and integrations, create an API key with [Using the API](/guides/using-the-api), then use [Using the patch API](/guides/using-the-patch-api) for targeted edits. For agent workflows, start with [Using the MCP server](/guides/using-the-mcp-server).
+
+
+ Create a key and make your first request.
+
+
+ Change specific resume fields with JSON Patch.
+
+
+ Connect Claude, Cursor or another MCP client.
+
+
+ Track job applications from an AI client.
+
+
+
+For the shape of resume data, see the [JSON Resume schema](/guides/json-resume-schema). For big payloads, see [Large RPC requests](/guides/large-rpc-requests).
diff --git a/docs/use-cases/export-and-share-resumes.mdx b/docs/use-cases/export-and-share-resumes.mdx
index a10c9bd10..aeca69dae 100644
--- a/docs/use-cases/export-and-share-resumes.mdx
+++ b/docs/use-cases/export-and-share-resumes.mdx
@@ -1,51 +1,53 @@
---
title: "Export and share resumes"
-description: "Export Reactive Resume resumes as PDF, DOCX, Markdown, or JSON files and share password-protected public URLs with recruiters and hiring managers."
+description: "Download Reactive Resume resumes as PDF, Word, Markdown or JSON, or share a public link with an optional password and view statistics."
---
-Reactive Resume exports resumes as PDFs, and it can also give you a public resume URL to send to a person: a recruiter, a hiring manager, a collaborator, or a visitor to your portfolio. Public resume URLs are not search-indexed by default.
+Reactive Resume gives you two ways to get a resume in front of someone: download a file, or share a link to a live page. Both live in the **Share** sheet in the editor, and **Download PDF** sits in the editor bar for the most common case.
-## Export a PDF
+## Download a file
-Export a PDF when an application requires a file upload, or when you want a fixed document to send by email.
+The **Download** tab in the Share sheet offers four formats:
-See [Exporting your resume](/guides/exporting-your-resume) for the export workflow.
+| Format | Best for |
+| --- | --- |
+| **PDF** | Applications and email. Looks exactly like the page you designed. |
+| **Word** (.docx) | Portals or recruiters who ask for Word. The layout is simplified. |
+| **Markdown** (.md) | Plain text with headings, for pasting into application forms and notes. |
+| **JSON** | A complete backup of the resume's data, which you can import back into Reactive Resume. |
-## When to export
+You can set the file name before downloading. If the resume belongs to a job application that has a cover letter, you can download the letter at the same time. Cover letters download in the same four formats from their own Share sheet.
-Export a PDF when a job application requires an uploaded document, when you need a fixed copy for your records, or when you want to send a file that will not change after you submit it.
+## Share a public link
-## When to share a link
+The **Link** tab turns on a public page for the resume at an address such as `rxresu.me/your-username/your-resume`. You choose the last part of the address. From the same tab you can:
-Share a public resume URL when the recipient can open a link and you want them to see the current version of your resume.
+- Copy the link or open the public page.
+- Let visitors download the PDF, or not.
+- Require a password before anyone can view it.
+- See how many times the page was viewed and the PDF downloaded over the last 30 days.
-## Share a public resume URL
+Public pages are not listed anywhere and ask search engines not to index them. People find them only through a link you share. Turn the link off and the page stops working immediately.
-Public sharing creates a URL you can send to someone else. The page shows the current version of your resume and can offer a download to viewers.
+## When to download and when to share
-Public resume URLs are meant for people who get the link from you or find it through you.
+Download a file when an application asks for an upload, or when you want a fixed copy that will not change. Share a link when the reader can open a URL and should always see your latest version, for example in an email signature, a portfolio or a networking message.
-## When not to share only a link
+If only certain people should read the resume, add a password. A link on its own is not access control: anyone who has it can open it.
-Do not send only a public URL when an application requires a PDF or document upload. Do not use public sharing for a restricted audience unless you turn on password protection, or keep the resume private until you are ready.
-
-See [Sharing your resume publicly](/guides/sharing-your-resume-publicly) for setup, password protection, statistics, and how to turn public access off.
-
-## Use the builder dock
-
-The builder dock has shortcuts for common editing and sharing actions inside the resume builder.
-
-See [Using the builder dock](/guides/using-the-builder-dock) for the available actions.
+## Next steps
-
- Download a fixed resume file.
+
+ Download PDF, Word, Markdown or JSON, or print.
-
- Share a live resume URL with selected recipients.
+
+ Set the address, password and download options.
+
+
+ Use plain text for forms, notes and AI tools.
+
+
+ Create a letter that matches your resume's design.
-
-## Next action
-
-Use [Exporting your resume](/guides/exporting-your-resume) if you need a file, or [Sharing your resume publicly](/guides/sharing-your-resume-publicly) if a link will do.
diff --git a/docs/use-cases/free-resume-builder.mdx b/docs/use-cases/free-resume-builder.mdx
index 07fd91620..410c59d4e 100644
--- a/docs/use-cases/free-resume-builder.mdx
+++ b/docs/use-cases/free-resume-builder.mdx
@@ -1,62 +1,48 @@
---
title: "Free resume builder"
-description: "Reactive Resume is a free resume builder with no paywalls or premium tiers for creating, editing, exporting, and sharing unlimited resumes online."
+description: "Reactive Resume is a free resume builder with no premium tier: unlimited resumes and cover letters, 15 templates, and PDF, Word, Markdown and JSON downloads."
---
-Reactive Resume is a free resume builder. Write your resume in a browser, pick a template, export a PDF, and share a public URL when you want someone else to read it. Public resume URLs are meant for people you send them to, and they are not search-indexed by default.
+Reactive Resume is a free resume builder. On [rxresu.me](https://rxresu.me) you get every feature with no premium tier, no watermark and no paywall on downloads. The project is open source and funded by donations, which is why nothing is held back.
-## What you can do for free
+## What you get for free
-- Create and manage resumes from the dashboard.
-- Edit resume content with a live preview in the builder.
-- Choose from the templates included with Reactive Resume.
-- Export your resume as a PDF.
-- Share a public resume URL with recruiters, hiring managers, or collaborators.
+- **Unlimited documents.** Keep as many resumes and cover letters as you need, and copy one for each job.
+- **15 templates.** Switch at any time without retyping; your content stays the same and only the design changes.
+- **Design controls.** Font pairings (or any of about 500 fonts), text size, density, accent colour, margins, A4, Letter or Free-form pages, and multi-page layouts.
+- **Every download format.** PDF, Word, Markdown and JSON, as often as you like.
+- **Check mode.** A readability score, issues pinned to the exact lines on your page, and keyword matching against a job posting.
+- **Public links.** Share a live page with an optional password and see how often it is viewed.
+- **Application tracker.** Follow every job from saved to offer, with a board, a calendar for interviews and insights.
+- **Version history.** Name versions and go back to any earlier state.
+- **Imports.** Bring in a PDF, a Reactive Resume or JSON Resume file, or a LinkedIn data export. Word files work too once you connect an AI provider.
+- **50+ languages** for the app interface.
-Start with [Introduction](/getting-started) for the product overview, or follow the [Quickstart](/getting-started/quickstart) to create your first resume on [rxresu.me](https://rxresu.me).
+AI features are free to use in Reactive Resume too, but they run on your own AI provider account, which may charge you. See [AI resume builder](/use-cases/ai-resume-builder).
-## Builder workflow
+## When the hosted app is the right choice
-Reactive Resume sticks to the core resume workflow: add your profile, work history, education, skills, projects, and other sections, then adjust the layout and template before exporting.
+Use [rxresu.me](https://rxresu.me) when you want a resume quickly and do not want to run any software. You need an account so your documents are saved between visits. Sign up with an email address and password, or with a social account where one is offered.
-Useful guides:
+## When it is not
-- [Creating your first resume](/guides/creating-your-first-resume)
-- [Choosing a template](/guides/choosing-a-template)
-- [Fitting content on a page](/guides/fitting-content-on-a-page)
-- [Using the builder dock](/guides/using-the-builder-dock)
+If your organization needs its own deployment, its own sign-in provider or control over where data is stored, run your own instance instead. See [Self-hosted resume builder](/use-cases/self-hosted-resume-builder).
-## When to use this path
+If you only want to check an existing PDF, you do not need an account at all: the [ATS checker](/guides/using-the-ats-checker) runs in your browser.
-Use the hosted app when you want a resume quickly and do not want to run any infrastructure. It covers personal resume editing, template selection, PDF export, and sharing a public URL with people who need to read your resume.
-
-## When not to use this path
-
-Do not rely on the hosted app alone if your organization requires a controlled deployment, custom auth, or specific data residency rules. Review [Self-hosting with Docker](/self-hosting/docker) instead. If an application requires a file upload, export a PDF rather than sending only a public URL.
-
-## Export and sharing options
-
-Download a PDF for applications that need a file. You can also turn on a public resume URL when a link is more convenient than an attachment.
-
-Public resume URLs are for the people you send the link to. For details, see [Exporting your resume](/guides/exporting-your-resume) and [Sharing your resume publicly](/guides/sharing-your-resume-publicly).
-
-## Next action
-
-Open [rxresu.me](https://rxresu.me) to create a resume, or follow [Creating your first resume](/guides/creating-your-first-resume) for the guided workflow.
-
-## Related resources
+## Next steps
-
- Open the official Reactive Resume instance.
+
+ Sign up, open a sample resume and download your first PDF.
-
- View the project repository.
+
+ Start blank, from a sample or from an import.
-
- Review the project license.
+
+ Compare the 15 templates and switch in one click.
-
- Learn how the hosted service describes data handling.
+
+ Download PDF, Word, Markdown or JSON.
diff --git a/docs/use-cases/open-source-resume-builder.mdx b/docs/use-cases/open-source-resume-builder.mdx
index b022e5143..72901312f 100644
--- a/docs/use-cases/open-source-resume-builder.mdx
+++ b/docs/use-cases/open-source-resume-builder.mdx
@@ -1,54 +1,50 @@
---
title: "Open-source resume builder"
-description: "Learn how Reactive Resume works as an open-source resume builder with public source code, an MIT license, and self-hosting support."
+description: "Reactive Resume is an open-source resume builder under the MIT license: read the code, contribute, translate, or run your own instance."
---
-Reactive Resume is an open-source resume builder. The source code is public, the license is MIT, and you can either use the hosted app or run your own instance.
+Reactive Resume is an open-source resume builder. Its source code is public on [GitHub](https://github.com/reactive-resume/reactive-resume) under the [MIT license](/legal/license). You can use the hosted app at [rxresu.me](https://rxresu.me), read exactly how it handles your data, or run your own copy.
## What open source means here
-The project repository is on [GitHub](https://github.com/reactive-resume/reactive-resume). You can read the code, report issues, contribute improvements, and see how the builder, the API, the self-hosting setup, and the documentation are put together.
+- **Everything is in the open.** The editor, the PDF renderer, the API, the MCP server and these docs all live in one public repository.
+- **The MIT license is permissive.** You can use, modify and host the software, including for an organization, as long as you keep the license notice.
+- **The hosted app runs the same code.** There is no closed "pro" edition. Features on [rxresu.me](https://rxresu.me) are the features in the repository.
+- **Your data is portable.** Download any resume as JSON, or export all your documents and applications as a zip from **Settings → Account**. A resume's JSON file imports into any other Reactive Resume instance.
-The license is documented in [License](/legal/license).
+## What the software includes
-## Product capabilities
+Reactive Resume v6 is more than a resume editor. It covers the whole job search:
-Reactive Resume has a browser-based builder, resume templates, PDF export, public sharing links, API access, MCP support, and optional AI-assisted workflows. Public resume URLs are meant for the people you share them with and are not search-indexed by default. The main workflow starts in the hosted app at [rxresu.me](https://rxresu.me) or on your own deployment.
-
-Helpful starting points:
-
-- [Introduction](/getting-started)
-- [Quickstart](/getting-started/quickstart)
-- [Creating your first resume](/guides/creating-your-first-resume)
-- [Managing resumes from the dashboard](/guides/managing-resumes-from-the-dashboard)
+- Resumes and cover letters as separate documents, with 15 templates and shared design.
+- Check mode for readability, issues and job-posting keywords, plus a public ATS checker.
+- Public links with passwords and view statistics.
+- An application tracker with board, list, calendar and insights views.
+- An optional AI assistant that uses your own provider.
+- An API and an MCP server for automation.
+- An interface translated into more than 50 languages by volunteers.
## When to use this path
-Use the open-source path when you want to see how resume data and exports are handled, contribute fixes or translations, or keep the option of running your own deployment.
+Start from the source if you want to audit how resume data is stored and exported, fix a bug, add a translation, or run the app yourself. If you only want to write a resume, use [rxresu.me](https://rxresu.me) and follow the [Quickstart](/getting-started/quickstart) instead.
-## When not to use this path
+Open source does not make a deployment compliant on its own. If you run an instance, you are responsible for its security, storage and privacy.
-Do not start with the source code if you only want to build a resume. Use [rxresu.me](https://rxresu.me) or the [Quickstart](/getting-started/quickstart) first. Do not assume open source covers your compliance needs on its own; if you run an instance, read the self-hosting and privacy docs.
-
-## Contribution and customization
-
-To contribute or to understand the codebase, start with the contributor docs. They cover the local development setup, the package layout, and the project conventions.
+## Contribute
- Set up the repository locally.
+ Run the repository locally.
- Understand the app and package boundaries.
+ Learn how the apps and packages fit together.
- Help translate the app.
+ Help translate the interface into your language.
- Browse issues, pull requests, and source code.
+ Browse issues, pull requests and source code.
-## Next action
-
-To contribute, set up the project with [Development setup](/contributing/development). To run it yourself, start with [Self-hosting with Docker](/self-hosting/docker).
+To run your own instance, start with [Self-hosting with Docker](/self-hosting/docker).
diff --git a/docs/use-cases/privacy-focused-resume-builder.mdx b/docs/use-cases/privacy-focused-resume-builder.mdx
index 1909d5fbb..892093bee 100644
--- a/docs/use-cases/privacy-focused-resume-builder.mdx
+++ b/docs/use-cases/privacy-focused-resume-builder.mdx
@@ -1,46 +1,51 @@
---
title: "Privacy-focused resume builder"
-description: "Reactive Resume's privacy-focused design: open-source transparency, self-hosting, public sharing controls, and optional bring-your-own AI providers."
+description: "How Reactive Resume protects your resume data: private by default, password-protected links, in-browser ATS checks, your own AI provider, and self-hosting."
---
-Reactive Resume is a privacy-focused resume builder: the code is open source, you can self-host it, editing is private by default, and both sharing and AI are choices you make yourself.
+Reactive Resume is a privacy-focused resume builder. Your documents are private until you choose to share them, AI only runs when you connect your own provider, and the code is open source, so you can check these claims yourself or run your own instance.
-## Privacy controls in the resume workflow
+## Private by default
-You edit resumes inside your account. When you want someone else to read one, turn on a public URL and send them the link. Public resume URLs are not search-indexed by default. You can turn public access off again, and a public resume can require a password.
+- **Documents stay in your account.** Nobody else can see your resumes or cover letters. A resume becomes visible only when you turn on its public link in **Share → Link**. Cover letters have no public link.
+- **Public links are unlisted.** Public pages are not listed anywhere and ask search engines not to index them.
+- **Links can require a password.** Add one in the Link tab, and turn the link off at any time to stop access immediately.
+- **Private notes stay private.** Notes you keep on a resume never appear on the page, in the PDF or on the public link. Hidden sections and entries are removed from the public link too.
-For the sharing workflow, see [Sharing your resume publicly](/guides/sharing-your-resume-publicly). For account security, see [Setting up two-factor authentication](/guides/setting-up-two-factor-authentication) and [Setting up passkeys](/guides/setting-up-passkeys).
+## Checks that never leave your browser
-## When to use this path
+The public [ATS checker](/guides/using-the-ats-checker) reads your PDF in your browser. The file is not uploaded and you do not need an account. In the editor, Check mode's score, issues and job match also run without sending your resume to an AI service.
-Use this path when you want to understand the tradeoffs before choosing hosted use, self-hosting, public sharing, API automation, or AI features. It matters most if your resume holds sensitive job-search information, or if you plan to share links with only a few people.
+## AI only on your terms
-## When not to use this path
+Reactive Resume does not include a built-in AI model. AI features run only after you connect a provider of your choice in **Settings → AI**, using your own key, and your text goes only to that provider when you use an AI feature. Without a provider, PDF imports are read in your browser. Remove the key at any time to turn AI off.
-Do not treat public sharing as access control. Use password protection, or keep the resume private, if only specific people should open the link. Do not turn on AI features unless you are comfortable sending the prompts and resume content to the provider you configure.
+## Account security and your data
-## Open source and self-hosting
+Protect your account with [two-factor authentication](/guides/setting-up-two-factor-authentication) or [passkeys](/guides/setting-up-passkeys). You can [export all your data](/guides/exporting-your-data) as a zip at any time, and deleting your account permanently removes your documents and applications.
-The source code is on [GitHub](https://github.com/reactive-resume/reactive-resume), so you can read how the application works. If you need direct control over infrastructure, storage, auth providers, and deployment policy, run your own instance.
+## Full control with self-hosting
-Start with:
-
-- [Self-hosting with Docker](/self-hosting/docker)
-- [Self-hosting examples](/self-hosting/examples)
-- [Single sign-on](/self-hosting/sso)
-- [Privacy Policy](/legal/privacy-policy)
-
-## Optional AI features
-
-AI is optional. Reactive Resume does not need it to create, edit, export, or share a resume. If you do want AI help, you configure the provider and the key yourself.
-
-For setup details, see [Using artificial intelligence](/guides/using-ai), [Using AI in the builder](/guides/using-ai-in-the-builder), and [Using the AI Agent workspace](/guides/using-ai-agent).
+If you need to decide where data is stored, who can sign in and which services it talks to, run your own instance. The source is on [GitHub](https://github.com/reactive-resume/reactive-resume), and self-hosted instances can use your own database, file storage, email server and single sign-on provider.
- Review the privacy policy for the instance you use. If you self-host, you are responsible for the deployment's data
- handling, storage, email, and third-party provider configuration.
+ The hosted service at rxresu.me is covered by its [Privacy Policy](/legal/privacy-policy). If you self-host, you are
+ responsible for how your deployment stores and handles data.
-## Next action
+## Next steps
-Read the [Privacy Policy](/legal/privacy-policy), then go to [Sharing your resume publicly](/guides/sharing-your-resume-publicly) for link controls or [Self-hosting with Docker](/self-hosting/docker) for infrastructure control.
+
+
+ Control the address, password and downloads.
+
+
+ See what is sent to your provider and when.
+
+
+ Run Reactive Resume on your own infrastructure.
+
+
+ Use your organization's identity provider.
+
+
diff --git a/docs/use-cases/self-hosted-resume-builder.mdx b/docs/use-cases/self-hosted-resume-builder.mdx
index 9285afebe..4b2e29d2f 100644
--- a/docs/use-cases/self-hosted-resume-builder.mdx
+++ b/docs/use-cases/self-hosted-resume-builder.mdx
@@ -1,40 +1,52 @@
---
title: "Self-hosted resume builder"
-description: "Run Reactive Resume as a self-hosted resume builder with Docker Compose, PostgreSQL, optional object storage, Single Sign-On, and v4 to v5 migration."
+description: "Run Reactive Resume on your own server with Docker, Kubernetes or Vercel: PostgreSQL, local or S3 storage, single sign-on, and the full v6 feature set."
---
-You can run Reactive Resume on your own infrastructure instead of using the hosted instance.
+You can run Reactive Resume on your own infrastructure instead of using [rxresu.me](https://rxresu.me). A self-hosted instance has the same features as the hosted app: resumes, cover letters, Check mode, public links, the application tracker, the AI assistant, the API and the MCP server.
## When self-hosting is a good fit
-Self-hosting helps when you need control over the deployment, domain, database, storage, email delivery, authentication providers, and operational policy. The Docker guide is the main setup path for most deployments.
+Self-host when you need to control where data lives, who can sign in, and which outside services the app talks to. Common reasons are a company or school that wants its own resume tool, strict data-residency rules, or a personal server you already run.
-Start here:
+If you only need a resume for yourself, the hosted app is less work.
-- [Self-hosting with Docker](/self-hosting/docker)
-- [Self-hosting examples](/self-hosting/examples)
-- [Single sign-on](/self-hosting/sso)
-- [Migration guide](/self-hosting/migration)
+## What a deployment needs
-## Core deployment pieces
+The production image runs a single Node.js process on port 3000 that serves the app, the API and the MCP server. Around it you need:
-A typical self-hosted deployment uses:
+- **PostgreSQL** for accounts, documents and applications. Database migrations run automatically when the server starts.
+- **File storage** for photos and uploads: a local folder, any S3-compatible service, or Vercel Blob.
+- **SMTP** for account emails. Without it, emails are written to the server log, which is fine for testing.
+- **Optional: Redis and an encryption secret.** Both are needed for saved AI providers and the assistant. Core resume features work without them.
+- **Optional: single sign-on** through Google, GitHub, LinkedIn or a custom OpenID Connect provider.
-- Reactive Resume application container.
-- PostgreSQL database.
-- SMTP configuration for account emails, or console-logged emails in simple development setups.
-- Optional S3-compatible storage for uploads.
-- Optional SSO or custom OAuth configuration.
+Rendering PDFs needs no extra service: there is no headless browser to run.
-The Docker guide has the environment variable reference and a Compose example.
+## Ways to deploy
-## Feature considerations
+
+
+ The main path: Docker Compose with PostgreSQL.
+
+
+ Run the same image in a cluster.
+
+
+ Deploy the frontend and backend as Vercel Services.
+
+
+ Ready-made setups with reverse proxies and storage.
+
+
-Some features need extra configuration. Saved AI providers need server-side encryption configured, and the AI Agent workspace uses Redis. Self-hosted deployments can also expose API and MCP endpoints from their own domain.
+Every setting is listed in [Environment variables](/self-hosting/environment-variables). For sign-in providers, see [Single sign-on](/self-hosting/sso).
-For automation setup on a self-hosted instance, see [Using the API](/guides/using-the-api) and [Using the MCP server](/guides/using-the-mcp-server).
+## Upgrading an existing instance
+
+Already running v5? Read [Upgrading to v6](/self-hosting/upgrading-to-v6) before you pull the new image. It covers what changes in the database, the API and the MCP server. For older v4 instances, see the [Migration guide](/self-hosting/migration).
- Replace hosted URLs such as https://rxresu.me with your own instance URL when following API, MCP, or
- sharing examples.
+ When you follow API, MCP or sharing examples in these docs, replace `https://rxresu.me` with your own instance's
+ address.