The shared Helm library chart behind the QuenchWorks catalog. It's the one place the security baseline is defined, so every app chart inherits the exact same hardening: identical labels, identical pod and container security contexts, and a digest-only image resolver that makes shipping an unpinned image impossible.
Harden it once here, and every chart in the catalog moves together.
Published as an OCI artifact and consumed by the charts in quenchworks/charts:
oci://ghcr.io/quenchworks/charts/quench-common
# Chart.yaml
dependencies:
- name: quench-common
version: 0.0.7
repository: oci://ghcr.io/quenchworks/charts- Naming and labels:
quench-common.fullname/name/labels/selectorLabels, consistent across the whole catalog. - The digest-only image resolver:
quench-common.imageresolves an image strictly byrepository@sha256:digest. A tag-only reference is refused on purpose, so a chart can never ship an unpinned image. - Hardened pod security context:
quench-common.podSecurityContextsetsrunAsNonRoot, uid/gid/fsGroup 1001, seccompRuntimeDefault. - Hardened container security context:
quench-common.containerSecurityContextsets a read-only root filesystem, no privilege escalation, drop ALL capabilities. - A shared knob surface: the override points every chart exposes the same way, including scheduling, probes, extra env/volumes/volumeMounts, init containers, sidecars, lifecycle hooks, and security-context overrides.
Five families of manifest that were near-identical in every chart now render from here. A chart adopts one with a single line, e.g. templates/rbac.yaml containing {{- include "quench-common.rbac" . }}.
| helper | renders | opt-in via |
|---|---|---|
quench-common.ingress |
Ingress |
ingress.enabled (default false) |
quench-common.serviceAccount |
ServiceAccount |
serviceAccount.create |
quench-common.rbac |
Role + RoleBinding, optionally ClusterRole + ClusterRoleBinding |
rbac.create |
quench-common.pdb |
PodDisruptionBudget |
podDisruptionBudget.enabled |
quench-common.hpa |
HorizontalPodAutoscaler |
autoscaling.enabled |
quench-common.networkPolicy |
NetworkPolicy |
networkPolicy.enabled |
Which manifests moved here was decided by measuring the catalog, not by taste. Grouping all 138 charts by rendered shape: serviceaccount.yaml 117/132 identical, poddisruptionbudget.yaml 105/123, rbac.yaml 104/123, hpa.yaml 20/23. networkpolicy.yaml produced 91 distinct shapes because every app allows different ports, so its helper takes them from values instead of fixing them. service.yaml produced 98 distinct shapes from 128 charts and deliberately stays per-chart: the port list is the application's identity, so a helper would need as much configuration as the manifest it replaced.
Every helper is built to be overridden rather than fought:
extraLabelsandannotationson each object.- Ingress: multi-host, per-host
paths(a host with nopathsgets one/Prefix),pathType, TLS list, andclassNameomitted entirely when unset so the cluster default applies. The backend port resolves fromingress.servicePort, thenservice.port,service.ports.http/.https,service.httpPort,service.httpsPortandservice.apiPort— the four service shapes the catalog actually uses (63 charts, 14, 4 and 2 respectively). It refuses to render an Ingress with no rules, and refuses to guess a port it cannot resolve. - RBAC:
rbac.rules(default empty, so nothing is granted implicitly), plusrbac.clusterScopedwithrbac.clusterRules. TheClusterRoleBindingname carries the namespace, because cluster-scoped names are global and two releases in different namespaces would otherwise fight over one object. - PDB:
minAvailableormaxUnavailable,unhealthyPodEvictionPolicy, or a wholesalespecoverride. - HPA:
targetKind/targetName(so a StatefulSet chart can scale itself),behavior, CPU and/or memory targets, or a fully custommetricslist. - NetworkPolicy:
ingressPorts,extraFrompeers (namespace selector, ipBlock), wholesaleingress/egressrule lists, anddenyAllEgress.
Ingress is only adopted by charts that serve HTTP. An Ingress is an HTTP router, so it cannot front PostgreSQL, Redis, Kafka or etcd — expose those with service.type=LoadBalancer or the ingress controller's TCP passthrough. Shipping an ingress.enabled flag that silently did nothing would be worse than not having one.
One concern per file, so a chart author can read one thing at a time:
| file | provides |
|---|---|
_names.tpl |
name, fullname (nameOverride, fullnameOverride) |
_labels.tpl |
labels, podTemplateLabels, commonAnnotations |
_selector-labels.tpl |
selectorLabels, selectorLabelsBase — read it before adding any |
_image.tpl |
image (digest-only), imagePullSecrets |
_security.tpl |
podSecurityContext, containerSecurityContext |
_pod.tpl |
pod-spec knobs: podSpecFields, probe, env, volumes, init containers, sidecars, lifecycle hooks, command, args |
_serviceaccount.tpl |
serviceAccountName, serviceAccount |
_rbac.tpl |
rbac |
_pdb.tpl |
pdb |
_hpa.tpl |
hpa |
_networkpolicy.tpl |
networkPolicy |
_ingress.tpl |
ingress |
_helpers.tpl now defines nothing; it is the index of the files above.
| value | applies to | safe to change later? |
|---|---|---|
nameOverride / fullnameOverride |
object names | no (renames objects) |
partOf |
app.kubernetes.io/part-of on every object |
yes |
commonLabels |
every object's metadata | yes |
commonAnnotations |
every object's metadata | yes |
podLabels |
the pod template only | yes |
selectorLabels |
the workload selector and pod template | NO — immutable |
spec.selector is immutable on Deployment, StatefulSet, DaemonSet and Job. Adding a selector label to a release that already exists makes every later helm upgrade fail with field is immutable, and the only way out is deleting and recreating the workload. So use commonLabels or podLabels for anything you just want to query on, and reach for selectorLabels only when a label must genuinely participate in pod selection — set before the first install. selectorLabelsBase gives the two standard selector labels without the additions, for templates that must keep matching a workload created before any were added.
partOf exists because an application can require a particular value: Argo CD's settings manager only sees ConfigMaps and Secrets labelled app.kubernetes.io/part-of=argocd, so with the catalog default its own configuration is invisible and every component dies on configmap "argocd-cm" not found.
image.registry is optional and only prepended when set, so an air-gapped mirror can be pointed at without rewriting every repository. imagePullSecrets accepts plain strings or {name: ...} maps.
Patch-bump the chart version on every change, and never overwrite a published version. App charts then move to the new version on their next release. This is a library chart, so there's nothing to helm install directly.
Pushing to main runs .github/workflows/release-common.yml: lint, package, push the OCI chart to GHCR, and cosign-sign it (keyless).
MIT.
