Skip to content

Helm & Kubernetes

The repository ships a Helm chart under etc/helm that deploys the whole Cargonerds stack — the four .NET hosts plus SQL Server, Redis and RabbitMQ — into a Kubernetes cluster. It was generated by the ABP Studio "Kubernetes" tooling and is shaped accordingly.

Local development only

This chart is a local-development convenience for running the full solution on a single-node cluster (Docker Desktop Kubernetes). It is not used by CI/CD and is not the production deployment path. Production runs on Azure: ephemeral/stage environments via .NET Aspire → Azure Container Apps and the long-lived production services via the App Service zip/slot pipeline. See the deployment overview for the full picture.

The chart bakes in insecure, checked-in defaults (SA password, encryption pass phrase, OpenIddict client secret) and pins images to aspnet:9.0 even though the solution targets .NET 10. Treat it as a dev harness, not a hardened deployment. See Gotchas.

Chart layout

The chart is an umbrella chart: a parent chart cargonerds with each service as a bundled subchart under charts/. There is no dependencies: block in the parent Chart.yaml and no Chart.lock / external repos — every subchart lives directly in-tree, so helm install works straight from the directory with no helm dependency build step.

etc/helm/
├── cargonerds/
│   ├── Chart.yaml                      # parent chart (name: cargonerds, v1.0.0)
│   ├── values.yaml                     # base values (tlsSecret, abpStudioClient)
│   ├── values.cargonerds-local.yaml    # per-release overrides (hosts, conn string, secrets)
│   ├── values.localdev.yaml            # GENERATED + .gitignored (per-image tags)
│   ├── templates/
│   │   └── _helpers.tpl                # cargonerds.hosts.* URL helpers
│   └── charts/                         # subcharts (each is a full chart)
│       ├── authserver/                 # OpenIddict auth server   → Deployment + Service + Ingress
│       ├── httpapihost/                # REST/OData API host      → Deployment + Service + Ingress
│       ├── blazor/                     # Blazor admin UI          → Deployment + Service + Ingress + ConfigMap
│       ├── webpublic/                  # public MVC site          → Deployment + Service + Ingress
│       ├── dbmigrator/                 # ABP DbMigrator           → Job
│       ├── sqlserver/                  # azure-sql-edge           → StatefulSet + Service
│       ├── redis/                      # redis:7.2.2-alpine       → Deployment + Service
│       └── rabbitmq/                   # rabbitmq management      → StatefulSet + Service
├── build-all-images.ps1 / build-image.ps1   # docker build helpers
├── create-tls-secrets.ps1              # mkcert → kube TLS secret
├── install.ps1 / uninstall.ps1         # helm upgrade --install / uninstall wrappers
├── cargonerds-local.pem / *-key.pem    # committed mkcert cert/key for the local hosts
└── README.md                           # ABP-Studio-generated local-k8s guide

The parent Chart.yaml is intentionally minimal:

etc/helm/cargonerds/Chart.yaml
apiVersion: v2
name: cargonerds
version: 1.0.0
appVersion: "1.0"
description: Cargonerds Solution

Every subchart's Chart.yaml follows the same shape (apiVersion: v2, version: 1.0.0, appVersion: "1.0"); the four .NET application subcharts additionally declare type: application.

Objects produced

For a release named cargonerds-local, helm template/install renders the following Kubernetes objects (names are {{ .Release.Name }}-{{ .Chart.Name }}):

Subchart Kind Object name Exposed via Ingress? Image
authserver Deployment + Service cargonerds-local-authserver yes (-authserver) cargonerds/authserver
httpapihost Deployment + Service cargonerds-local-httpapihost yes (-httpapihost) cargonerds/httpapihost
blazor Deployment + Service + ConfigMap cargonerds-local-blazor yes (-blazor) cargonerds/blazor
webpublic Deployment + Service cargonerds-local-webpublic yes (-webpublic) cargonerds/webpublic
dbmigrator Job cargonerds-local-dbmigrator no cargonerds/dbmigrator
sqlserver StatefulSet + Service cargonerds-local-sqlserver no mcr.microsoft.com/azure-sql-edge:1.0.7
redis Deployment + Service cargonerds-local-redis no redis:7.2.2-alpine
rabbitmq StatefulSet + Service cargonerds-local-rabbitmq no rabbitmq:3.12.7-management-alpine

All application Services are port: 80 ClusterIP; the infra Services use their native ports (SQL 1433, Redis 6379, RabbitMQ 15672/5672). The four web-facing services each get an NGINX Ingress; the API/auth/web hosts and the DbMigrator all expose containerPort: 80 and (except the migrator) an /health-status readiness probe.

flowchart TB
    subgraph ingress["NGINX Ingress (TLS: cargonerds-local-tls)"]
        I1["cargonerds-local-authserver"]
        I2["cargonerds-local-httpapihost"]
        I3["cargonerds-local-blazor"]
        I4["cargonerds-local-webpublic"]
    end

    I1 --> AUTH["authserver<br/>Deployment :80"]
    I2 --> API["httpapihost<br/>Deployment :80"]
    I3 --> BLAZOR["blazor<br/>Deployment :80 + ConfigMap"]
    I4 --> WEB["webpublic<br/>Deployment :80"]

    JOB["dbmigrator<br/>Job (run-once)"]

    subgraph infra["Infrastructure (ClusterIP only)"]
        SQL["sqlserver<br/>StatefulSet :1433"]
        REDIS["redis<br/>Deployment :6379"]
        MQ["rabbitmq<br/>StatefulSet :5672/:15672"]
    end

    AUTH --> SQL
    AUTH --> REDIS
    API --> SQL
    API --> REDIS
    API --> MQ
    API --> AUTH
    WEB --> SQL
    WEB --> API
    WEB --> AUTH
    BLAZOR -.browser calls.-> API
    BLAZOR -.browser calls.-> AUTH
    JOB --> SQL
    JOB --> REDIS

DbMigrator runs as a Kubernetes Job

Consistent with ABP's DbMigrator-as-a-service pattern, the migrator is not a long-running Deployment. It is a run-once batch/v1 Job that applies migrations and seed data, then exits:

charts/dbmigrator/templates/migrator.yaml (excerpt)
apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ .Release.Name }}-{{ .Chart.Name }}"
spec:
  backoffLimit: 10
  manualSelector: true
  selector:
    matchLabels:
      app: "{{ .Release.Name }}-{{ .Chart.Name }}"
  template:
    spec:
      activeDeadlineSeconds: 180
      restartPolicy: Never

The Job also seeds OpenIddict client registrations at migration time via env vars (OpenIddict__Applications__*) for Cargonerds_Web_Public, Cargonerds_Blazor, Cargonerds_App, Cargonerds_Mobile and Cargonerds_Swagger, wiring each client's RootUrl to the corresponding rendered host URL.

Job retries are not idempotent-friendly here

backoffLimit: 10 with activeDeadlineSeconds: 180 means up to ten retries inside a three-minute window. There is no Helm hook annotation (helm.sh/hook) on the Job, so on helm upgrade the Job is treated as an ordinary resource — re-running migrations is expected to be idempotent. If the migration overruns 180 s it is killed regardless of progress.

Configuration model

Global values flow down to subcharts

ABP's chart relies on Helm's global values mechanism: the parent sets global.* and every subchart template reads .Values.global.*. The base values.yaml carries cluster-wide defaults:

etc/helm/cargonerds/values.yaml
global:
  tlsSecret: "cargonerds-local-tls"
  abpStudioClient:
    studioUrl: "http://abp-studio-proxy:38271"

Per-release values (host names, connection string, environment, secrets) live in values.cargonerds-local.yaml — the file name encodes the release name (values.<ReleaseName>.yaml) and install.ps1 loads it explicitly:

etc/helm/cargonerds/values.cargonerds-local.yaml
global:
  hosts:
    dbmigrator: "[RELEASE_NAME]-dbmigrator"
    webpublic: "[RELEASE_NAME]-webpublic"
    httpapi: "[RELEASE_NAME]-httpapihost"
    blazor: "[RELEASE_NAME]-blazor"
    authserver: "[RELEASE_NAME]-authserver"
  connectionStrings:
    default: "Server=[RELEASE_NAME]-sqlserver,1433; Database=Cargonerds; User Id=sa; Password=myPassw@rd; TrustServerCertificate=True"
  dotnetEnvironment: "Staging"
  disablePII: "false"
  stringEncryptionDefaultPassPhrase: "vd7i8105O0jZ7xQl"
  abpStudioClient:
    isLinkEnabled: "true"

The [RELEASE_NAME] token

Host names and the connection string use a literal [RELEASE_NAME] placeholder that templates substitute with the real release name at render time, using Helm's replace:

- name: "ConnectionStrings__Default"
  value: "{{ .Values.global.connectionStrings.default | replace "[RELEASE_NAME]" .Release.Name }}"

This is how the SQL connection string resolves to the in-cluster service DNS name (cargonerds-local-sqlserver,1433) without hard-coding the release name into the chart. The same token feeds the cargonerds.hosts.* helpers in _helpers.tpl:

etc/helm/cargonerds/templates/_helpers.tpl (excerpt)
{{- define "cargonerds.hosts.httpapi" -}}
{{- print "https://" (.Values.global.hosts.httpapi | replace "[RELEASE_NAME]" .Release.Name) -}}
{{- end -}}

These helpers produce the public https://… URLs that get injected as ABP options (App__SelfUrl, AuthServer__Authority, RemoteServices__Default__BaseUrl, …) and reused as the Ingress host (after trimPrefix "https://").

ABP options as environment variables

Each .NET host template translates ABP's hierarchical configuration keys into double-underscore environment variables — the standard ASP.NET Core convention where __ maps to a config section nesting level. The HTTP API host is representative:

charts/httpapihost/templates/httpapihost.yaml (env excerpt)
env:
- name: "DOTNET_ENVIRONMENT"
  value: "{{ .Values.global.dotnetEnvironment }}"
- name: "App__SelfUrl"
  value: "{{ include "cargonerds.hosts.httpapi" . }}"
- name: "App__CorsOrigins"
  value: "https://*.Cargonerds.com,{{ include "cargonerds.hosts.blazor" . }}"
- name: "ConnectionStrings__Default"
  value: "{{ .Values.global.connectionStrings.default | replace "[RELEASE_NAME]" .Release.Name }}"
- name: "Redis__Configuration"
  value: "{{ .Release.Name }}-redis"
- name: "AuthServer__Authority"
  value: "http://{{ .Release.Name }}-authserver"
- name: "AuthServer__RequireHttpsMetadata"
  value: "false"
- name: "RabbitMQ__Connections__Default__HostName"
  value: "{{ .Release.Name }}-rabbitmq"
- name: "StringEncryption__DefaultPassPhrase"
  value: "{{ .Values.global.stringEncryptionDefaultPassPhrase }}"
- name: "AbpStudioClient__StudioUrl"
  value: "{{ .Values.global.abpStudioClient.studioUrl }}"

These keys map directly onto the same ABP/ASP.NET Core settings documented in the configuration reference and appsettings reference — the chart is simply another binding source for them. A few service-specific notes:

  • In-cluster vs. public URLs are deliberately split. Calls that stay inside the cluster use the plain service DNS name over HTTP (http://{{ .Release.Name }}-authserver, AuthServer__MetaAddress, RemoteServices__Default__BaseUrl), while browser-facing values use the https:// Ingress host from the cargonerds.hosts.* helpers. AuthServer__RequireHttpsMetadata is set to false so OIDC metadata can be fetched over the in-cluster HTTP address.
  • webpublic additionally sets AuthServer__IsOnK8s=true and carries the OpenIddict client secret (AuthServer__ClientSecret) from its subchart values.yaml (authServer.clientSecret: "1q2w3e*").
  • AbpStudioClient__* keys exist so the cluster can report back to ABP Studio's Kubernetes integration (the studioUrl points at the abp-studio-proxy). They are harmless if you are not using ABP Studio.

Blazor is configured via a ConfigMap, not env vars

The Blazor WebAssembly admin app is the exception. Because its configuration is served to the browser as a static wwwroot/appsettings.json, its subchart renders a ConfigMap and mounts a single file into the container:

charts/blazor/templates/blazor.yaml (excerpt)
volumeMounts:
- name: config-volume
  mountPath: /app/wwwroot/appsettings.json
  subPath: appsettings.json
volumes:
- name: config-volume
  configMap:
    name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap

The ConfigMap embeds the rendered App.SelfUrl, AuthServer.Authority/ClientId, and RemoteServices.*.BaseUrl so the WASM client knows where the API and auth server live. The Blazor Deployment has no readiness probe (it just serves static files).

Missing dbmigrator host helper + stray leading comma in _helpers.tpl

_helpers.tpl defines cargonerds.hosts.webpublic, …httpapi, …blazor and …authserver, but not cargonerds.hosts.dbmigrator, even though values.cargonerds-local.yaml declares a hosts.dbmigrator value. The migrator Job never needs a public host, so this is benign — but do not include "cargonerds.hosts.dbmigrator" anywhere or rendering will fail. Note also the file's first line is a stray ,, an artifact of the ABP Studio generator; Helm tolerates it because it is outside any define block, but it is surprising.

Building the images

The chart references locally-built images (cargonerds/authserver:latest, etc.) — there is no public registry. Build them first with the helper scripts:

# from etc/helm/
./build-all-images.ps1

build-all-images.ps1 calls build-image.ps1 once per host:

etc/helm/build-all-images.ps1
./build-image.ps1 -ProjectPath "../../src/Cargonerds.DbMigrator/Cargonerds.DbMigrator.csproj" -ImageName cargonerds/dbmigrator
./build-image.ps1 -ProjectPath "../../src/Cargonerds.Web.Public/Cargonerds.Web.Public.csproj" -ImageName cargonerds/webpublic
./build-image.ps1 -ProjectPath "../../src/Cargonerds.HttpApi.Host/Cargonerds.HttpApi.Host.csproj" -ImageName cargonerds/httpapihost
./build-image.ps1 -ProjectPath "../../src/Cargonerds.Blazor/Cargonerds.Blazor.csproj" -ImageName cargonerds/blazor
./build-image.ps1 -ProjectPath "../../src/Cargonerds.AuthServer/Cargonerds.AuthServer.csproj" -ImageName cargonerds/authserver

For each project, build-image.ps1 runs dotnet publish -c Release, then docker build . -f Dockerfile -t <image>:<version> where the version defaults to a timestamp (yyyyMMdd.HHmmss). It then writes the freshly built tag into the generated cargonerds/values.localdev.yaml so the install picks up exactly that image:

cargonerds/values.localdev.yaml (generated, gitignored)
httpapihost:
  image:
    tag: "20260623.121530"

This file is created on demand (and .gitignored). To rebuild just one service for a faster loop, call build-image.ps1 directly, e.g.:

./build-image.ps1 -ProjectPath "../../src/Cargonerds.HttpApi.Host/Cargonerds.HttpApi.Host.csproj" -ImageName cargonerds/httpapihost

ABP Studio can build the images too

The README notes you can build one or all images from ABP Studio's UI instead of the scripts; the chart layout is the same either way.

Installing & upgrading

Prerequisites

Per the chart's README.md:

  1. Docker Desktop with Kubernetes enabled.
  2. Helm installed.
  3. NGINX ingress controller in the cluster:

    helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
    helm repo update
    helm upgrade --install --version=4.0.19 ingress-nginx ingress-nginx/ingress-nginx
    
  4. mkcert for the local TLS certificate (see TLS below).

  5. The built images (see Building the images).

TLS secret

The Ingresses reference a single TLS secret named by global.tlsSecret (default cargonerds-local-tls). create-tls-secrets.ps1 uses mkcert to mint a cert covering all the local hostnames, creates the namespace, and stores the cert as a Kubernetes TLS secret:

etc/helm/create-tls-secrets.ps1 (excerpt)
mkcert --cert-file "${Namespace}.pem" --key-file "${Namespace}-key.pem" `
  "${Namespace}" "${Namespace}-authserver" "${Namespace}-blazor" `
  "${Namespace}-httpapihost" "${Namespace}-webpublic"
kubectl create namespace ${Namespace}
kubectl create secret tls -n ${Namespace} ${BaseNamespace}-tls --cert=./${Namespace}.pem --key=./${Namespace}-key.pem

Run mkcert -install first; a pre-generated cert is also committed

mkcert -install adds mkcert's root CA to the local trust store so browsers trust the generated cert. The repo also ships a committed cargonerds-local.pem / cargonerds-local-key.pem pair, but regenerating with create-tls-secrets.ps1 is the documented flow. The Ingress annotation cert-manager.io/cluster-issuer: "letsencrypt" is present on every Ingress but is inert locally unless cert-manager + a letsencrypt ClusterIssuer happen to be installed; the tls.secretName (the mkcert secret) is what actually serves HTTPS.

Install / upgrade

install.ps1 is a thin wrapper around helm upgrade --install:

etc/helm/install.ps1 (excerpt)
helm upgrade --install ${FinalReleaseName} ${ChartName} `
  --namespace ${Namespace} --create-namespace `
  --set global.dotnetEnvironment=${DotnetEnvironment} `
  -f "cargonerds/values.localdev.yaml" `
  -f "$ChartName/values.${ReleaseName}.yaml"

Defaults: ChartName=cargonerds, Namespace=cargonerds-local, ReleaseName=cargonerds-local, DotnetEnvironment=Staging. So from etc/helm/:

./install.ps1

The -User parameter lets multiple developers share a cluster: passing -User alice suffixes both the namespace and the release name (cargonerds-local-alice), giving each user an isolated copy. Note that the two values files loaded are values.localdev.yaml (generated image tags) and values.<ReleaseName>.yaml — the --set global.dotnetEnvironment override and these files are layered on top of each subchart's own values.yaml and the parent values.yaml.

Hosts file

Because the Ingresses route by hostname and there is no DNS, add the local hostnames to your machine's hosts file (C:\Windows\System32\drivers\etc\hosts):

127.0.0.1 cargonerds-local-web
127.0.0.1 cargonerds-local-webgateway
127.0.0.1 cargonerds-local-authserver

Hosts-file entries in the README don't match the rendered Ingress hosts

The ABP-Studio-generated README lists cargonerds-local-web / -webgateway / -authserver, but the chart actually renders Ingress hosts cargonerds-local-authserver, cargonerds-local-httpapihost, cargonerds-local-blazor and cargonerds-local-webpublic (from values.cargonerds-local.yaml). Add entries for the hosts the chart really exposes — e.g. add 127.0.0.1 cargonerds-local-httpapihost, cargonerds-local-blazor, cargonerds-local-webpublic. The README appears to be a stock template that was not fully updated for this solution; ABP Studio's Kubernetes integration would otherwise manage these entries automatically.

Browse & uninstall

After the pods are ready, browse the relevant host over HTTPS (e.g. https://cargonerds-local-blazor). To tear the release down:

etc/helm/uninstall.ps1
helm uninstall ${ReleaseName} --namespace ${Namespace}

uninstall.ps1 accepts the same -Namespace / -ReleaseName / -User parameters as install.

Gotchas

Insecure secrets are baked into the chart

The chart commits plaintext credentials intended only for a throwaway local cluster: SQL sa password myPassw@rd (sqlserver/values.yaml and the connection string in values.cargonerds-local.yaml), the StringEncryption__DefaultPassPhrase (vd7i8105O0jZ7xQl), and the OpenIddict client secret 1q2w3e* (dbmigrator / webpublic subchart values). Never reuse these values or this chart against any shared or internet-reachable cluster.

  • Images are .NET 9, the solution is .NET 10. build-image.ps1 runs dotnet publish -c Release against each host's Dockerfile, and those Dockerfiles use FROM mcr.microsoft.com/dotnet/aspnet:9.0 and COPY bin/Release/net9.0/publish/. The solution targets net10.0. This mismatch is a known issue across all the container paths — verify the images build and run before relying on them. See the deployment overview Gotchas.
  • No dependencies: / Chart.lock. Subcharts are vendored directly under charts/, so there is nothing to helm dependency build. Conversely, you cannot bump a subchart by editing a version constraint — you edit the in-tree subchart.
  • No resource requests/limits, replicas, autoscaling, or PodDisruptionBudgets. Every workload runs a single replica with default scheduling. The infra StatefulSets (sqlserver, rabbitmq) declare no volumeClaimTemplates, so their data is ephemeral — deleting the pod loses the database. This is fine for local dev and unsuitable for anything else.
  • values.localdev.yaml is required-but-generated. install.ps1 always passes -f cargonerds/values.localdev.yaml. If you have never run the build scripts, the install script creates an empty file so Helm doesn't error, but the image tags then fall back to each subchart's tag: "latest" — which won't exist locally unless you tagged your build latest. Run build-all-images.ps1 first.
  • cert-manager annotation is misleading locally. Every Ingress carries cert-manager.io/cluster-issuer: "letsencrypt", but local clusters have neither cert-manager nor a public DNS name Let's Encrypt could validate. HTTPS works only because of the mkcert-backed tls.secretName. Ignore the annotation locally.
  • _helpers.tpl quirks. A stray leading comma on line 1 and the missing cargonerds.hosts.dbmigrator define (see the note above) are generator artifacts. Both are currently harmless but easy to trip over when extending the chart.
  • README/hosts mismatch. The committed README is a stock ABP Studio template; its hosts-file list and some prose (e.g. "microservice template") don't fully match this specific chart. Trust the rendered templates over the README.

See also