The first thing you learn when you start with Kubernetes is kubectl apply -f deployment.yaml. The second thing you learn is that this approach doesn’t scale, doesn’t provide auditability, and is a nightmare to manage across multiple environments.
By now, Kubernetes deployment has matured enough that “best practices” are actually knowable. Here’s the stack that makes sense for production deployments in 2026.
The Non-Starters
First, let’s eliminate the approaches that seem reasonable but aren’t:
Plain kubectl apply: No release management. No rollback mechanism. No audit trail of what changed when. Fine for learning, not for production.
kubectl edit: Absolutely not. Every change bypasses your CI/CD pipeline, produces no audit trail, and creates drift between your Git repo and cluster state.
helm install from a developer’s laptop: Good for local development. Terrible for production. You want your deployments coming from a CI system, not from someone’s machine with a different values file than everyone else.
Copying manifests between environments by hand: The fastest way to ensure your environments diverge in ways that are subtle and painful to debug.
The Production Stack
The approach that works:
Git Repository (source of truth)
↓
Helm Chart + Values
↓
CI Pipeline (lint, test, build image, push)
↓
GitOps Controller (Flux/Argo CD)
↓
Kubernetes Cluster
Every piece matters.
Helm: The Templating Layer
Helm is the de facto standard for packaging Kubernetes applications. Your application’s Kubernetes manifests belong in a Helm chart—even if you’re not publishing it to a public repository, even if you’re the only consumer.
Why? Because Helm gives you:
- Parameterization: One chart, multiple values files for each environment
- Release management:
helm rollbackworks (though GitOps is better) - Dependency management:
Chart.yamldependencies for multi-component apps - Hooks: Pre-install, post-upgrade, pre-delete lifecycle hooks
- Test hooks:
helm testfor post-deployment validation
A well-structured chart layout:
my-app/
├── Chart.yaml
├── values.yaml # Default values
├── values-staging.yaml # Staging overrides
├── values-production.yaml # Production overrides
└── templates/
├── _helpers.tpl
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── configmap.yaml
├── secret.yaml
├── hpa.yaml
├── poddisruptionbudget.yaml
└── serviceaccount.yaml
Immutable Container Images
Non-negotiable: every deployment should reference a specific, immutable image tag. Never deploy :latest. Never.
# Bad
image: myapp:latest
# Good
image: myregistry.example.com/myapp:1.4.2-abc1234
Your CI pipeline builds an image, tags it with the git commit SHA or semantic version, pushes it to your registry, and updates the reference in your Helm values or Kustomize overlay. This is what you deploy. Every deployment is reproducible and auditable.
GitOps: The Deployment Mechanism
Once you have a Helm chart and an image tag, the deployment mechanism should be GitOps. Either Flux or Argo CD—both work well, choose based on your team’s preferences (see my comparison of Argo CD vs Flux).
The key concept: a commit to your deploy repo triggers reconciliation, not a manual command.
Your workflow:
- Developer merges PR to application repo
- CI builds and pushes new image
myapp:1.4.3-def5678 - CI (or image automation) opens a PR against the deploy repo updating the image tag
- PR is reviewed and merged
- GitOps controller detects the change and reconciles the cluster
At no point did anyone kubectl apply or helm upgrade anything. The cluster is a function of your Git history.
# flux HelmRelease referencing the values
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: my-app
namespace: my-app
spec:
interval: 30m
chart:
spec:
chart: my-app
version: ">=1.0.0 <2.0.0"
sourceRef:
kind: HelmRepository
name: my-charts
namespace: flux-system
values:
image:
tag: "1.4.3-def5678"
replicaCount: 3
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
Environment Promotion
Your GitOps repository should have separate paths for each environment:
clusters/
├── staging/
│ ├── my-app/
│ │ ├── helmrelease.yaml # references values-staging.yaml
│ │ └── namespace.yaml
└── production/
├── my-app/
│ ├── helmrelease.yaml # references values-production.yaml
│ └── namespace.yaml
Promoting from staging to production means updating the image tag in the production HelmRelease and merging. The same process, the same audit trail.
Health Checks and Rollout Strategy
Your Deployment should have meaningful health probes:
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
Use a rolling update strategy with maxUnavailable: 0 to ensure zero-downtime deployments:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
Configure terminationGracePeriodSeconds long enough for your application to finish in-flight requests (typically 30-60 seconds for most apps).
PodDisruptionBudgets
Often forgotten, always important:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: my-app-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: my-app
Without a PDB, Kubernetes can evict all your pods simultaneously during node maintenance. With a PDB, at least one pod stays available. For production workloads with more than one replica, this should be standard.
Resource Management
Every container should have resource requests and limits:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
Requests affect scheduling—the scheduler uses them to find a node with sufficient capacity. Limits affect runtime—a container exceeding its memory limit gets OOMKilled.
Rule of thumb: set limits to 2-4x your requests. Monitor actual usage with tools like Goldilocks or VPA (Vertical Pod Autoscaler) in recommendation mode, then tune.
Secrets: SOPS or External Secrets Operator
Never put plaintext secrets in your GitOps repo. Two good options:
SOPS (Mozilla): Encrypt secrets in your Git repo with age or PGP. Flux can decrypt them natively. Simple, no additional infrastructure.
External Secrets Operator: Pull secrets from Vault, AWS SSM, Google Secret Manager, Azure Key Vault, etc. More complex to set up, but better for organizations with existing secret management infrastructure.
# External Secrets example
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-app-secrets
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: my-app-secrets
data:
- secretKey: db-password
remoteRef:
key: my-app/production
property: db-password
The Complete Checklist
Before declaring a deployment production-ready:
- Helm chart with parameterized values per environment
- Immutable image tags (no
:latest) - GitOps controller managing reconciliation
- Liveness and readiness probes configured
- Rolling update strategy with
maxUnavailable: 0 - PodDisruptionBudget defined
- Resource requests and limits set
- Secrets encrypted or externalized
- HPA configured for variable-load services
terminationGracePeriodSecondstuned- Network policies defined
- ServiceAccount with least-privilege RBAC
Do all of this and you’ve got a deployment that’s repeatable, auditable, resilient, and will handle most production failures gracefully. Skip items from this list and you’ll encounter each omission at the worst possible time.