> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rxresu.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Self-hosting with Kubernetes

> How to self-host Reactive Resume on Kubernetes with plain manifests: PostgreSQL, persistent uploads, Secrets, ingress and verification steps.

<Info>
  **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.
</Info>

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

<CardGroup cols={2}>
  <Card title="Image">
    Use <code>ghcr.io/reactive-resume/reactive-resume:latest</code> or <code>amruthpillai/reactive-resume:latest</code>.
  </Card>

  <Card title="PostgreSQL">Stores accounts, resumes, and application data. Runs separately, never embedded in the app image.</Card>
</CardGroup>

## Minimum requirements

<CardGroup cols={1}>
  <Card title="Kubernetes cluster">
    A running cluster with <code>kubectl</code> access and a default StorageClass for PersistentVolumeClaims.
  </Card>

  <Card title="Ingress + TLS">
    An Ingress controller (nginx, Traefik, …) and a way to issue TLS certificates, for example cert-manager.
  </Card>

  <Card title="Compute">1 vCPU / 1 GB RAM minimum for the app Pod (2 GB recommended when PostgreSQL runs in the same cluster).</Card>
</CardGroup>

## Create the namespace

Save this as `namespace.yaml`. Apply it before any of the namespaced resources below.

```yaml namespace.yaml theme={null}
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 theme={null}
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)
```

<Steps>
  <Step title="Generate AUTH_SECRET">
    Generate a strong secret and paste it into `AUTH_SECRET`.

    ```bash theme={null}
    openssl rand -hex 32
    ```
  </Step>

  <Step title="Set APP_URL">
    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.
  </Step>

  <Step title="Set DATABASE_URL">
    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.
  </Step>
</Steps>

<Tip>
  `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.
</Tip>

## 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 theme={null}
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 theme={null}
# 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
```

<Note>
  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.
</Note>

Apply the four files in order and wait for PostgreSQL before starting the app:

```bash theme={null}
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 theme={null}
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.

<Warning>
  Switching between local storage and S3 does not move existing uploads. Export or back them up before changing the
  storage driver.
</Warning>

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/`.

<Tip>
  HTTPS is strongly recommended. Authentication cookies and the first-user signup flow depend on a correct public
  origin.
</Tip>

## 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 theme={null}
kubectl -n reactive-resume port-forward service/reactive-resume 3000:80
```

```bash theme={null}
curl -f http://localhost:3000/api/health
```

<Info>
  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`.
</Info>

## Verify the installation

<Steps>
  <Step title="Create the first account">
    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`.
  </Step>

  <Step title="Create a 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.
  </Step>

  <Step title="Export a PDF">
    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.
  </Step>

  <Step title="Check persistence">
    Replace the Pod and verify nothing is lost:

    ```bash theme={null}
    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.
  </Step>

  <Step title="Close signups (optional)">
    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.
  </Step>
</Steps>

## 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)

<Warning>
  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.
</Warning>

## 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 theme={null}
   kubectl -n reactive-resume rollout restart deployment/reactive-resume
   ```

3. **Wait for the rollout**, then check the startup logs while migrations run:

   ```bash theme={null}
   kubectl -n reactive-resume rollout status deployment/reactive-resume
   kubectl -n reactive-resume logs -f deployment/reactive-resume
   ```

<Tip>
  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.
</Tip>

## Troubleshooting

<AccordionGroup>
  <Accordion title="The app Pod is in CrashLoopBackOff">
    * **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.
  </Accordion>

  <Accordion title="Can't sign in / redirect loop / cookies don't stick">
    * **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.
  </Accordion>

  <Accordion title="/api/health returns 503 even though Postgres is up">
    * **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.
  </Accordion>

  <Accordion title="Uploads disappear after a restart">
    * **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.
  </Accordion>

  <Accordion title="PDF export fails or downloads an empty file">
    * **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.
  </Accordion>
</AccordionGroup>
