Table of Contents
- patchmon
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- A wrong ingress.domain keeps the pod out of Ready forever
- ingress.domain is required even without an Ingress
- Sign-in is OIDC-only, and there is no local account
- One replica only
- Give the first start time
- Rotating a secret does not restart the pod
- The guacd sidecar
- The egress ports are the standard ones, not your configured ones
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
patchmon
Deploys PatchMon, a patch-status monitor for Linux and Windows hosts, as a single stateless Deployment. The 2.x server serves the frontend and /api/* from one process; frontend assets, agent binaries and SCAP content are baked into the image, so there is no PVC. Postgres and Redis are not part of this chart, and sign-in is OIDC-only.
| Image | ghcr.io/patchmon/patchmon-server |
| Versions | not listed on purpose — read the current pair off patchmon/Chart.yaml, see Chart index |
| Workloads | one Deployment (1 replica), optional guacd sidecar in the same pod |
| Storage | none — the image is stateless; /tmp is an emptyDir |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled); ingress.domain survives both gates — see Routing: Ingress or Gateway API |
| External dependencies | Postgres, Redis, an OIDC provider |
| Secrets | 4 × OnePasswordItem |
| Install-test | ci/patchmon/ (fixtures with postgres, redis and a dex OIDC provider) |
Prerequisites
- 1Password operator in the cluster, plus four items — see the fields below.
- Postgres and Redis reachable before the first start. The server waits for the database and runs migrations at boot.
- An OIDC provider. There is no local account to fall back on — see Sign-in is OIDC-only.
- cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. - The hostname decided up front. A wrong
ingress.domainkeeps the pod out of Ready forever — see below.
Installation
The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update
helm install patchmon jlab/patchmon \
--namespace patchmon --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
postgresSecretRef: op://vault/patchmon-postgres
redisSecretRef: op://vault/patchmon-redis
patchmonSecretRef: op://vault/patchmon
oidcSecretRef: op://vault/patchmon-oidc
ingress:
domain: patchmon.example.com
clusterIssuer: letsencrypt
The same installation in Gateway mode instead — the Gateway, its class and its certificate belong to the cluster, not to this release:
ingress:
enabled: false
domain: <the public hostname> # the HTTPRoute's hostname, see below
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; ingress.clusterIssuer is not used. ingress.domain is still required — it builds CORS_ORIGIN and the probes' Host header. See Routing: Ingress or Gateway API.
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
What the 1Password items must contain
The chart renders OnePasswordItem CRs that carry an item path only; the operator turns each into a Kubernetes Secret whose keys are the field names of the item. Which fields each item needs is decided in the Deployment. A missing field does not fail the install and does not even stop the container: the variable arrives empty and the server misbehaves later.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.postgresSecretRef |
<release>-patchmon-postgres-secret |
database-url — e.g. postgresql://user:password@host:5432/patchmon |
secrets.redisSecretRef |
<release>-patchmon-redis-secret |
host, port, username, password, db |
secrets.patchmonSecretRef |
<release>-patchmon-patchmon-secret |
jwt-secret, encryption-key (AI_ENCRYPTION_KEY), session-secret |
secrets.oidcSecretRef |
<release>-patchmon-oidc-secret |
issuer-url, client-id, client-secret, redirect-uri, requested-scopes, button-text, user-group-name, admin-group-name |
Watch them materialize:
kubectl -n <namespace> get onepassworditem
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the Deployment with replicas: 0. Takes precedence over patchmon.server.replicas. Secrets, Service and Ingress stay; the agents on your hosts keep retrying, so nothing is lost. |
global |
object | {} |
Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.postgresSecretRef |
string | null |
Required. op:// path of the Postgres item. |
secrets.redisSecretRef |
string | null |
Required. op:// path of the Redis item. |
secrets.patchmonSecretRef |
string | null |
Required. op:// path of the app-secrets item. |
secrets.oidcSecretRef |
string | null |
Required. op:// path of the OIDC item — the chart hardcodes OIDC_ENABLED=true, so this one is not optional. |
patchmon.server
| Key | Type | Default | Description |
|---|---|---|---|
patchmon.server.replicas |
int | 1 |
Replica count; 0 is allowed. Raising it does not scale the server out — see One replica only. |
patchmon.server.securityContext.pod |
object | non-root 65532/65532, seccompProfile: RuntimeDefault |
Passed verbatim into the pod spec. The seccomp profile sits here on purpose: from the pod context it covers every container — the server and the guacd sidecar alike — including any init container added later. |
patchmon.server.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root |
The image is a hardened Alpine build running as uid 65532; only /tmp (emptyDir) is writable. |
patchmon.server.livenessProbe |
object | GET /health:3000 with Host: {{ .Values.ingress.domain }}, period 10s, timeout 5s, 3 failures |
The Host header is load-bearing — see below. Probes are rendered through tpl, so the header follows ingress.domain. |
patchmon.server.readinessProbe |
object | same, period 10s, timeout 5s, 3 failures | |
patchmon.server.startupProbe |
object | same, period 10s, 60 failures (= 600s) | Gates liveness and readiness. The first 2.x boot rebuilds database indexes, which takes minutes on slow storage. |
patchmon.server.env |
list | [] |
Extra environment variables appended to the chart's own. Rendered through tpl; value, secretKeyRef, configMapKeyRef. Additive, not overriding — see Environment variables. |
patchmon.server.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
patchmon.server.resources.requests |
object | cpu: 0.1, memory: 256Mi, ephemeral-storage: 10Mi |
|
patchmon.server.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
patchmon.guacd
| Key | Type | Default | Description |
|---|---|---|---|
patchmon.guacd.enabled |
bool | false |
Runs the Apache Guacamole daemon as a sidecar and points the server at it via GUACD_ADDRESS=127.0.0.1:4822. In-browser RDP for Windows hosts, which upstream still labels beta. |
patchmon.guacd.image |
string | docker.io/guacamole/guacd:1.6.0 |
Full image reference including the tag (not a repository/tag pair). An independent upstream, so it does not follow appVersion; reviewed on every app-update round. |
patchmon.guacd.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root 1000/1000 |
No pod key exists here — a sidecar shares the server's pod context. |
patchmon.guacd.livenessProbe |
object | TCP 4822, period 10s, timeout 5s, 3 failures |
|
patchmon.guacd.readinessProbe |
object | TCP 4822, period 10s, timeout 5s, 3 failures |
|
patchmon.guacd.startupProbe |
object | TCP 4822, period 5s, 30 failures (= 150s) |
|
patchmon.guacd.env |
list | [] |
Extra environment variables for the sidecar (e.g. GUACD_LOG_LEVEL); the env block is only rendered when the list is non-empty. |
patchmon.guacd.resources.limitFactor |
number | 3 |
|
patchmon.guacd.resources.requests |
object | cpu: 0.05, memory: 64Mi, ephemeral-storage: 10Mi |
|
patchmon.guacd.resources.limits |
object | unset |
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
false omits the Ingress object and frees clusterIssuer — but not domain. Mutually exclusive with httpRoute.enabled. |
ingress.domain |
string | null |
Required, always — also with ingress.enabled: false. It is the Ingress host, the server's CORS_ORIGIN (https://<domain>), and the Host header of all three probes. |
ingress.clusterIssuer |
string | null |
Required while the Ingress is enabled. cert-manager ClusterIssuer. |
ingress.className |
string | nginx |
Ingress class. |
ingress.annotations |
object | {} |
Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode — but nothing this chart needs lives here: the server's WebSocket upgrade works on a plain route and TRUST_PROXY is an environment variable. |
The Ingress carries a single / prefix path: the 2.x server serves the frontend and /api/* from one process, WebSockets included. TLS is stored in tls-<release>-patchmon-ingress.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 1 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).
| Key | Type | Default | Description |
|---|---|---|---|
httpRoute.enabled |
bool | false |
Renders <release>-patchmon-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values. |
httpRoute.parentRefs |
list | [] |
The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases. |
httpRoute.hostnames |
list | [] |
Empty means [ingress.domain], so the hostname has a single source of truth. Set it explicitly only to publish under a different or an additional name — which is how a migration off the Ingress runs. |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-patchmon-service on port 80. One path covers everything, for the same reason the Ingress needs only one.
networkPolicy
Inbound 3000 only. Outbound DNS, TCP 5432 (Postgres), 6379 (Redis) and 443 (the OIDC provider).
| Value | Type | Default | Meaning |
|---|---|---|---|
networkPolicy.enabled |
bool | true |
Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. |
networkPolicy.allowedNamespaces |
list | [] |
Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it. |
networkPolicy.probeSources |
list | [] |
CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready. |
networkPolicy.ingressController |
object | kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed. |
networkPolicy.egress.extraPorts |
list | [] |
Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. |
Operating notes
Routing: Ingress or Gateway API
Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.
| Rule | Why it is a hard failure and not a default |
|---|---|
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths |
two data paths onto one Service under one hostname is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname (httpRoute.hostnames set explicitly while the Ingress keeps ingress.domain), never in parallel. |
httpRoute.parentRefs must not be empty — the render aborts |
a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer. |
| the mode is a value, never auto-detected | .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, which live in cluster-config. ingress.clusterIssuer is unused and unnecessary in this mode.
ingress.domain is required in this mode too, and not because of the route. It builds CORS_ORIGIN (with its own required()) and the explicit Host header all three probes send. The consequence is the one this page already describes for the Ingress and it is unchanged by a Gateway in front: the 2.x server answers 403 to any request whose Host is neither loopback nor a CORS_ORIGIN host, so a wrong value keeps the pod out of Ready forever. If you set httpRoute.hostnames to a name ingress.domain does not carry, the server rejects that name — set both together. TRUST_PROXY stays right either way; a Gateway is a reverse proxy too.
Whether the route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:
kubectl -n <namespace> get httproute <release>-patchmon-httproute -o yaml
Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.
A wrong ingress.domain keeps the pod out of Ready forever
The 2.x server answers 403 to any request whose Host header is neither loopback nor a CORS_ORIGIN host, and CORS_ORIGIN is built from ingress.domain. Kubelet probes address the pod IP, so they must send the public hostname explicitly — which is why all three probe defaults carry an httpHeaders entry rendered through tpl from ingress.domain.
If that value is not the hostname the server is actually reached under, the probes get 403, the pod never turns Ready, and nothing in the log looks like a configuration error — just repeated 403s. This is the case §12.5 is written from: a probe default carries the probe's context, not just its timings, and where the context is dynamic the fallback helper takes the root context in and rebuilds the header.
The same applies when you address the server yourself:
kubectl -n <namespace> port-forward svc/<release>-patchmon-service 3000:80
curl -H "Host: <ingress.domain>" http://localhost:3000/health
ingress.domain is required even without an Ingress
ingress.enabled: false removes the Ingress object and makes clusterIssuer unnecessary. It does not free ingress.domain: the Deployment reads it for CORS_ORIGIN behind its own required(), and the probe defaults read it through tpl. This is exactly the "a value read outside its obvious template needs its own guard" case from §12.1.
The comment above
ingress.enabledinvalues.yamland theenableddescription invalues.schema.jsonboth claim the domain is only read inside the Ingress template. That is stale — the templates andNOTES.txtare right, those two comments are wrong.
Sign-in is OIDC-only, and there is no local account
The chart hardcodes OIDC_ENABLED=true and OIDC_DISABLE_LOCAL_AUTH=true: the login form is gone and there is no built-in admin. If the OIDC item is wrong, nobody gets in — including you.
Users are created on first login (OIDC_AUTO_CREATE_USERS=true) with the readonly role; admin rights follow the group named in the item's admin-group-name field, and roles are synchronized on each login (OIDC_SYNC_ROLES=true). The item's redirect-uri must point back at this installation under https://<ingress.domain> and match what is registered at the identity provider exactly.
The 2.x role vocabulary is superadmin, admin, host_manager, readonly, user. The 1.x viewer role this chart used to default to no longer exists; readonly is its least-privilege equivalent.
One replica only
The server registers itself in the Redis presence registry under its pod hostname, which the chart pins to <release>-patchmon-server — a Deployment pod's default hostname changes on every rollout and would leave stale presence records behind (upstream pins the same name in its compose file). A second replica would register the same identity, so this chart supports exactly one. Raising patchmon.server.replicas does not scale it out.
Give the first start time
The server waits for the database and runs migrations at boot, and the first 2.x start rebuilds the database indexes — minutes on slow storage. The startup probe grants 600s before the kubelet starts killing the container, so a slow first boot is expected, not a hang:
kubectl -n <namespace> logs deploy/<release>-patchmon-deployment -f
Rotating a secret does not restart the pod
Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9). This chart sets neither a checksum/secret-ref annotation nor the operator's operator.1password.io/auto-restart — which, unlike the StatefulSet charts here, it could: the annotation works for Deployments. As it stands, pointing a *SecretRef at a different item rolls the pods; editing a value inside the same item does not:
kubectl -n <namespace> rollout restart deployment/<release>-patchmon-deployment
Rotating jwt-secret or session-secret invalidates existing sessions. Rotating encryption-key without keeping the old value makes data encrypted under it unreadable.
The guacd sidecar
With patchmon.guacd.enabled: true the daemon runs beside the server and is reached at 127.0.0.1:4822; it listens on the pod only and nothing exposes 4822 outside. The image is pinned separately from appVersion and needs its own review at upgrade time — see Renovate, which tracks it through the built-in helm-values manager.
The egress ports are the standard ones, not your configured ones
A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination.
Worse, the chart does not even know the port: the connection details arrive inside 1Password items it never parses. So the rules name the conventional ports, and a dependency listening anywhere else is blocked. Put the real port in networkPolicy.egress.extraPorts.
All three of this chart's dependencies arrive that way, so all three ports are conventions rather than configuration.
The probed port is open inside the cluster by default
networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.
It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.
Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.
The policy is only a boundary where the CNI enforces one
The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.
Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.
Upgrades and backup
- Back up the database before every app-version bump. Migrations run at boot and are one-way; a
helm rollbackreturns the old image to a database the new one has already rewritten. The chart itself holds no state at all, so the database plus the four 1Password items are the backup. - Raise
patchmon.server.startupProbe.failureThresholdbefore a large jump, not after the crash loop: a restart lands in the middle of an index rebuild. - App major jumps go stepwise: last release of the old major line first, then the newest major (Versioning). The 1.x → 2.x jump is what turned this chart from two Deployments (frontend + backend) into the single one documented here.
- Review the
guacdpin in the same round.
CI
ci/patchmon/ holds test-values.yaml (op:// refs against the stub CRD, patchmon.ci.local) and fixtures.yaml (throwaway Postgres and Redis, a dex OIDC provider with a static client, and the four dummy secrets). Because the 2.x server is a single stateless container, no PV, StorageClass or service-alias fixtures are needed and the chart takes the default install timeout. The install-test runs on every PR that touches the chart — see CI/CD.
The install-test stays in Ingress mode; the route is held at render level. The install-test installs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. cyberchef is the chart that pays that price and installs in Gateway mode against the real Gateway-API CRDs (ci/gateway-api-crds.sh, pinned, applied --server-side), which is where a malformed parentRefs or backendRefs is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead ci/httproute/patchmon.yaml is a render-only overlay that switches this chart into Gateway mode for ci/run-httproute-match-test.sh, which asserts that every backendRefs entry names a Service and a port the same render creates and that parentRefs is not empty. A chart that has an overlay and renders no route fails that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI. See CI/CD.
See also
- Chart style guide — §12.5 is written from this chart's probes
- Versioning — what a bump of
7.0.1+up2.1.3means - Renovate —
appVersionand the guacd pin - Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline