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:
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:
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:
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:
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:
{{- 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:
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 thehttps://Ingress host from thecargonerds.hosts.*helpers.AuthServer__RequireHttpsMetadatais set tofalseso OIDC metadata can be fetched over the in-cluster HTTP address. webpublicadditionally setsAuthServer__IsOnK8s=trueand carries the OpenIddict client secret (AuthServer__ClientSecret) from its subchartvalues.yaml(authServer.clientSecret: "1q2w3e*").AbpStudioClient__*keys exist so the cluster can report back to ABP Studio's Kubernetes integration (thestudioUrlpoints at theabp-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:
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:
build-all-images.ps1 calls build-image.ps1 once per host:
./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:
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:
- Docker Desktop with Kubernetes enabled.
- Helm installed.
-
NGINX ingress controller in the cluster:
-
mkcertfor the local TLS certificate (see TLS below). - 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:
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:
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/:
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:
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.ps1runsdotnet publish -c Releaseagainst each host'sDockerfile, and those Dockerfiles useFROM mcr.microsoft.com/dotnet/aspnet:9.0andCOPY bin/Release/net9.0/publish/. The solution targetsnet10.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 undercharts/, so there is nothing tohelm 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 novolumeClaimTemplates, so their data is ephemeral — deleting the pod loses the database. This is fine for local dev and unsuitable for anything else. values.localdev.yamlis required-but-generated.install.ps1always 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'stag: "latest"— which won't exist locally unless you tagged your buildlatest. Runbuild-all-images.ps1first.cert-managerannotation is misleading locally. Every Ingress carriescert-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-backedtls.secretName. Ignore the annotation locally._helpers.tplquirks. A stray leading comma on line 1 and the missingcargonerds.hosts.dbmigratordefine (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¶
- Deployment overview — the two production Azure paths and where Helm fits (local-only).
- Local deployment — Docker Compose alternative for offline full-stack local dev.
- Azure Container Apps — the Aspire/
azdephemeral + stage path. - Azure App Service — the production zip/slot pipeline.
- Database migrations — the DbMigrator-as-a-service pattern the Job implements.
- Configuration settings and appsettings reference — the ABP/ASP.NET Core keys the env vars and ConfigMap bind to.
- ABP docs: OpenIddict / auth server, Distributed event bus (RabbitMQ), Distributed cache (Redis), .NET Aspire integration.