惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Y
Y Combinator Blog
GbyAI
GbyAI
U
Unit 42
WordPress大学
WordPress大学
Last Week in AI
Last Week in AI
P
Proofpoint News Feed
D
DataBreaches.Net
N
Netflix TechBlog - Medium
H
Hackread – Cybersecurity News, Data Breaches, AI and More
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
C
Check Point Blog
Martin Fowler
Martin Fowler
月光博客
月光博客
MongoDB | Blog
MongoDB | Blog
MyScale Blog
MyScale Blog
The Cloudflare Blog
Apple Machine Learning Research
Apple Machine Learning Research
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
M
MIT News - Artificial intelligence
云风的 BLOG
云风的 BLOG
罗磊的独立博客
B
Blog RSS Feed
J
Java Code Geeks
The GitHub Blog
The GitHub Blog

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
Helm Charts in Production: Packaging Kubernetes Deploymen...
The Practica · 2026-06-12 · via The Practical Developer

Your team’s web service needs a Deployment, a Service, an Ingress, a ConfigMap, a HorizontalPodAutoscaler, and a PodDisruptionBudget. That is six YAML files. Each has a replicas field, an image tag, a set of environment variables, and a half-dozen other values that differ between staging and production. The current process for a new release is: open the staging YAML, update the image tag, check the replicas, check the env vars, commit, push, wait for ArgoCD, and repeat for production while hoping nobody forgets to change the env: prod label in the ConfigMap.

Last week someone did forget. Staging got deployed with production’s database connection string. Nobody noticed for four hours because staging was using a read replica that happened to work. The fix was a forced restart and a rotation of a credential that should never have been shared.

This is the problem Helm solves. It packages all six YAML files into one chart, parameterizes the differences with a values.yaml file, and lets you deploy any environment with a single helm upgrade --install command. The image tag, the replicas, the environment variables, the ingress hostname, everything that changes between environments goes in a values file, and everything that stays the same lives in the templates.

This post is the production Helm setup: the chart structure that scales, the template patterns that do not make you hate Go templates, the environment strategy, the CI/CD integration, and the versioning convention that makes rollbacks a one-flag operation.

What Helm actually gives you

Helm is three things bundled into one CLI:

Templating. Your raw Kubernetes YAML becomes Go templates with values injected from a values.yaml file. One chart produces the right manifests for dev, staging, and production by swapping values files.

Packaging. A chart is a versioned, portable directory you can publish to a registry (OCI or classic Helm repo), download, and install. Your chart is your deployable unit.

Lifecycle management. helm upgrade --install creates or updates a release. helm rollback reverts to a previous revision. helm history shows every change. You get deployment versioning without setting up a separate tool.

If you are not using Helm, your deployment process is either a shell script that runs kubectl apply -f against a directory of YAML files, or a GitOps tool (ArgoCD, Flux) that points at a monorepo of raw YAML. Both work. Both become painful at around five services because every environment variation requires either a copy-pasted YAML file or a fragile sed command in the deploy script.

A production chart structure

Start with this directory layout:

chart/
├── Chart.yaml
├── values.yaml
├── values.staging.yaml
├── values.production.yaml
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── hpa.yaml
│   └── pdb.yaml
└── charts/

The Chart.yaml file sets the metadata:

apiVersion: v2
name: checkout-api
description: Checkout service for the product platform
type: application
version: 1.4.0
appVersion: 1.4.0

The version field is the chart version. The appVersion is the application version. They can move independently, which matters when you update manifests without deploying new code.

The master values.yaml file holds every sensible default:

# values.yaml
replicaCount: 2

image:
  repository: ghcr.io/myorg/checkout-api
  tag: latest
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 3000

ingress:
  enabled: true
  className: nginx
  host: checkout-api.staging.example.com
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod

config:
  env: staging
  logLevel: debug
  dbPoolMin: 2
  dbPoolMax: 10

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 75

pdb:
  enabled: true
  minAvailable: 1

Every value has a default. Every value can be overridden by an environment-specific values file. A template that references a nonexistent value will fail at render time, not at runtime.

Templates that do not make you hate templates

Go templates in Helm are not pleasant to write. The syntax is ugly, the function list is long, and the error messages are unhelpful. The trick is to keep templates simple and push complexity into values.yaml.

A clean deployment template looks like this:

{{- template "checkout-api.name" . }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "checkout-api.fullname" . }}
  labels:
    {{- include "checkout-api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "checkout-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "checkout-api.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
          envFrom:
            - configMapRef:
                name: {{ include "checkout-api.fullname" . }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Three rules to follow:

Use the _helpers.tpl naming conventions. Helm has a standard set of named templates (name, fullname, labels, selectorLabels) that every chart should include. They handle truncation, unique naming, and label consistency.

Keep logic out of templates. Do not write {{- if and .Values.ingress.enabled (eq .Values.config.env "production") -}} in a template. Either put the complex condition in a helper, or encode it as a single boolean in values. Templates are hard to test. Values files are easy to read.

Use toYaml and nindent for structured blocks. Instead of listing every resource field in template YAML, pass structured blocks through from values:

# values.yaml
extraEnv:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=512"
  - name: SENTRY_DSN
    value: "https://key@sentry.io/project"
# templates/deployment.yaml
{{- with .Values.extraEnv }}
env:
  {{- toYaml . | nindent 12 }}
{{- end }}

This pattern handles environment variables, sidecar containers, volumes, init containers, and any other variable-length list without template gymnastics.

The _helpers.tpl that every chart needs

Put this in your templates/_helpers.tpl:

{{- define "checkout-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "checkout-api.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{- define "checkout-api.labels" -}}
helm.sh/chart: {{ include "checkout-api.name" . }}-{{ .Chart.Version | replace "+" "_" }}
{{ include "checkout-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "checkout-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "checkout-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

These four named templates appear in almost every Helm chart. They are Helm’s version of a linter rule: predictable, standard, and they prevent silly naming collisions.

Environment-specific values files

The environment values files override only the values that change:

# values.staging.yaml
replicaCount: 2
image:
  tag: staging-abc1234
ingress:
  host: checkout-api.staging.example.com
config:
  env: staging
  logLevel: debug
  dbPoolMax: 10
# values.production.yaml
replicaCount: 6
image:
  tag: v1.4.0
ingress:
  host: checkout-api.example.com
config:
  env: production
  logLevel: info
  dbPoolMax: 25

Deploy with:

helm upgrade --install checkout-api ./chart \
  --namespace checkout \
  --values ./chart/values.yaml \
  --values ./chart/values.production.yaml

Helm merges values files in order. Later files override earlier ones. The base values.yaml provides every default, the environment file overrides what is different, and any one-off override can pass --set image.tag=v1.4.1 on the command line.

Do not put secrets in values files. Use --set with values from your CI secrets manager, or better, use External Secrets Operator or Sealed Secrets to pull secrets from Vault or AWS Secrets Manager at runtime.

CI/CD integration

A Helm deployment in CI is a four-step pipeline:

# .github/workflows/deploy.yml
deploy:
  runs-on: ubuntu-22.04
  steps:
    - uses: actions/checkout@v4

    - name: Lint chart
      run: helm lint ./chart

    - name: Render manifests
      run: |
        helm template checkout-api ./chart \
          --values ./chart/values.yaml \
          --values ./chart/values.${{ inputs.environment }}.yaml \
          > /tmp/rendered.yaml

    - name: Deploy
      run: |
        helm upgrade --install checkout-api ./chart \
          --namespace checkout \
          --create-namespace \
          --values ./chart/values.yaml \
          --values ./chart/values.${{ inputs.environment }}.yaml \
          --set image.tag=${{ inputs.tag }}

The helm lint step catches YAML syntax errors and missing required values. The helm template step lets you inspect the rendered output in CI logs without touching the cluster. The actual deploy uses helm upgrade --install, which is idempotent: run it a hundred times and the cluster state is the same as after one run.

For GitOps workflows (ArgoCD, Flux), the chart lives in a repository and the GitOps tool runs helm template and applies the result. The chart structure is the same; only the deployment mechanism changes.

Versioning and rollback

Every helm upgrade creates a new revision. Run helm history checkout-api -n checkout to see them:

REVISION    UPDATED                    STATUS        CHART              APP VERSION
1           Tue Jun 10 14:22:01 2026   superseded    checkout-api-1.3.0 1.3.0
2           Wed Jun 11 09:15:32 2026   superseded    checkout-api-1.3.1 1.3.1
3           Thu Jun 12 11:04:17 2026   deployed      checkout-api-1.4.0 1.4.0

If revision 3 breaks, roll back:

helm rollback checkout-api 2 -n checkout

The rollback is a new revision (4) that re-applies the revision-2 manifests. It is not a git revert. It is not a redeploy of an old image. It is Helm re-running the templates with the old values and applying the result.

To make rollbacks reliable, version your chart in lockstep with your application. When you tag a release v1.4.0, bump both version and appVersion in Chart.yaml:

version: 1.4.0
appVersion: "1.4.0"

Now helm list shows exactly which version of the application is deployed, and helm rollback restores both the manifests and the semantic intent.

The three mistakes that break production charts

Mistake 1: Missing values validation. Helm 3 has built-in schema validation with a values.schema.json file in the chart root. Use it to enforce types and required fields:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["replicaCount", "image", "ingress", "config"],
  "properties": {
    "replicaCount": {
      "type": "integer",
      "minimum": 1
    },
    "image": {
      "type": "object",
      "required": ["repository", "tag"],
      "properties": {
        "repository": { "type": "string" },
        "tag": { "type": "string" }
      }
    }
  }
}

Apply the schema at deploy time:

$ helm template ./chart --values values.production.yaml --validate
Error: values don't meet the specifications of the schema(s) in the following chart(s):
checkout-api:
- replicaCount: Invalid type. Expected: integer, Given: string

Without this, a typo like replicaCount: "6" (string instead of integer) silently passes through and produces a broken Deployment.

Mistake 2: Storing secrets in values.yaml. A values file in git is not encrypted. A --set flag printed in CI logs is visible to anyone with access. Use External Secrets Operator, Sealed Secrets, or a Vault sidecar to inject secrets at runtime. The chart should reference a secret name, not a secret value.

Mistake 3: Overriding too much in --set. Every --set image.tag=v1.4.1 on the command line is a deployment that cannot be reproduced from the values files alone. If you rely on CI variables for critical values, your helm rollback might recreate the right chart version but the wrong image tag. Always encode the full release state in values files, and use --set only for one-off debugging.

Helm is good for single-service deployments and shared infrastructure components (ingress controllers, monitoring stacks, cert-manager). It is less good when:

  • Every service has a wildly different set of resources. If each service needs a unique combination of a dozen resource types, the template overhead outweighs the benefit. Raw YAML or a tool like kustomize (which overlays patches without templates) may be simpler.

  • You need to manage cross-service dependencies. Helm has a dependencies feature in Chart.yaml, but it is designed for third-party chart composition, not for wiring up your own services. For cross-service orchestration, use a higher-level tool like ArgoCD ApplicationSets or a Pulumi/CDK program.

  • Your team does not know Go templates. This is a real constraint. Helm templates are the worst part of Helm. If the team cannot read a {{- range $key, $value := .Values.configMap -}} block without a documentation tab open, you will accumulate technical debt in the templates. Consider kustomize as a template-free alternative, or invest in writing clean helpers that hide the template complexity.

The practical takeaway

A Helm chart is the packaging layer your Kubernetes deployments are missing. It standardizes the deploy process, eliminates copy-paste errors between environments, and gives you versioned rollbacks without adding another control plane.

Start small. Pick one service that has at least four YAML files. Build a chart for it. Deploy it to staging with helm upgrade --install. Add a values.production.yaml with the differences. Drop helm lint into your CI pipeline. Expand to the rest of the services when the team sees the difference between “edit six files” and helm upgrade --install --values values.production.yaml.

The chart templates in this post are ready to copy. Adjust the names, fill in your values, and delete one deployment script from your repo.

A note from Yojji

The gap between “it works on my machine” and “it deploys reliably across three environments” is where most teams lose time. Packaging infrastructure into versioned, testable charts is the kind of production discipline that does not make the feature roadmap but prevents the Friday afternoon deploy that takes down staging. Yojji’s engineering teams build this discipline into every cloud-native product they ship, from the first chart to the CI pipeline that deploys it.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior teams specialize in the JavaScript ecosystem, cloud platforms (AWS, Azure, Google Cloud), and Kubernetes-native architectures, handling the full product cycle from discovery through DevOps and production operations.