Installing PatchMon Server on K8S with Helm
PatchMon Helm Chart Documentation
Helm chart for deploying PatchMon on Kubernetes.
- Chart repository: github.com/RuTHlessBEat200/PatchMon-helm
- Application repository: github.com/PatchMon/PatchMon
Overview
PatchMon v2.0.0 runs as a containerised application made up of four services:
- Database -- PostgreSQL 18 (StatefulSet with persistent storage)
- Redis -- Redis 8 (used for BullMQ job queues and caching, StatefulSet with persistent storage)
BackendServer --Node.jsSingle Go binary serving both the backend APIserverand the React frontend static files (DeploymentStatefulSet with optional HPA)FrontendGuacd --ReactApacheapplicationGuacamoleservedproxyviadaemonNGINXfor remote desktop / SSH terminal access (Deployment with optional HPA)Deployment)
Note: In v2.0.0 the separate backend and frontend containers have been merged into a single
serverbinary. If you are upgrading from v1.x, update your values files accordingly —backend.*andfrontend.*keys no longer exist.
The chart deploys all four components into a single namespace and wires them together automatically using init containers, internal ClusterIP services, and a shared ConfigMap.
Container Images
| Component | Image | Default Tag |
|---|---|---|
| ghcr.io/patchmon/patchmon- |
| |
|
||
| Database | docker.io/postgres | 18-alpine |
| Redis | docker.io/redis | 8-alpine |
| Guacd | docker.io/guacamole/guacd | latest |
Available Tags (Backend and Frontend)Server)
Both backend and frontend images share the same version tags.
| Tag | Description |
|---|---|
latest |
Latest stable release |
x.y.z |
Exact version pin (e.g. ) |
x.y |
Latest patch in a minor series (e.g. ) |
x |
Latest minor and patch in a major series (e.g. ) |
edge |
Latest development build from the main branch -- may be unstable, for testing only |
Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- A PersistentVolume provisioner in the cluster (for
database, Redis,database andbackend agent-fileRedis storage) - (Optional) An Ingress controller (e.g. NGINX Ingress) for external access
- (Optional) cert-manager for automatic TLS certificate management
- (Optional) Metrics Server for HPA functionality
Quick Start
The quickest way to get PatchMon running is to use the provided
values-quick-start.yaml file.
It contains all required secrets inline and sensible defaults so you can install
with a single command.
Warning:
values-quick-start.yamlships with placeholder secrets and is intended for evaluation and testing only. Never use it in production without replacing all secret values.
1. Install the chart
wget https://github.com/RuTHlessBEat200/PatchMon-helm/blob/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
3. Access PatchMon
If ingress is enabled, open the host you configured (e.g. https://patchmon-dev.example.com).
Without ingress, use port-forwarding:
kubectl port-forward -n patchmon svc/patchmon-dev-frontendserver 3000:3000
Production Deployment
For production use, refer to the provided
values-prod.yaml file as a starting point.
It demonstrates how to:
- Use an external secret (e.g. managed by KSOPS, Sealed Secrets, or External Secrets Operator) instead of inline passwords
- Configure HTTPS with cert-manager
- Set the correct server protocol, host, and port for agent communication
1. Create your secrets
The chart does not auto-generate secrets. You must supply them yourself.
Required secrets:
| Key | Description |
|---|---|
postgres-password |
PostgreSQL password |
redis-password |
Redis password |
jwt-secret |
JWT signing secret for the |
ai-encryption-key |
Encryption key for AI provider credentials |
oidc-client-secret |
OIDC client secret (only if OIDC is enabled) |
You can either:
- Set passwords directly in your values file (
database.auth.password,redis.auth.password,,backend.server.jwtSecret), orbackend.server.aiEncryptionKey - Create a Kubernetes Secret separately and reference it with
existingSecret/existingSecretPasswordKeyfields.
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)"
Secret management tools for production:
- KSOPS -- encrypt secrets in Git using Mozilla SOPS
- Sealed Secrets -- encrypt secrets that only the cluster can decrypt
- External Secrets Operator -- sync secrets from external stores (Vault, AWS Secrets Manager, etc.)
- Vault -- enterprise-grade secret management
2. Create your values file
Start from values-prod.yaml and adjust to your environment:
global:
storageClass: "your-storage-class"
fullnameOverride: "patchmon-prod"
backend:server:
env:
serverProtocol: https
serverHost: patchmon.example.com
serverPort: "443"
corsOrigin: https://patchmon.example.com
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 # Disable chart-managed secret since we use an external one
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/client-body-buffer-size: "4m"
nginx.ingress.kubernetes.io/websocket-services: "server"
hosts:
- host: patchmon.example.com
paths:
- path: /
pathType: Prefix
service:
name: frontendserver
port: 3000
- path: /api
pathType: Prefix
service:
name: backend
port: 3001
tls:
- secretName: patchmon-tls
hosts:
- patchmon.example.com
3. Install
helm install patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
--namespace patchmon \
--create-namespace \
--values values-prod.yaml
Configuration Reference
Global Settings
| Parameter | Description | Default |
|---|---|---|
global.imageRegistry |
Override the image registry for all components | "" |
global.imageTag |
Override the image tag for server.image.tag |
"" |
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 | 18-alpine |
database.image.pullPolicy |
Image pull policy | IfNotPresent |
database.host |
External database host (overrides built-in service discovery when set) | "" |
database.port |
External database port | "" |
database.auth.database |
Database name | patchmon_db |
database.auth.username |
Database user | patchmon_user |
database.auth.password |
Database password (required if existingSecret is not set) |
"" |
database.auth.existingSecret |
Name of an existing secret containing the password | "" |
database.auth.existingSecretPasswordKey |
Key inside the existing secret | postgres-password |
database.replicaCount |
Number of replicas | 1 |
database.updateStrategy.type |
StatefulSet update strategy | RollingUpdate |
database.persistence.enabled |
Enable persistent storage | true |
database.persistence.storageClass |
Storage class (falls back to global.storageClass) |
"" |
database.persistence.accessModes |
PVC access modes | ["ReadWriteOnce"] |
database.persistence.size |
PVC size | 5Gi |
database.resources.requests.cpu |
CPU request | 100m |
database.resources.requests.memory |
Memory request | 128Mi |
database.resources.limits.cpu |
CPU limit | 1000m |
database.resources.limits.memory |
Memory limit | 1Gi |
database.livenessProbe.enabled |
Enable liveness probe | true |
database.readinessProbe.enabled |
Enable readiness probe | true |
database.service.type |
Service type | ClusterIP |
database.service.port |
Service port | 5432 |
database.podAnnotations |
Pod annotations | {} |
database.podSecurityContext |
Pod-level security context | see values.yaml |
database.securityContext |
Container-level security context | see values.yaml |
database.nodeSelector |
Node selector | {} |
database.tolerations |
Tolerations | [] |
database.affinity |
Affinity rules | {} |
Redis
| Parameter | Description | Default |
|---|---|---|
redis.enabled |
Deploy the Redis StatefulSet | true |
redis.image.registry |
Image registry | docker.io |
redis.image.repository |
Image repository | redis |
redis.image.tag |
Image tag | 8-alpine |
redis.image.pullPolicy |
Image pull policy | IfNotPresent |
redis.auth.password |
Redis password (required if existingSecret is not set) |
"" |
redis.auth.existingSecret |
Name of an existing secret containing the password | "" |
redis.auth.existingSecretPasswordKey |
Key inside the existing secret | redis-password |
redis.replicaCount |
Number of replicas | 1 |
redis.updateStrategy.type |
StatefulSet update strategy | RollingUpdate |
redis.persistence.enabled |
Enable persistent storage | true |
redis.persistence.storageClass |
Storage class (falls back to global.storageClass) |
"" |
redis.persistence.accessModes |
PVC access modes | ["ReadWriteOnce"] |
redis.persistence.size |
PVC size | 5Gi |
redis.resources.requests.cpu |
CPU request | 50m |
redis.resources.requests.memory |
Memory request | 10Mi |
redis.resources.limits.cpu |
CPU limit | 500m |
redis.resources.limits.memory |
Memory limit | 512Mi |
redis.livenessProbe.enabled |
Enable liveness probe | true |
redis.readinessProbe.enabled |
Enable readiness probe | true |
redis.service.type |
Service type | ClusterIP |
redis.service.port |
Service port | 6379 |
redis.podAnnotations |
Pod annotations | {} |
redis.podSecurityContext |
Pod-level security context | see values.yaml |
redis.securityContext |
Container-level security context | see values.yaml |
redis.nodeSelector |
Node selector | {} |
redis.tolerations |
Tolerations | [] |
redis.affinity |
Affinity rules | {} |
BackendServer
The server component is a single Go binary that serves both the backend API and the React frontend on port 3000. It is deployed as a StatefulSet.
| Parameter | Description | Default |
|---|---|---|
|
Deploy the |
true |
|
Image registry | ghcr.io |
|
Image repository | patchmon/patchmon- |
|
Image tag (overridden by global.imageTag if set) |
|
|
Image pull policy | |
|
Number of replicas |
1 |
|
|
|
|
JWT signing secret (required if existingSecret is not set) |
"" |
|
AI encryption key (required if existingSecret is not set) |
"" |
|
Name of an existing secret for JWT and AI encryption key | "" |
|
Key for JWT secret inside the existing secret | jwt-secret |
|
Key for AI encryption key inside the existing secret | ai-encryption-key |
| | |
| | |
| | |
| | |
|
CPU request | 10m |
|
Memory request | 256Mi |
|
CPU limit | 2000m |
|
Memory limit | 2Gi |
| | |
| | |
|
Enable HPA |
false |
|
Minimum replicas | 1 |
|
Maximum replicas | 10 |
|
Target CPU utilisation | 80 |
|
Target memory utilisation | 80 |
|
Service type | ClusterIP |
server.service.port |
Service port | 3000 |
server.service.annotations |
Service annotations | [] |
server.livenessProbe.enabled |
Enable liveness probe (TCP on port 3000) | true |
server.readinessProbe.enabled |
Enable readiness probe (GET /health on port 3000) |
true |
server.initContainers.waitForDatabase.enabled |
Wait for database before starting | true |
|
Wait for Redis before starting | true |
|
|
|
|
Extra environment variables to inject into the server container | [] |
server.extraVolumeMounts |
Extra volume mounts for the server container | [] |
server.extraVolumes |
Extra volumes to add to the server pod | [] |
server.podAnnotations |
Pod annotations | {} |
server.podSecurityContext |
Pod-level security context | see values.yaml |
server.securityContext |
Container-level security context | see values.yaml |
server.nodeSelector |
Node selector | {} |
|
Tolerations | [] |
|
Affinity rules | {} |
server.topologySpreadConstraints |
Topology spread constraints | [] |
BackendServer Environment Variables
| Parameter | Description | Default |
|---|---|---|
|
Enable application logging | true |
|
Log level (trace, debug, info, warn, error) |
info |
|
Log to stdout | true |
|
Protocol used by agents to reach the http or https) |
http |
|
Hostname used by agents to reach the |
patchmon.example.com |
|
Port used by agents (80 or 443) |
80 |
|
CORS allowed origin (should match the URL users access in a browser) | http://patchmon.example.com |
|
Database connection pool limit | 30 |
|
Pool timeout in seconds | 20 |
|
Connection timeout in seconds | 10 |
|
Idle connection timeout in seconds | 300 |
|
Max connection lifetime in seconds | 1800 |
|
General rate limit window (ms) | 900000 |
|
General rate limit max requests | 5000 |
|
Auth rate limit window (ms) | 600000 |
|
Auth rate limit max requests | 500 |
|
Agent rate limit window (ms) | 60000 |
|
Agent rate limit max requests | 1000 |
|
Redis database index | 0 |
|
Trust proxy headers true |
|
|
Enable HSTS header | false |
|
Default role for new users | user |
|
Auto-create role permissions | false |
OIDC / SSO Configuration
| Parameter | Description | Default |
|---|---|---|
|
Enable OIDC authentication | false |
|
OIDC issuer URL | "" |
|
OIDC client ID | "" |
|
OIDC client secret (required if existingSecret not set) |
"" |
|
Existing secret containing the OIDC client secret | "" |
|
Key inside the existing secret | oidc-client-secret |
|
OIDC scopes | openid profile email |
|
Login button text | Login with SSO |
|
Auto-create users on first OIDC login | |
|
Default role for OIDC-created users | user |
|
Sync roles from OIDC group claims | |
|
Disable local username/password authentication | false |
|
OIDC session TTL in seconds | 86400 |
|
OIDC group mapped to the superadmin role | "" |
|
OIDC group mapped to the admin role | "" |
|
OIDC group mapped to the hostManager role | "" |
|
OIDC group mapped to the user role | "" |
|
OIDC group mapped to the readonly role | "" |
FrontendGuacd
Apache Guacamole proxy daemon used for browser-based SSH and remote desktop sessions.
| Parameter | Description | Default |
|---|---|---|
|
Deploy |
true |
|
Image registry | |
|
Image repository | |
|
Image tag |
|
|
Image pull policy | IfNotPresent |
|
Number of replicas | 1 |
|
Deployment update strategy | |
|
CPU request | 10m |
|
Memory request | |
|
CPU limit | 1000m |
|
Memory limit | 512Mi |
|
Enable liveness probe (TCP) | true |
guacd.readinessProbe.enabled |
Enable readiness probe (TCP) | true |
guacd.service.type |
Service type | ClusterIP |
|
Service port | |
|
|
|
|
see |
|
|
see |
|
| | |
| | |
| | |
|
Node selector | {} |
|
Tolerations | [] |
|
Affinity rules | {} |
Ingress
| Parameter | Description | Default |
|---|---|---|
ingress.enabled |
Enable ingress resource | true |
ingress.className |
Ingress class name | "" |
ingress.annotations |
Ingress annotations | see |
ingress.hosts |
List of ingress host rules | see values.yaml |
ingress.tls |
TLS configuration | [] (disabled) |
The default ingress annotations enable WebSocket support and tune proxy timeouts for agent connections:
ingress:
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/client-body-buffer-size: "4m"
nginx.ingress.kubernetes.io/websocket-services: "server"
Other
| Parameter | Description | Default |
|---|---|---|
serviceAccount.create |
Create a ServiceAccount | false |
serviceAccount.annotations |
ServiceAccount annotations | {} |
serviceAccount.name |
ServiceAccount name | "" |
configMap.create |
Create the application ConfigMap | true |
configMap.annotations |
ConfigMap annotations | {} |
secret.create |
Create the chart-managed Secret (disable when using an external secret) | true |
secret.annotations |
Secret annotations | {} |
Persistent Volumes
The chart creates the following PersistentVolumeClaims:
| PVC | Component | Purpose | Default Size |
|---|---|---|---|
postgres-data |
Database | PostgreSQL data directory | 5Gi |
redis-data |
Redis | Redis data directory | 5Gi |
| |
All PVCs respect the global.storageClass setting unless overridden at the component level.
Note:The backend container runs as UID/GID 1000. If you usehostPathvolumes or a storage provider that does not respectfsGroup, you may need to enablebackend.initContainers.fixPermissions.enabled(requires privileged init containers).
Updating PatchMon
Using global.imageTag
The simplest way to update boththe backendserver and frontend at onceimage is to set global.imageTag:
helm upgrade patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
-n patchmon \
-f values-prod.yaml \
--set global.imageTag=2.1.5.0
When global.imageTag is set it overrides both backend.image.tag and .frontend.server.image.tag
Pinning individualthe tags
You can also set eachserver tag independently:
backend:server:
image:
tag: "1.4.2"
frontend:
image:
tag: "1.4.2"2.0.0"
Upgrading the chart version
# Upgrade with new values
helm upgrade patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
--namespace patchmon \
--values values-prod.yaml
# Upgrade and wait for rollout
helm upgrade patchmon oci://ghcr.io/ruthlessbeat200/charts/patchmon \
--namespace patchmon \
--values values-prod.yaml \
--wait --timeout 10m
Check the releases page for version-specific changes and migration notes.
Uninstalling
# Uninstall the release
helm uninstall patchmon -n patchmon
# Clean up PVCs (optional -- this deletes all data)
kubectl delete pvc -n patchmon -l app.kubernetes.io/instance=patchmon
Advanced Configuration
Custom Image Registry
Override the registry for all images (useful for air-gapped environments or private mirrors):
global:
imageRegistry: "registry.example.com"
This changes every image pull to use the specified registry:
registry.example.com/postgres:18-alpineregistry.example.com/redis:8-alpineregistry.example.com/patchmon/patchmon-backend:1.4.2guacamole/guacd:latestregistry.example.com/patchmon/patchmon-frontend:1.4.2server:2.0.0registry.example.com/busybox:latest(init containers)
Without global.imageRegistry, components use their default registries (docker.io for database/Redis,Redis/guacd, ghcr.io for backend/frontend)server).
Multi-Tenant Deployment
Deploy multiple isolated instances in separate namespaces using fullnameOverride:
fullnameOverride: "patchmon-tenant-a"
backend:server:
env:
serverHost: tenant-a.patchmon.example.com
corsOrigin: https://tenant-a.patchmon.example.com
ingress:
hosts:
- host: tenant-a.patchmon.example.com
paths:
- path: /
pathType: Prefix
service:
name: frontendserver
port: 3000
- path: /api
pathType: Prefix
service:
name: backend
port: 3001
Horizontal Pod Autoscaling
backend:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 70
persistence:
accessModes:
- ReadWriteMany # RWX required when running multiple replicas
frontend:server:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
Note:Scaling the backend beyond one replica requires a storage class that supportsReadWriteMany(RWX) access mode, because all replicas need write access to agent files.
Using an External Database
Disable the built-in database and point the backendserver at an external PostgreSQL instance:
database:
enabled: false
backend:host: env:"my-postgres.example.com"
#port: Configure5432
theauth:
externaldatabase: databasepatchmon_db
connectionusername: viapatchmon_user
existingSecret: patchmon-secrets
existingSecretPasswordKey: postgres-password
Injecting Extra Environment Variables
Use server.extraEnv to pass additional environment variablesvariables, #for orexample adjustto yourtrust externala DBcustom settingsCA accordinglyfor OIDC:
server:
extraEnv:
- name: NODE_EXTRA_CA_CERTS
value: /etc/ssl/certs/my-ca.crt
extraVolumeMounts:
- name: my-ca
mountPath: /etc/ssl/certs/my-ca.crt
subPath: ca.crt
readOnly: true
extraVolumes:
- name: my-ca
configMap:
name: my-ca-configmap
OIDC / SSO Integration
backend:server:
oidc:
enabled: true
issuerUrl: "https://auth.example.com/realms/master"
clientId: "patchmon"
clientSecret: "your-client-secret"
scopes: "openid profile email groups"
buttonText: "Login with SSO"
autoCreateUsers: true
syncRoles: true
groups:
superadmin: "patchmon-admins"
admin: ""
hostManager: ""
user: ""
readonly: ""
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
kubectl logs <pod-name> -n patchmon -c wait-for-database
kubectl logs <pod-name> -n patchmon -c wait-for-redis
kubectl logs <pod-name> -n patchmon -c wait-for-backendguacd
Check service connectivity
# Test database connection
kubectl exec -n patchmon -it deployment/statefulset/patchmon-prod-backendserver -- nc -zv patchmon-prod-database 5432
# Test Redis connection
kubectl exec -n patchmon -it deployment/statefulset/patchmon-prod-backendserver -- nc -zv patchmon-prod-redis 6379
# Test guacd connection
kubectl exec -n patchmon -it statefulset/patchmon-prod-server -- nc -zv patchmon-prod-guacd 4822
# Check backendserver health
kubectl exec -n patchmon -it deployment/statefulset/patchmon-prod-backendserver -- wget -qO- http://localhost:3001/3000/health
Common issues
| Symptom | Likely cause | Fix |
|---|---|---|
Pods stuck in Init state |
Check kubectl describe sts -n patchmon |
|
PVC stuck in Pending |
No matching StorageClass or no available PV | Verify storage class exists: kubectl get sc |
ImagePullBackOff |
Registry credentials missing or incorrect image reference | Check imagePullSecrets and image path |
| Ingress returns 404 / 502 | Ingress controller not installed or misconfigured path rules | Verify controller pods and ingress resource: kubectl describe ingress -n patchmon |
| WebSocket disconnects | Missing WebSocket annotations on ingress | Ensure nginx.ingress.kubernetes.io/websocket-services: "server" and proxy timeout annotations are set |
secret ... not found |
Required secret was not created before install | Create the secret or set secret.create: true with inline passwords |
Development
Lint the chart
helm lint .
Render templates locally
# Render with default values
helm template patchmon . --values values-quick-start.yaml
# Render with production values
helm template patchmon . --values values-prod.yaml
# Debug template rendering
helm template patchmon . --values values-quick-start.yaml --debug
Dry-run installation
helm install patchmon . \
--namespace patchmon \
--dry-run --debug \
--values values-quick-start.yaml
Support
- GitHub Issues: github.com/RuTHlessBEat200/PatchMon-helm/issues
- Application repository: github.com/PatchMon/PatchMon