5.4. Package as a Helm Chart

Convert the hand-written deployment.yaml into a Helm chart so the app can be installed, upgraded, and rolled back as a single versioned unit — and so the hostname and credentials are passed in at install time rather than baked into the manifest.

Step 1 — Create the chart skeleton

Create the directory structure by hand — no generated boilerplate needed.

mkdir -p helm/csharp-messages/templates

Create helm/csharp-messages/Chart.yaml:

apiVersion: v2
name: csharp-messages
description: Helm chart for the csharp-messages ASP.NET Core app
type: application
version: 0.1.0
appVersion: "latest"

Create helm/csharp-messages/values.yaml:

replicaCount: 1

image:
  repository: ""
  tag: "latest"

postgresql:
  connectionString: ""

ingress:
  host: ""
  clusterIssuer: letsencrypt-prod

Explanation

A Helm chart is a directory with two required files (Chart.yaml, values.yaml) and a templates/ folder. Chart.yaml provides metadata; values.yaml provides the defaults that callers override.

values.yaml declares only the four things that vary between installations — image, connection string, hostname, and issuer. Everything else (port numbers, label names, replica count) stays hardcoded in the templates because it never changes.


Step 2 — Template deployment.yaml

Create helm/csharp-messages/templates/deployment.yaml by copying the existing manifest and replacing the three variable parts with template expressions:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: messages-app
  namespace: {{ .Release.Namespace }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: messages-app
  template:
    metadata:
      labels:
        app: messages-app
    spec:
      containers:
        - name: messages-app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - containerPort: 8080
          {{- if .Values.postgresql.connectionString }}
          env:
            - name: POSTGRES_CONNECTION_STRING
              value: {{ .Values.postgresql.connectionString | quote }}
          {{- end }}

Explanation

Three things changed from the raw manifest:

WasNow
namespace: messages-appnamespace: {{ .Release.Namespace }}
image: YOUR_ACR_SERVER/csharp-messages:...image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
hardcoded env blockconditional {{- if .Values.postgresql.connectionString }}

The {{- if }} guard means the env var is omitted entirely when connectionString is empty — the app then falls back to SQLite, exactly as it did before the database was provisioned.


Step 3 — Template service.yaml

Create helm/csharp-messages/templates/service.yaml — only the namespace is templated:

apiVersion: v1
kind: Service
metadata:
  name: messages-app
  namespace: {{ .Release.Namespace }}
spec:
  selector:
    app: messages-app
  ports:
    - port: 80
      targetPort: 8080

Step 4 — Add ingress.yaml

Create helm/csharp-messages/templates/ingress.yaml, modelling it on https.yaml from the Chapter 4 lab:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: messages-app
  namespace: {{ .Release.Namespace }}
  annotations:
    cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }}
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - {{ .Values.ingress.host }}
      secretName: tls-messages-app
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: messages-app
                port:
                  number: 80

Explanation

The cert-manager.io/cluster-issuer annotation tells cert-manager to request a certificate from the letsencrypt-prod issuer when the Ingress object is created. Traefik reads the tls block and terminates TLS using the certificate stored in tls-messages-app.

ingress.host is set at install time to a hostname in the wildcard DNS zone provisioned in Chapter 3 (*.{username}.labs.netrics.dev), so no additional DNS record is needed.


Step 5 — Verify the chart

Lint and render locally before touching the cluster.

helm lint ./helm/csharp-messages

DNS_ZONE=$(pulumi stack output dns_zone)
ACR_SERVER=$(az acr show --name $(pulumi stack output registry_name) --query loginServer -o tsv)

helm template messages-app ./helm/csharp-messages \
  --namespace messages-app \
  --set image.repository=$ACR_SERVER/csharp-messages \
  --set ingress.host=messages.$DNS_ZONE \
  --set postgresql.connectionString="placeholder"

Explanation

helm lint checks syntax and best-practice rules. helm template renders the chart locally — the output is plain YAML you can read and diff. Neither command contacts the cluster.


Step 6 — Install

Remove the existing raw deployment, then install the chart.

kubectl delete namespace messages-app

export POSTGRES_CONNECTION_STRING=$(pulumi stack output db_connection_string --show-secrets)

helm install messages-app ./helm/csharp-messages \
  --namespace messages-app \
  --create-namespace \
  --set image.repository=$ACR_SERVER/csharp-messages \
  --set ingress.host=messages.$DNS_ZONE \
  --set postgresql.connectionString="$POSTGRES_CONNECTION_STRING"

Wait for the rollout and open the app in the browser:

kubectl rollout status deployment/messages-app -n messages-app

Navigate to https://messages.<your-username>.labs.netrics.dev — cert-manager will issue a certificate automatically; it usually takes 30–60 seconds on first install.

Verify the health endpoint:

curl https://messages.$DNS_ZONE/health

Explanation

helm install creates a named release (messages-app). Helm stores release metadata in a Secret inside the namespace, which is how it tracks what is deployed for upgrades and rollbacks.

--create-namespace replaces the explicit Namespace object that was at the top of deployment.yaml. The namespace is now owned by the release.

To push a new image and upgrade in place:

helm upgrade messages-app ./helm/csharp-messages \
  --namespace messages-app \
  --set image.repository=$ACR_SERVER/csharp-messages \
  --set image.tag=v2 \
  --set ingress.host=messages.$DNS_ZONE \
  --set postgresql.connectionString="$POSTGRES_CONNECTION_STRING"

To roll back to the previous revision:

helm rollback messages-app -n messages-app
Avoid –set for secrets in production

Passing the connection string via --set works for a workshop. In production, store it in a Kubernetes Secret and reference it with valueFrom.secretKeyRef in the deployment template, or use External Secrets Operator to sync it from Key Vault (see the Challenges chapter).


Complete files

helm/csharp-messages/Chart.yaml

apiVersion: v2
name: csharp-messages
description: Helm chart for the csharp-messages ASP.NET Core app
type: application
version: 0.1.0
appVersion: "latest"

helm/csharp-messages/values.yaml

replicaCount: 1

image:
  repository: ""
  tag: "latest"

postgresql:
  connectionString: ""

ingress:
  host: ""
  clusterIssuer: letsencrypt-prod

helm/csharp-messages/templates/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: messages-app
  namespace: {{ .Release.Namespace }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: messages-app
  template:
    metadata:
      labels:
        app: messages-app
    spec:
      containers:
        - name: messages-app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - containerPort: 8080
          {{- if .Values.postgresql.connectionString }}
          env:
            - name: POSTGRES_CONNECTION_STRING
              value: {{ .Values.postgresql.connectionString | quote }}
          {{- end }}

helm/csharp-messages/templates/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: messages-app
  namespace: {{ .Release.Namespace }}
spec:
  selector:
    app: messages-app
  ports:
    - port: 80
      targetPort: 8080

helm/csharp-messages/templates/ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: messages-app
  namespace: {{ .Release.Namespace }}
  annotations:
    cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }}
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - {{ .Values.ingress.host }}
      secretName: tls-messages-app
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: messages-app
                port:
                  number: 80