KH.

RESOURCE · HELM CHART

A production-ready Helm chart starter for stateless services

Opinionated defaults for resource limits, security contexts, HPA, PDB, and Prometheus scraping — all in one chart.

Helm 3Kubernetes 1.28+HPA v2Prometheus Operator

What you get

  • Chart.yaml, values.yaml, and six production templates (Deployment, Service, Ingress, HPA, PDB, ServiceMonitor)
  • Security context defaults that pass CIS Kubernetes Benchmark: non-root, read-only filesystem, no privilege escalation, all capabilities dropped
  • Dual-metric HPA targeting both CPU and memory, with configurable scale-down stabilisation to avoid flapping

What it assumes

  • A Kubernetes cluster running 1.28 or newer
  • Helm 3.12+ installed on your local machine or CI runner
  • Prometheus Operator installed if you want the ServiceMonitor to work (otherwise set serviceMonitor.enabled: false)

The code

apiVersion: v2
name: my-app
description: >
  A production-ready Helm chart for stateless services.
  Batteries included: HPA, PDB, ServiceMonitor, secure defaults.
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
  - stateless
  - kubernetes
  - production
home: https://github.com/kamalhussaindevops/helm-chart-starter
maintainers:
  - name: Kamal Hussain
    url: https://kamalhussain.dev

The explanation

Why resource requests and limits are not optional

The values.yaml enforces resource requests and limits with no defaults — meaning a deploy fails if you forget to set them. This is intentional. Without requests, the Kubernetes scheduler cannot make accurate placement decisions, and without limits, a single runaway pod can starve everything else on the node. The Cluster Autoscaler also uses requests to decide when to add nodes; without them, it cannot scale up ahead of demand.

The specific defaults in the example (100m CPU / 128Mi memory for requests, 500m / 512Mi for limits) are a reasonable starting point for a small Node.js or Go service. You should measure your actual usage with kubectl top pods and adjust. A 5:1 ratio between limit and request is a common sign that the limits were set too conservatively — the pod will get throttled under load even though the node has headroom.

Why liveness and readiness probes point at different endpoints

A common mistake is to use /healthz for both probes. The probes have fundamentally different semantics:

  • Liveness answers "should Kubernetes restart me?" If it returns a non-2xx, the pod gets killed and restarted. Point this at a simple endpoint that only returns 500 when the process is genuinely broken (deadlock, corrupted state). Do not include database connectivity here — a transient database blip should not restart your pod.
  • Readiness answers "should I receive traffic?" If it returns non-2xx, the pod is removed from the Service endpoints. Point this at an endpoint that checks whether your application has finished initialising and can actually serve requests.

Using the same endpoint for both means a healthy pod under a slow database query gets killed by Kubernetes — which then restarts it, which then experiences the same slow query, which causes a cascade. The startup probe in this chart adds a third layer: it gives the container up to 150 seconds (30 failures × 5 second period) to start before liveness kicks in, which prevents slow-starting JVM services from being killed before they finish their warm-up.

Why HPA targets memory AND CPU

The default autoscaling/v2 HPA in many example charts only targets CPU utilisation. This works fine for CPU-bound workloads — it does not work for memory-bound ones. A Node.js service that buffers large JSON payloads can sit at 5% CPU and 95% memory utilisation. Without a memory target, HPA will never scale it out, and you get OOMKills under load instead of new pods.

The scale-down stabilisation window is set to 300 seconds (5 minutes). Without it, HPA scales down aggressively during brief traffic dips, then has to scale back up — which takes time and causes latency spikes. The scale-up policy is more aggressive: it can add up to 100% of current replicas or 4 pods per 15 seconds, whichever is larger, with no stabilisation delay. Scaling up should be fast; scaling down should be conservative.

Why PDB is included by default

A PodDisruptionBudget with minAvailable: 1 is the single most frequently missed Kubernetes setting in real clusters. Without it, a node drain (during a cluster upgrade, for example) can evict all pods in a Deployment simultaneously if they happen to be on the same node. With a PDB, Kubernetes will only evict pods if it can guarantee at least one remains available. For a 2-replica Deployment, this means upgrades are never completely disruptive.

Set minAvailable: 1 as the floor. If you have a high-traffic service, considerminAvailable: 2 or a percentage-based policy (minAvailable: 75%).

The security context

The security context in this chart satisfies the "restricted" pod security standard, which aligns with CIS Kubernetes Benchmark controls 5.2.x:

  • runAsNonRoot: true — prevents processes from running as root, which limits the blast radius of a container escape
  • readOnlyRootFilesystem: true — any write attempt to the container filesystem fails; legitimate writes must go to mounted volumes
  • allowPrivilegeEscalation: false — prevents setuid binaries from gaining elevated privileges
  • capabilities.drop: [ALL] — removes all Linux capabilities; if your app needs one (e.g. NET_BIND_SERVICE for port 80), add it explicitly
  • seccompProfile.type: RuntimeDefault — enables the container runtime's default seccomp profile, which blocks dangerous syscalls

Topology spread constraints

The topology spread constraint forces Kubernetes to spread pods across availability zones, with a maximum skew of 1 (meaning no zone can have more than 1 extra pod compared to others). This is set to ScheduleAnyway, which means Kubernetes will still schedule pods even if it can't satisfy the constraint — useful when you only have pods in one zone. Change this to DoNotSchedule if you require strict AZ distribution and would rather fail a deploy than let it pile up in one zone.

Customisation notes

The most common change is renaming the chart. Replace all references to my-app in Chart.yaml and the _helpers.tpl file (which the Explore agent found uses the chart name as a template prefix). The nameOverride and fullnameOverride values provide a lighter-weight option if you want to keep the chart generic.

For services that need writable filesystem paths (e.g. a service that writes temporary files), add a volume and volumeMount for the specific path rather than disablingreadOnlyRootFilesystem. A typical pattern is an emptyDir volume mounted at/tmp.

If you run the Prometheus Operator, set serviceMonitor.enabled: true and add the additionalLabels that match your Prometheus instance'sserviceMonitorSelector. Without the matching label, the ServiceMonitor exists but Prometheus won't discover it.

Need this customised for your stack?

The resource above covers the general case. If you need it adapted to your specific cloud account, VPC layout, security requirements, or team structure, that's what engagements are for.

Let's talk