mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
Rewrite every guide for the redesigned app, add guides for new features (documents, editor modes, check, cover letters, assistant, applications, self-hosting upgrade and environment reference), remove v5-only pages with redirects, and replace every screenshot.
400 lines
14 KiB
Plaintext
400 lines
14 KiB
Plaintext
---
|
|
title: "Self-hosting with Kubernetes"
|
|
description: "Deploy Reactive Resume on Kubernetes with plain manifests: PostgreSQL, a Secret, persistent uploads, health probes, Ingress, scaling and updates."
|
|
---
|
|
|
|
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.
|
|
|
|
## Before you start
|
|
|
|
You need:
|
|
|
|
- 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.
|
|
|
|
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.
|
|
|
|
## Create the namespace and Secret
|
|
|
|
Save both manifests. The Secret holds every setting and is passed to the app as environment variables.
|
|
|
|
```yaml namespace.yaml
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: reactive-resume
|
|
```
|
|
|
|
```yaml secret.yaml
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: reactive-resume
|
|
namespace: reactive-resume
|
|
type: Opaque
|
|
stringData:
|
|
# The public HTTPS address people use. Must match the Ingress host.
|
|
APP_URL: "https://resume.example.com"
|
|
# "postgres" is the Service name from postgres.yaml.
|
|
DATABASE_URL: "postgresql://postgres:REPLACE_WITH_DATABASE_PASSWORD@postgres:5432/postgres"
|
|
# Read by the example PostgreSQL Deployment. Same password as in DATABASE_URL.
|
|
POSTGRES_PASSWORD: "REPLACE_WITH_DATABASE_PASSWORD"
|
|
# 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 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.
|
|
|
|
<Tip>
|
|
`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.
|
|
</Tip>
|
|
|
|
## Run PostgreSQL
|
|
|
|
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 lines expandable
|
|
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 Pod before a new one mounts the same data.
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: postgres
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: postgres
|
|
spec:
|
|
containers:
|
|
- name: postgres
|
|
image: postgres:18
|
|
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
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /var/lib/postgresql
|
|
readinessProbe:
|
|
exec:
|
|
command: ["pg_isready", "-h", "127.0.0.1", "-U", "postgres", "-d", "postgres"]
|
|
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
|
|
```
|
|
|
|
- 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 app
|
|
|
|
This file adds the uploads volume, the Deployment, a Service and an Ingress.
|
|
|
|
```yaml reactive-resume.yaml lines expandable
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: reactive-resume-data
|
|
namespace: reactive-resume
|
|
spec:
|
|
accessModes: ["ReadWriteOnce"]
|
|
resources:
|
|
requests:
|
|
storage: 10Gi
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: reactive-resume
|
|
namespace: reactive-resume
|
|
spec:
|
|
replicas: 1
|
|
# The uploads volume is ReadWriteOnce, so the old Pod must stop first.
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: reactive-resume
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: reactive-resume
|
|
spec:
|
|
# The image runs as the non-root "node" user (UID and GID 1000).
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 1000
|
|
runAsGroup: 1000
|
|
fsGroup: 1000
|
|
containers:
|
|
- name: reactive-resume
|
|
image: ghcr.io/reactive-resume/reactive-resume:v6
|
|
imagePullPolicy: Always
|
|
ports:
|
|
- 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: http
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
resources:
|
|
requests:
|
|
cpu: 250m
|
|
memory: 512Mi
|
|
limits:
|
|
memory: 1Gi
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim:
|
|
claimName: reactive-resume-data
|
|
---
|
|
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: http
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: Ingress
|
|
metadata:
|
|
name: reactive-resume
|
|
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:
|
|
- host: resume.example.com
|
|
http:
|
|
paths:
|
|
- path: /
|
|
pathType: Prefix
|
|
backend:
|
|
service:
|
|
name: reactive-resume
|
|
port:
|
|
name: http
|
|
tls:
|
|
- hosts:
|
|
- resume.example.com
|
|
secretName: reactive-resume-tls
|
|
```
|
|
|
|
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 and verify
|
|
|
|
<Steps>
|
|
<Step title="Apply the manifests">
|
|
```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`.
|
|
</Step>
|
|
|
|
<Step title="Create your account">
|
|
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.
|
|
</Step>
|
|
|
|
<Step title="Check that data persists">
|
|
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
|
|
```
|
|
|
|
Reload the page. The resume and picture should still be there. Expect a short outage while a single-replica
|
|
Deployment restarts.
|
|
</Step>
|
|
|
|
<Step title="Close sign-ups (optional)">
|
|
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.
|
|
</Step>
|
|
</Steps>
|
|
|
|
## How the probes work
|
|
|
|
- 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.
|
|
|
|
See [Check the health endpoint](/self-hosting/docker#check-the-health-endpoint) for the response format. To call it
|
|
yourself:
|
|
|
|
```bash
|
|
kubectl -n reactive-resume port-forward service/reactive-resume 3000:80
|
|
curl http://localhost:3000/api/health
|
|
```
|
|
|
|
## Run several replicas
|
|
|
|
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
|
|
kubectl -n reactive-resume rollout status deployment/reactive-resume
|
|
```
|
|
|
|
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)
|
|
|
|
<Warning>
|
|
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.
|
|
</Warning>
|
|
|
|
## Troubleshooting
|
|
|
|
<AccordionGroup>
|
|
<Accordion title="The app Pod is in CrashLoopBackOff">
|
|
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.
|
|
</Accordion>
|
|
|
|
<Accordion title="The Pod never becomes Ready">
|
|
Port-forward and call `/api/health`. The entry marked `unhealthy` points at the problem: the database, the
|
|
uploads volume or S3 settings, or Redis.
|
|
</Accordion>
|
|
|
|
<Accordion title="Sign-in loops back to the sign-in page">
|
|
`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.
|
|
</Accordion>
|
|
|
|
<Accordion title="Assistant replies stop partway or attachments fail to upload">
|
|
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.
|
|
</Accordion>
|
|
</AccordionGroup>
|
|
|
|
## 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.
|