Chapter 2 of 15
Installing PatchMon on Kubernetes with Helm
Overview
The community Helm chart for PatchMon deploys the server on any Kubernetes 1.19+ cluster. It is maintained in a separate repository:
- Chart repository: github.com/RuTHlessBEat200/PatchMon-helm
- Application repository: github.com/PatchMon/PatchMon
Verify against the latest chart. This page describes the chart values shape. The chart is community-maintained and may be a version or two behind the latest PatchMon release. Always check the chart's own README for the current value names and defaults before upgrading.
Important: PatchMon 2.0 architecture
The Helm chart was originally written against the 1.4.x Node.js stack, which shipped a separate patchmon-backend and patchmon-frontend image and used BullMQ for background jobs. PatchMon 2.0 consolidates everything into a single Go binary: the React frontend is embedded, chi serves both /api/* and the SPA, background jobs run on Asynq, and schema migrations are applied automatically at boot by golang-migrate.
Practical implications for the chart:
| Concept | 1.4.x (Node) | 2.0+ (Go) |
|---|---|---|
| Containers | patchmon-backend + patchmon-frontend |
Single patchmon-server |
| Queue | BullMQ | Asynq |
| Migrations | Prisma | golang-migrate (embedded, automatic) |
| Listen port | backend 3001, frontend 3000 | server 3000 (both /api/* and SPA) |
| RDP sidecar | n/a | guacamole/guacd (optional, required for in-browser RDP) |
If your version of the chart still ships with separate backend and frontend deployments, set the frontend deployment to enabled: false and point your Ingress exclusively at the backend image tagged ghcr.io/patchmon/patchmon-server:2.0.0 or later, exposed on port 3000. Where the chart's values file uses backend.env.* keys, those now map to the single patchmon-server container's environment.
Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- A PersistentVolume provisioner in the cluster (for PostgreSQL and Redis data)
- An Ingress controller (e.g. NGINX Ingress) for external access (strongly recommended)
- cert-manager for automatic TLS certificate management (optional)
- Metrics Server for HPA (optional)
Container Images
| Component | Image | Default Tag |
|---|---|---|
| Server | ghcr.io/patchmon/patchmon-server |
2.0.0 |
| Database | docker.io/postgres |
17-alpine |
| Redis | docker.io/redis |
7-alpine |
| guacd (RDP sidecar, optional) | docker.io/guacamole/guacd |
1.6.0 |
Available tags (server image)
| Tag | Description |
|---|---|
latest |
Latest stable release |
x.y.z |
Exact version pin (e.g. 2.0.0) |
x.y |
Latest patch in a minor series (e.g. 2.0) |
x |
Latest minor and patch in a major series (e.g. 2) |
edge |
Latest development build from the main branch. Unstable, for testing only. |
Quick Start
The quickest way to try PatchMon on Kubernetes is the provided values-quick-start.yaml. It contains placeholder secrets and sensible defaults for a single-command install.
Warning:
values-quick-start.yamlships with placeholder secrets and is intended for evaluation only. Never use it in production without replacing all secret values.
1. Install the chart
wget https://raw.githubusercontent.com/RuTHlessBEat200/PatchMon-helm/refs/heads/main/values-quick-start.yaml
helm install patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
--namespace patchmon \
--create-namespace \
--values values-quick-start.yaml
2. Wait for pods to become ready
kubectl get pods -n patchmon -w
The server pod runs embedded migrations on first boot. Follow the logs to watch them apply:
kubectl logs -n patchmon deploy/patchmon-server -f
3. Access PatchMon
If an Ingress is configured, open the host you set (e.g. https://patchmon.example.com).
Without Ingress, use port-forwarding:
kubectl port-forward -n patchmon svc/patchmon-server 3000:3000
Then open http://localhost:3000 and complete the first-time admin setup.
Production Deployment
For production, start from values-prod.yaml in the chart repository. The example below demonstrates how to:
- Use an external Kubernetes Secret (managed by SOPS, Sealed Secrets, or External Secrets Operator) instead of inline passwords
- Configure HTTPS with cert-manager
- Set the
CORS_ORIGINto the external URL your users access (comma-separate multiple URLs with no spaces if PatchMon is reached from more than one origin)
1. Create your secrets
The chart does not auto-generate secrets. You must supply them yourself.
Required secrets for a PatchMon 2.0 deployment:
| Key | Description |
|---|---|
postgres-password |
PostgreSQL password |
redis-password |
Redis password |
jwt-secret |
JWT signing secret used by the server |
ai-encryption-key |
Encryption key used for AI provider credentials, bootstrap tokens, and other secrets at rest |
oidc-client-secret |
OIDC client secret (only when OIDC is enabled) |
Example: creating a Secret manually
kubectl create namespace patchmon
kubectl create secret generic patchmon-secrets \
--namespace patchmon \
--from-literal=postgres-password="$(openssl rand -hex 32)" \
--from-literal=redis-password="$(openssl rand -hex 32)" \
--from-literal=jwt-secret="$(openssl rand -hex 64)" \
--from-literal=ai-encryption-key="$(openssl rand -hex 32)"
Recommended secret-management tools for production:
- SOPS: encrypt secrets in Git
- Sealed Secrets: cluster-only decryption
- External Secrets Operator: sync secrets from Vault, AWS Secrets Manager, etc.
2. Create your values file
Start from values-prod.yaml and adjust:
global:
storageClass: "your-storage-class"
imageTag: "2.0.0"
fullnameOverride: "patchmon-prod"
server:
env:
# Comma-separate with no spaces to allow multiple origins, e.g.
# "https://patchmon.example.com,https://patchmon.internal.lan"
CORS_ORIGIN: "https://patchmon.example.com"
ENABLE_HSTS: "true"
TRUST_PROXY: "true"
existingSecret: "patchmon-secrets"
existingSecretJwtKey: "jwt-secret"
existingSecretAiEncryptionKey: "ai-encryption-key"
database:
auth:
existingSecret: "patchmon-secrets"
existingSecretPasswordKey: "postgres-password"
redis:
auth:
existingSecret: "patchmon-secrets"
existingSecretPasswordKey: "redis-password"
secret:
create: false
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400"
nginx.ingress.kubernetes.io/proxy-send-timeout: "86400"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
hosts:
- host: patchmon.example.com
paths:
- path: /
pathType: Prefix
service:
name: server
port: 3000
tls:
- secretName: patchmon-tls
hosts:
- patchmon.example.com
Verify against the latest chart. The exact value keys (
server.*vsbackend.*) depend on whether the chart has been updated for 2.0. If your chart still splits backend and frontend, setfrontend.enabled: falseand expose only the backend on port 3000.
3. Install
helm install patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
--namespace patchmon \
--create-namespace \
--values values-prod.yaml
Configuration Reference
Verify against the latest chart. The table below reflects the original chart value names. 2.0 versions of the chart are expected to drop the separate
frontend.*block and consolidate everything under a singleserver.*block. Always diff against the chart'svalues.yamlbefore changing things.
Global Settings
| Parameter | Description | Default |
|---|---|---|
global.imageRegistry |
Override the image registry for all components | "" |
global.imageTag |
Override the image tag for the PatchMon image (takes priority over individual tags) | "" |
global.imagePullSecrets |
Image pull secrets applied to all pods | [] |
global.storageClass |
Default storage class for all PVCs | "" |
nameOverride |
Override the chart name used in resource names | "" |
fullnameOverride |
Override the full resource name prefix | "" |
commonLabels |
Labels added to all resources | {} |
commonAnnotations |
Annotations added to all resources | {} |
Database (PostgreSQL)
| Parameter | Description | Default |
|---|---|---|
database.enabled |
Deploy the PostgreSQL StatefulSet | true |
database.image.registry |
Image registry | docker.io |
database.image.repository |
Image repository | postgres |
database.image.tag |
Image tag | 17-alpine |
database.auth.database |
Database name | patchmon_db |
database.auth.username |
Database user | patchmon_user |
database.auth.password |
Database password (required unless existingSecret is set) |
"" |
database.auth.existingSecret |
Existing Secret containing the password | "" |
database.auth.existingSecretPasswordKey |
Key inside the existing Secret | postgres-password |
database.persistence.enabled |
Enable persistent storage | true |
database.persistence.size |
PVC size | 5Gi |
database.resources.requests.cpu |
CPU request | 100m |
database.resources.requests.memory |
Memory request | 128Mi |
database.resources.limits.memory |
Memory limit | 1Gi |
database.service.port |
Service port | 5432 |
Redis
| Parameter | Description | Default |
|---|---|---|
redis.enabled |
Deploy the Redis StatefulSet | true |
redis.image.tag |
Image tag | 7-alpine |
redis.auth.password |
Redis password (required unless existingSecret is set) |
"" |
redis.auth.existingSecret |
Existing Secret containing the password | "" |
redis.auth.existingSecretPasswordKey |
Key inside the existing Secret | redis-password |
redis.persistence.enabled |
Enable persistent storage | true |
redis.persistence.size |
PVC size | 5Gi |
redis.resources.requests.memory |
Memory request | 10Mi |
redis.resources.limits.memory |
Memory limit | 512Mi |
redis.service.port |
Service port | 6379 |
Server (PatchMon 2.0)
In 2.0 the server is a single Go binary that serves /api/* and the embedded React SPA on port 3000. In chart versions that have not yet been updated for 2.0, the equivalent values live under backend.* and frontend.enabled should be set to false.
| Parameter | Description | Default |
|---|---|---|
server.enabled |
Deploy the PatchMon server | true |
server.image.registry |
Image registry | ghcr.io |
server.image.repository |
Image repository | patchmon/patchmon-server |
server.image.tag |
Image tag (overridden by global.imageTag if set) |
2.0.0 |
server.replicaCount |
Number of replicas | 1 |
server.jwtSecret |
JWT signing secret (required unless existingSecret is set) |
"" |
server.aiEncryptionKey |
Encryption key for secrets at rest | "" |
server.existingSecret |
Name of an existing Secret for JWT and encryption key | "" |
server.existingSecretJwtKey |
Key for JWT_SECRET inside the existing Secret |
jwt-secret |
server.existingSecretAiEncryptionKey |
Key for AI_ENCRYPTION_KEY inside the existing Secret |
ai-encryption-key |
server.resources.requests.cpu |
CPU request | 100m |
server.resources.requests.memory |
Memory request | 256Mi |
server.resources.limits.memory |
Memory limit | 1Gi |
server.service.port |
Service port | 3000 |
server.autoscaling.enabled |
Enable HPA | false |
Migrations: The PatchMon server runs migrations automatically at boot via embedded
golang-migrate. You do not need a dedicated migration Job; remove it from the chart if one is present.
Server environment variables
The server.env.* keys map directly to the environment variables the PatchMon binary reads. See the Environment Variables Reference for the full list.
| Key | Description | Default |
|---|---|---|
CORS_ORIGIN |
Allowed origin for CORS (must match the URL users type in their browser; comma-separate with no spaces to allow multiple, e.g. https://patchmon.example.com,https://patchmon.internal.lan) |
http://localhost:3000 |
ENABLE_HSTS |
Enable HSTS header for HTTPS | false |
TRUST_PROXY |
Trust proxy headers when behind an Ingress controller | true |
ENABLE_LOGGING |
Enable structured logging to stdout | true |
LOG_LEVEL |
Log level (debug, info, warn, error) |
info |
JSON_BODY_LIMIT |
Max JSON body size | 5mb |
AGENT_UPDATE_BODY_LIMIT |
Max agent update body size | 5mb |
TZ |
IANA timezone for log timestamps | UTC |
Tip: Many of these can be changed later from the Settings UI without a restart of the whole cluster. See Settings in the web UI.
OIDC / SSO
| Key | Description | Default |
|---|---|---|
OIDC_ENABLED |
Enable OIDC authentication | false |
OIDC_ISSUER_URL |
OIDC issuer URL | "" |
OIDC_CLIENT_ID |
OIDC client ID | "" |
OIDC_CLIENT_SECRET |
OIDC client secret (put this in an existing Secret) | "" |
OIDC_REDIRECT_URI |
Callback URL (https://<host>/api/v1/auth/oidc/callback) |
"" |
OIDC_SCOPES |
Space-separated scopes | openid email profile groups |
OIDC_AUTO_CREATE_USERS |
Auto-provision users on first login | false |
OIDC_DEFAULT_ROLE |
Default role for new OIDC users | user |
OIDC_SYNC_ROLES |
Sync roles from OIDC group claims on each login | false |
OIDC_DISABLE_LOCAL_AUTH |
Disable local username/password authentication | false |
Full OIDC configuration and group-to-role mapping variables are documented in the Environment Variables Reference.
guacd sidecar (optional, for RDP)
PatchMon 2.0 can proxy Windows RDP through the Apache Guacamole daemon. If you need in-browser RDP, add a guacd sidecar and point GUACD_ADDRESS at it:
server:
env:
GUACD_ADDRESS: "guacd:4822"
guacd:
enabled: true
image:
repository: guacamole/guacd
tag: "1.6.0"
Verify against the latest chart.
guacdsupport was added after the original 1.4.x chart; if your chart version does not include aguacd.enabledoption, you can deploy it as a separate Deployment and Service in the same namespace and setGUACD_ADDRESSto its ClusterIP hostname.
Ingress
| Parameter | Description | Default |
|---|---|---|
ingress.enabled |
Enable Ingress resource | true |
ingress.className |
Ingress class name | "" |
ingress.annotations |
Ingress annotations | {} |
ingress.hosts |
List of Ingress host rules | see chart values.yaml |
ingress.tls |
TLS configuration | [] |
Required annotations for WebSocket support (agent WS and live patch streaming):
ingress:
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400"
nginx.ingress.kubernetes.io/proxy-send-timeout: "86400"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
For detailed reverse proxy configuration (Nginx, Caddy, Traefik), see Reverse proxy examples.
Persistent Volumes
| PVC | Component | Purpose | Default Size |
|---|---|---|---|
postgres-data |
Database | PostgreSQL data directory | 5Gi |
redis-data |
Redis | Redis data directory | 5Gi |
No agent files volume in 2.0. In 1.4.x, agent binaries and SCAP compliance content were stored on a
agent-filesPVC. In 2.0 both are embedded in the server binary, so no application-specific volume is required. If your chart still declaresbackend.persistence, it can be removed.
Updating PatchMon
Using global.imageTag
helm upgrade patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
-n patchmon \
-f values-prod.yaml \
--set global.imageTag=2.0.1
When the new pod starts, golang-migrate applies any pending schema migrations automatically. No manual Job is required.
Pinning individual tags
server:
image:
tag: "2.0.0"
Upgrading the chart version
helm upgrade patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
--namespace patchmon \
--values values-prod.yaml \
--wait --timeout 10m
Check the chart releases page and the PatchMon releases page before upgrading.
Uninstalling
# Uninstall the release
helm uninstall patchmon -n patchmon
# Clean up PVCs (this deletes all data)
kubectl delete pvc -n patchmon -l app.kubernetes.io/instance=patchmon
Advanced Configuration
Custom image registry (air-gapped)
global:
imageRegistry: "registry.example.com"
This changes every image pull to use the specified registry:
registry.example.com/postgres:17-alpineregistry.example.com/redis:7-alpineregistry.example.com/patchmon/patchmon-server:2.0.0registry.example.com/guacamole/guacd:1.6.0(when RDP is enabled)
Horizontal Pod Autoscaling
server:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
Note: Scaling the server beyond a single replica is safe as of 2.0. There are no writeable local file volumes, and background jobs are coordinated through Redis + Asynq. WebSocket connections (agent WS, SSH terminal WS, live patch streams) do need sticky sessions through the Ingress controller if you run multiple replicas; set
nginx.ingress.kubernetes.io/affinity: cookiein your Ingress annotations.
Using an external database
Disable the built-in database and set DATABASE_URL to an external PostgreSQL instance:
database:
enabled: false
server:
env:
DATABASE_URL: "postgresql://patchmon:password@external-db.example.com:5432/patchmon"
OIDC / SSO integration
server:
env:
OIDC_ENABLED: "true"
OIDC_ISSUER_URL: "https://auth.example.com/realms/master"
OIDC_CLIENT_ID: "patchmon"
OIDC_REDIRECT_URI: "https://patchmon.example.com/api/v1/auth/oidc/callback"
OIDC_SCOPES: "openid profile email groups"
OIDC_BUTTON_TEXT: "Login with SSO"
OIDC_AUTO_CREATE_USERS: "true"
OIDC_SYNC_ROLES: "true"
OIDC_ADMIN_GROUP: "patchmon-admins"
The client secret should live in a Kubernetes Secret and be mounted as OIDC_CLIENT_SECRET, not set inline.
Troubleshooting
Check pod status
kubectl get pods -n patchmon
kubectl describe pod <pod-name> -n patchmon
kubectl logs <pod-name> -n patchmon
Check init container logs (waiting for DB or Redis)
kubectl logs <pod-name> -n patchmon -c wait-for-database
kubectl logs <pod-name> -n patchmon -c wait-for-redis
Check migration logs
Migrations run inside the server pod on startup. Follow the startup logs:
kubectl logs -n patchmon deploy/patchmon-server --since=5m | grep migrate
You should see lines like [migrate] running migrations from embedded binary and either [migrate] applied successfully (version N) or [migrate] already up to date.
Health check
The server exposes a liveness probe at /health:
kubectl exec -n patchmon -it deploy/patchmon-server -- wget -qO- http://localhost:3000/health
Response is healthy (plain text) or a JSON structure when the Accept: application/json header is set.
Common issues
| Symptom | Likely cause | Fix |
|---|---|---|
Pods stuck in Init state |
Database or Redis not yet running | kubectl describe sts -n patchmon |
PVC stuck in Pending |
No matching StorageClass | Run kubectl get sc and set global.storageClass |
ImagePullBackOff |
Registry credentials missing | Check imagePullSecrets and image path |
| Ingress returns 404 / 502 | Ingress misconfigured or points at wrong port | All traffic should go to server:3000 |
| WebSocket connections drop every ~30s | Ingress default read timeout too short | Set proxy-read-timeout: "86400" |
secret ... not found |
Required Secret not created before install | Create the Secret or set secret.create: true |
| CORS errors in browser | CORS_ORIGIN doesn't match the URL users see |
Set it to the exact URL from the Ingress host. If the chart exposes more than one Ingress host, comma-separate them with no spaces, e.g. https://patchmon.example.com,https://patchmon.internal.lan |
Support
- Chart issues: github.com/RuTHlessBEat200/PatchMon-helm/issues
- Application issues: github.com/PatchMon/PatchMon
- Community: Discord
See also
- Installing PatchMon Server on Docker: the officially supported deployment method
- Reverse proxy examples: Nginx, Caddy, Traefik snippets
- PatchMon Environment Variables Reference: every variable the server reads
- First-time admin setup: what to do once the pod is running