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. + +