Skip to main content

Kubernetes Primer for .NET Developers: From kubectl to Helm

·15 mins

The Hosting series Kubernetes article covered the primitives you need to run an ASP.NET Core application on Kubernetes: Deployments, Services, Ingress, probes, resource limits. This article covers the other half: how to actually work with Kubernetes day to day as a .NET developer. What kubectl commands matter, how to organize manifests so that dev, staging, and production do not drift, how Kustomize and Helm fit together, and the concrete workflow a team uses to go from “I wrote a change” to “it is deployed” without hand-crafting YAML for every environment.

The assumption here is that you have read the Hosting article, you understand what a Deployment and a Service are, and you now need to ship the thing. The goal of this primer is to give you the minimum viable tooling to do that, and the judgment to know when to reach for more.

Why Kubernetes deployment workflow matters #

Kubernetes itself is declarative, which is wonderful in theory and unforgiving in practice. A single manifest file with hardcoded values works great for one environment and drifts within a week across three. The gap between “I have a Deployment manifest” and “my team ships reliably to dev, staging, and prod with the same pipeline” is bigger than most introductory tutorials admit.

The workflow this article covers answers four concrete questions:

  1. How do I actually talk to the cluster? kubectl has hundreds of subcommands; maybe fifteen matter for daily work.
  2. How do I keep manifests DRY across environments? A production deployment needs different resource limits, different replica counts, different secrets, and different ingress hostnames than a dev deployment. Copy-pasting YAML across three folders is the problem that Kustomize and Helm solve.
  3. How do I package and version a release? A release is not just an image tag. It is a Deployment, a Service, an Ingress, a ConfigMap, a Secret, a HorizontalPodAutoscaler, and whatever else the application needs. All of that should move together.
  4. How do I deploy without touching kubectl in production? Pipelines, GitOps, and reviewable changes are the alternative to “someone typed a command on their laptop”.

Overview: the deployment workflow #

graph LR A[Source code
+ manifests] --> B[CI build] B --> C[Image pushed
to registry] B --> D[Manifests rendered
Kustomize or Helm] D --> E[kubectl apply
or GitOps sync] E --> F[Cluster reconciles
desired state] F --> G[Running pods]

Every Kubernetes deployment follows the same basic shape. CI builds the image, pushes it to a registry, renders the manifests for the target environment, and applies them to the cluster. The cluster reconciles its running state with the declared state and reports back. The variations between teams are mostly in how manifests are rendered and how they are applied.

Zoom: the kubectl commands that matter #

Out of everything kubectl can do, twelve commands cover 95% of day-to-day work. Learn these first.

# Where am I?
kubectl config current-context              # which cluster
kubectl config use-context prod             # switch cluster
kubectl get nodes                           # nodes and their status

# What is running?
kubectl get pods -n shop                    # pods in a namespace
kubectl get deployments,svc,ingress -n shop # all common resources at once
kubectl describe pod shop-api-abc123 -n shop  # detailed state of a pod

# Logs and debugging
kubectl logs shop-api-abc123 -n shop --tail=100 --follow
kubectl logs -l app=shop-api -n shop --tail=100  # all pods matching a label
kubectl exec -it shop-api-abc123 -n shop -- /bin/sh  # shell into a pod

# Apply and rollback
kubectl apply -f deployment.yaml             # create or update
kubectl rollout status deployment/shop-api -n shop  # wait for rollout to complete
kubectl rollout undo deployment/shop-api -n shop    # rollback to previous revision

# Port-forward for local debugging
kubectl port-forward svc/shop-api 8080:80 -n shop

Two habits save real time. First, set a default namespace so you do not type -n shop on every command: kubectl config set-context --current --namespace=shop. Second, use kubectl get with label selectors (-l app=shop-api) to operate on groups of resources, not individual ones.

πŸ’‘ Info : kubectl logs -l app=shop-api --follow is the command to remember for production log tailing. It aggregates logs from every matching pod in real time, which is what you want when debugging why a specific endpoint is slow across replicas.

Zoom: the naive single-file manifest #

Every “get started with Kubernetes” tutorial begins the same way: one YAML file, everything inlined.

# k8s/all-in-one.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: shop-api
  template:
    metadata:
      labels:
        app: shop-api
    spec:
      containers:
        - name: api
          image: myregistry.azurecr.io/shop-api:latest
          ports:
            - containerPort: 8080
          env:
            - name: ConnectionStrings__Default
              value: "Host=postgres;Database=shop;Username=admin;Password=supersecret"
            - name: ASPNETCORE_ENVIRONMENT
              value: Production
---
apiVersion: v1
kind: Service
metadata:
  name: shop-api
spec:
  selector:
    app: shop-api
  ports:
    - port: 80
      targetPort: 8080

This works for a five-minute demo. In any real scenario it creates several problems at once:

  • latest tag. There is no way to know which version is running, no way to rollback to a known good build, and image pull behavior depends on the node cache.
  • Hardcoded secrets in plain text. The connection string is visible to anyone who can read the manifest, and it will end up in version control.
  • No resource limits. A memory leak in the container can take down the node.
  • No probes. Kubernetes has no way to know whether the application is healthy, so it will keep routing traffic to a broken pod.
  • No ingress. The service is reachable only inside the cluster.
  • Everything in one file. Diffing changes across environments is painful, and patching a single value means editing a file that also contains unrelated resources.

The moment a staging environment appears alongside production, this approach collapses. The next sections show how to decompose it properly.

Zoom: decomposing into production-grade manifests #

A production-grade layout splits each resource into its own file:

k8s/
β”œβ”€β”€ deployment.yaml
β”œβ”€β”€ service.yaml
β”œβ”€β”€ ingress.yaml
β”œβ”€β”€ configmap.yaml
β”œβ”€β”€ secret.yaml          # or better: use external-secrets
└── hpa.yaml

deployment.yaml with probes, resource limits, and a pinned image tag:

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: shop-api
  template:
    metadata:
      labels:
        app: shop-api
    spec:
      containers:
        - name: api
          image: myregistry.azurecr.io/shop-api:1.4.7
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: shop-api-config
            - secretRef:
                name: shop-api-secrets
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /healthz/live
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20

configmap.yaml for non-secret configuration:

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: shop-api-config
data:
  ASPNETCORE_ENVIRONMENT: Production
  Logging__LogLevel__Default: Warning
  Logging__LogLevel__Microsoft.AspNetCore: Warning

secret.yaml for sensitive values:

# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: shop-api-secrets
type: Opaque
stringData:
  ConnectionStrings__Default: "Host=postgres;Database=shop;Username=admin;Password=supersecret"

stringData accepts plain text and Kubernetes base64-encodes it at rest. This is encoding, not encryption: anyone with read access to the namespace can decode it. For anything beyond a local dev cluster, the next step is Sealed Secrets or the External Secrets Operator pointing to a real vault.

❌ Never do : Never commit a plain-text Secret manifest to git. Use kubectl create secret imperatively, or use Sealed Secrets / External Secrets Operator to keep secrets out of version control entirely.

hpa.yaml for horizontal pod autoscaling:

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: shop-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-api
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Each file has a single responsibility, each can be diffed independently, and each can be overridden per environment with Kustomize patches (covered in the next section).

Zoom: from Compose to Kubernetes with kompose #

Teams coming from Docker Compose can use kompose as a migration path. It reads a compose.yaml and generates Kubernetes manifests:

kompose convert -f compose.yaml -o k8s/

For a typical Compose file with an API service and a database, kompose generates a Deployment and a Service for each container, and PersistentVolumeClaims for named volumes. It gets the basic structure right and saves time on the initial scaffolding.

What it does not generate, and what needs to be added manually:

  • Liveness and readiness probes. Compose has healthcheck, but kompose does not always map it to Kubernetes probes correctly.
  • Resource requests and limits. Compose deploy.resources is only partially supported.
  • Ingress. Compose has no equivalent concept, so there is nothing to convert.
  • Secrets. Compose secrets and environment variables are converted to ConfigMaps, not Kubernetes Secrets.
  • Volume claims. The generated PVCs use default storage classes and access modes that may not match the target cluster.

The post-kompose cleanup checklist:

  1. Add readiness and liveness probes to every Deployment.
  2. Set resource requests and limits.
  3. Replace ConfigMap entries that contain secrets with proper Secret resources (or external-secrets).
  4. Review generated PVC sizes and storage classes.
  5. Add an Ingress resource.
  6. Pin image tags (kompose preserves whatever tag the Compose file uses, which is often latest).

⚠️ It works, but… : kompose is a starting point, not a production output. Treat the generated files as scaffolding and expect to edit every one of them before applying to a real cluster.

Zoom: passing secrets from CI/CD #

Secrets should live in the CI/CD platform’s secret store and be injected at deploy time, never checked into git. Here are two concrete pipelines.

GitHub Actions:

- name: Deploy to AKS
  run: |
    kubectl create secret generic shop-secrets \
      --from-literal=ConnectionStrings__Default="${{ secrets.DB_CONNECTION }}" \
      --from-literal=PaymentGateway__ApiKey="${{ secrets.PAYMENT_KEY }}" \
      --namespace shop-prod \
      --dry-run=client -o yaml | kubectl apply -f -
    
    kubectl set image deployment/shop-api \
      api=myregistry.azurecr.io/shop-api:${{ github.sha }} \
      --namespace shop-prod

Azure DevOps:

- task: Kubernetes@1
  inputs:
    connectionType: 'Azure Resource Manager'
    azureSubscriptionEndpoint: '$(azureSubscription)'
    azureResourceGroup: '$(resourceGroup)'
    kubernetesCluster: '$(aksCluster)'
    command: 'apply'
    arguments: '-k k8s/overlays/prod'
    secretType: 'generic'
    secretArguments: '--from-literal=ConnectionStrings__Default=$(DB_CONNECTION)'

The --dry-run=client -o yaml | kubectl apply -f - pattern in the GitHub Actions example deserves explanation: kubectl create secret normally fails if the secret already exists. By rendering it as YAML with --dry-run=client and piping it to kubectl apply, the command becomes idempotent, it creates the secret on the first run and updates it on subsequent runs without errors.

For production-grade secret management at scale, teams should consider the External Secrets Operator pointing to Azure Key Vault, or GitHub’s OIDC federation for keyless authentication to cloud providers. These approaches remove the need for long-lived credentials in CI/CD variables entirely.

βœ… Good practice : Secrets live in CI/CD variables (GitHub Secrets, Azure DevOps Variable Groups), never in git, never in Helm values files. The CI/CD pipeline is the only place where secret values are resolved, and they are injected into the cluster at deploy time.

Zoom: manifest layout with Kustomize #

A naive approach puts all the manifests in one folder and edits them by hand for each environment. It works for a week and collapses after that. Kustomize solves it with a base + overlays pattern that is native to kubectl since 1.14.

k8s/
β”œβ”€β”€ base/
β”‚   β”œβ”€β”€ deployment.yaml
β”‚   β”œβ”€β”€ service.yaml
β”‚   β”œβ”€β”€ ingress.yaml
β”‚   β”œβ”€β”€ configmap.yaml
β”‚   └── kustomization.yaml
└── overlays/
    β”œβ”€β”€ dev/
    β”‚   β”œβ”€β”€ kustomization.yaml
    β”‚   └── patch-replicas.yaml
    β”œβ”€β”€ staging/
    β”‚   └── kustomization.yaml
    └── prod/
        β”œβ”€β”€ kustomization.yaml
        β”œβ”€β”€ patch-replicas.yaml
        └── patch-resources.yaml

The base contains the manifests as they would look in a “default” environment: one replica, minimal resources, no environment-specific values. The overlays contain only the differences from the base.

# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml
  - configmap.yaml

commonLabels:
  app: shop-api

images:
  - name: shop-api
    newName: myregistry.azurecr.io/shop-api
    newTag: "1.4.7"
# k8s/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: shop-prod
resources:
  - ../../base

patches:
  - path: patch-replicas.yaml
  - path: patch-resources.yaml

configMapGenerator:
  - name: shop-api-config
    behavior: merge
    literals:
      - Logging__LogLevel__Default=Warning
      - ASPNETCORE_ENVIRONMENT=Production

images:
  - name: shop-api
    newTag: "1.4.7"
# k8s/overlays/prod/patch-replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
spec:
  replicas: 5

Three things Kustomize gives you for free. Namespace substitution: the overlay declares namespace: shop-prod and every resource in the overlay gets deployed there, without editing the base. Patch-based overrides: the replica count and resource limits live in small patch files that only describe the delta from the base. ConfigMap generation with merge semantics: environment-specific values are layered on top of base values without duplicating the full ConfigMap.

Rendering and applying is a single command:

# Preview what will be applied
kubectl kustomize k8s/overlays/prod

# Actually apply it
kubectl apply -k k8s/overlays/prod

βœ… Good practice : Always kubectl kustomize before kubectl apply in a new environment. Diffing the rendered output against the current cluster state with kubectl diff -k ... shows exactly what will change, which is the closest thing to a dry run Kubernetes offers.

Zoom: Helm for packaging and reuse #

Kustomize is excellent for a team’s own manifests. Helm solves a different problem: packaging manifests as a reusable artifact that can be versioned, shared, and deployed with parameters. If Kustomize is “my team’s manifests, per environment”, Helm is “a packaged unit I can install, upgrade, and uninstall like a library”.

The practical use cases where Helm wins:

  1. Installing third-party components. NGINX Ingress Controller, cert-manager, Prometheus, Grafana, external-secrets: all of them ship as Helm charts and installing them is a one-line command.
  2. Packaging your own application for multiple consumers. A .NET service that multiple teams deploy (a shared auth service, a shared observability agent) is easier as a chart with parameters than as a set of manifests each team has to copy.
  3. Upgrades and rollbacks as first-class operations. helm upgrade and helm rollback track the release history in the cluster itself, which is cleaner than manually tracking Git commits.

A minimal chart for the .NET API:

chart/
β”œβ”€β”€ Chart.yaml
β”œβ”€β”€ values.yaml
└── templates/
    β”œβ”€β”€ deployment.yaml
    β”œβ”€β”€ service.yaml
    β”œβ”€β”€ ingress.yaml
    └── _helpers.tpl
# Chart.yaml
apiVersion: v2
name: shop-api
version: 1.4.7
appVersion: "1.4.7"
description: Shop API service
# values.yaml
image:
  repository: myregistry.azurecr.io/shop-api
  tag: "1.4.7"
  pullPolicy: IfNotPresent

replicaCount: 3

resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

ingress:
  enabled: true
  className: nginx
  host: api.shop.example.com
  tls:
    enabled: true
    secretName: shop-api-tls
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "shop-api.fullname" . }}
  labels:
    {{- include "shop-api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "shop-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "shop-api.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: api
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Installation:

helm install shop-api ./chart --namespace shop-prod --create-namespace \
  --set image.tag=1.4.7 \
  --set replicaCount=5

Upgrade:

helm upgrade shop-api ./chart --namespace shop-prod \
  --set image.tag=1.4.8

Rollback:

helm rollback shop-api --namespace shop-prod  # back to previous revision
helm rollback shop-api 3 --namespace shop-prod  # back to revision 3 specifically

⚠️ It works, but… : Helm templates are Go text/template over YAML, which is a combination that does not always degrade gracefully. A misplaced indent in a template can produce valid-looking but semantically wrong YAML. helm template ./chart -f values-prod.yaml | kubectl apply --dry-run=server -f - is the standard way to catch these before they reach the cluster.

Zoom: Kustomize or Helm, which one #

The choice is not either/or. Most mature Kubernetes setups use both.

Use Helm for third-party components, shared services, and anything you publish to a chart repository. Its strength is packaging and upgrade semantics.

Use Kustomize for your own team’s services, where you control both the base manifests and the overlays. Its strength is simplicity: no templating language, no helpers, just YAML patches.

Combine them by using Kustomize to post-process Helm output. Helm renders a chart with base values, Kustomize applies team-specific overrides on top. This is the pattern most production clusters land on after a year or two of experimentation.

# kustomization.yaml
helmCharts:
  - name: ingress-nginx
    repo: https://kubernetes.github.io/ingress-nginx
    version: 4.10.0
    releaseName: ingress
    namespace: ingress-nginx
    valuesFile: values-ingress.yaml

patches:
  - path: patch-ingress-resources.yaml

πŸ’‘ Info : kubectl ships with Kustomize built in, but not Helm. Installing Helm is a one-line script (curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash), and most Kubernetes environments already have it.

Zoom: GitOps with Flux or ArgoCD #

Once the manifests are organized and the rendering works, the next step is removing humans from the deployment path entirely. GitOps is the pattern where the cluster continuously reconciles itself with a Git repository: the repository is the source of truth, and the cluster polls it for changes and applies them automatically.

The two widely-used tools are Flux and ArgoCD. Both work the same way: you install a controller in the cluster, point it at a Git repository, and every change merged to the main branch of that repository is applied to the cluster within seconds. Rollback is a git revert.

Minimal ArgoCD Application:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: shop-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/shop-manifests.git
    path: overlays/prod
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: shop-prod
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

After this is applied once, ArgoCD watches the overlays/prod folder of the manifests repository. Any merge to main triggers an automatic sync. The selfHeal: true option means the cluster auto-corrects drift: if someone manually edits a resource with kubectl, ArgoCD reverts it to match Git.

The benefits are concrete: every deployment is a pull request with reviewers, every rollback is a Git revert, and every environment is auditable by looking at Git history.

βœ… Good practice : Keep application source code and manifests in separate repositories. shop-api has the C# code; shop-manifests has the YAML. This separation lets the CI pipeline push manifest updates (new image tag) without polluting the code repository history, and it gives the operations team a clear boundary.

When this is overkill #

Everything in this article assumes the team actually runs on Kubernetes and intends to keep doing so. If the current setup is a single container on Azure Web App, jumping to Kustomize + Helm + GitOps is overkill. Start with the hosting option that fits the size of the team and the workload, and adopt this toolchain when the scale justifies it.

Rough thresholds:

  • One or two services, one team: kubectl apply -f with plain manifests is fine.
  • A handful of services, one environment other than dev: add Kustomize.
  • Many services, multiple environments, multiple teams: add Helm (for shared components) and GitOps (for the deployment pipeline).

Wrap-up #

Deploying .NET applications on Kubernetes as a day-to-day workflow comes down to a small set of tools and habits: fifteen kubectl commands for everything operational, Kustomize for base-plus-overlays manifest management, Helm for packaging and third-party charts, and GitOps with Flux or ArgoCD when the scale justifies removing humans from the deployment path. You can adopt these incrementally, start with just Kustomize, add Helm when it pays off, and reach GitOps when “who deployed what when” becomes a real question. You can avoid the common failure mode of copy-pasted YAML across environments, and you can give your team a deployment workflow that is reviewable, auditable, and reversible.

Ready to level up your next project or share it with your team? See you in the next one, .NET Aspire is where we go next.

References #