diff --git a/docs/docs.json b/docs/docs.json
index 643e22d04..e08a915e4 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -140,7 +140,13 @@
},
{
"group": "Self-Hosting",
- "pages": ["self-hosting/docker", "self-hosting/examples", "self-hosting/sso", "self-hosting/migration"]
+ "pages": [
+ "self-hosting/docker",
+ "self-hosting/kubernetes",
+ "self-hosting/examples",
+ "self-hosting/sso",
+ "self-hosting/migration"
+ ]
},
{
"group": "Contributing",
diff --git a/docs/self-hosting/kubernetes.mdx b/docs/self-hosting/kubernetes.mdx
new file mode 100644
index 000000000..030e49b4c
--- /dev/null
+++ b/docs/self-hosting/kubernetes.mdx
@@ -0,0 +1,510 @@
+---
+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."
+---
+
+
+ **From v5.1.0 onwards** — the builder generates PDFs in the browser via `@react-pdf/renderer`. 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.
+
+
+## Overview
+
+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:
+
+- **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).
+
+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.
+
+
+
+ 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.
+
+
+## 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.
+
+```yaml namespace.yaml
+apiVersion: v1
+kind: Namespace
+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
+metadata:
+ name: reactive-resume
+ namespace: reactive-resume
+type: Opaque
+stringData:
+ # Canonical public URL of your instance. Must match the HTTPS URL users actually visit.
+ APP_URL: "https://resume.example.com"
+ # "postgres" is the Service name from the PostgreSQL section below.
+ DATABASE_URL: "postgresql://postgres:REPLACE_WITH_DATABASE_PASSWORD@postgres:5432/postgres"
+ # Used by the example PostgreSQL Deployment. Must match the password 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)
+```
+
+
+
+ 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.
+
+
+
+
+ `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.
+
+
+## PostgreSQL dependency
+
+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:
+
+```yaml postgres.yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: postgres-data
+ namespace: reactive-resume
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: postgres
+ namespace: reactive-resume
+spec:
+ replicas: 1
+ # Stop the old database Pod before another one mounts the same data directory.
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: postgres
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: postgres
+ spec:
+ containers:
+ - name: postgres
+ image: postgres:17
+ ports:
+ - containerPort: 5432
+ env:
+ - name: POSTGRES_DB
+ value: postgres
+ - name: POSTGRES_USER
+ value: postgres
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: reactive-resume
+ key: POSTGRES_PASSWORD
+ - name: PGDATA
+ value: /var/lib/postgresql/data/pgdata
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ readinessProbe:
+ exec:
+ command: ["pg_isready", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres"]
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: postgres-data
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: postgres
+ namespace: reactive-resume
+spec:
+ selector:
+ 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.
+
+## Deploy the application
+
+With the namespace and Secret above, this file adds the uploads PersistentVolumeClaim, Deployment, Service, and Ingress.
+
+```yaml reactive-resume.yaml
+# Persistent storage for uploads, used when S3 is not configured
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: reactive-resume-data
+ namespace: reactive-resume
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+---
+# Deployment
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: reactive-resume
+ namespace: reactive-resume
+spec:
+ replicas: 1
+ # One replica at a time: migrations run on startup and the PVC is ReadWriteOnce.
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: reactive-resume
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: reactive-resume
+ spec:
+ # The official image runs as the non-root `node` user (UID/GID 1000).
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 1000
+ fsGroup: 1000
+ containers:
+ - name: reactive-resume
+ image: ghcr.io/reactive-resume/reactive-resume:latest
+ imagePullPolicy: Always
+ # Docker Hub alternative: amruthpillai/reactive-resume:latest
+ ports:
+ - containerPort: 3000
+ envFrom:
+ - secretRef:
+ name: reactive-resume
+ volumeMounts:
+ - name: data
+ mountPath: /app/data
+ readinessProbe:
+ httpGet:
+ path: /api/health
+ port: 3000
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 5
+ resources:
+ requests:
+ cpu: 250m
+ memory: 512Mi
+ limits:
+ memory: 1Gi
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: reactive-resume-data
+---
+# Service
+apiVersion: v1
+kind: Service
+metadata:
+ name: reactive-resume
+ namespace: reactive-resume
+spec:
+ selector:
+ app.kubernetes.io/name: reactive-resume
+ ports:
+ - name: http
+ port: 80
+ targetPort: 3000
+---
+# Ingress
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: reactive-resume
+ namespace: reactive-resume
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt
+spec:
+ ingressClassName: nginx
+ rules:
+ - host: resume.example.com
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: reactive-resume
+ port:
+ number: 80
+ 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.
+
+
+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
+
+
+
+ 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`.
+
+
+
+ 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 **Download** in the builder header and choose **PDF**. Builder PDF rendering happens in the browser via
+ `@react-pdf/renderer`. Open the downloaded file and check its text, fonts, and picture.
+
+
+
+ Replace the Pod and verify nothing is lost:
+
+ ```bash
+ kubectl -n reactive-resume rollout restart deployment/reactive-resume
+ kubectl -n reactive-resume rollout status deployment/reactive-resume --timeout=300s
+ ```
+
+ 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.
+
+
+
+ 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.
+
+
+
+## Community Helm chart (HelmForge)
+
+A community-maintained Helm chart for Reactive Resume is available in the HelmForge charts repository:
+
+- 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)
+
+
+ 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.
+
+
+## Updating
+
+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.
+
+ ```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.
+
+
+## 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.
+
+
+
+ - **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.
+
+
+
+ - **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.
+
+
+
+ - **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.
+
+