Skip to main content
PathMon

Chapter 2 of 15

Installing PatchMon on Kubernetes with Helm

Updated Read the full guide

Overview

The community Helm chart for PatchMon deploys the server on any Kubernetes 1.19+ cluster. It is maintained in a separate repository:

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.yaml ships 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_ORIGIN to 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:

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.* vs backend.*) depend on whether the chart has been updated for 2.0. If your chart still splits backend and frontend, set frontend.enabled: false and 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 single server.* block. Always diff against the chart's values.yaml before 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. guacd support was added after the original 1.4.x chart; if your chart version does not include a guacd.enabled option, you can deploy it as a separate Deployment and Service in the same namespace and set GUACD_ADDRESS to 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-files PVC. In 2.0 both are embedded in the server binary, so no application-specific volume is required. If your chart still declares backend.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-alpine
  • registry.example.com/redis:7-alpine
  • registry.example.com/patchmon/patchmon-server:2.0.0
  • registry.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: cookie in 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


See also