Enterprise-grade Helm chart for deploying a full-stack web application on Kubernetes: React (Nginx) frontend + Python FastAPI backend + PostgreSQL via CloudNativePG.
Built and versioned with Gradle.
- Architecture
- Prerequisites
- Quick Start
- Building
- Testing
- Publishing
- Installing the Chart
- Configuration
- Versioning
- CI/CD
┌─────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
Internet ──► Ingress ──►│─► Frontend (Nginx, :80) │
│ │ │
│ └──► Backend (FastAPI, :8000)│
│ │ │
│ └──► PostgreSQL │
│ (CloudNativePG) │
└─────────────────────────────────────┘
| Component | Default Image | Port |
|---|---|---|
| Frontend | nginx:stable-alpine |
80 |
| Backend | python:3.11-slim |
8000 |
| Database | ghcr.io/cloudnative-pg/postgresql:18 |
5432 |
The chart creates all Kubernetes resources needed for a production deployment: Services, Deployments, ConfigMap, ServiceAccount, Ingress, HPA, PodDisruptionBudget, NetworkPolicy, and a CloudNativePG Cluster.
-
Kubernetes 1.20+
-
CloudNativePG operator installed before this chart:
helm repo add cnpg https://cloudnative-pg.github.io/charts helm upgrade --install cnpg cnpg/cloudnative-pg \ --namespace cnpg-system --create-namespace --wait
-
(Optional) An Ingress controller if
ingress.enabled: trueor per-component ingresses are used. For Docker Desktop:kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.1/deploy/static/provider/cloud/deploy.yaml
| Tool | Version |
|---|---|
| Java (JDK) | 21 (Temurin) |
| Gradle | 9.0.0 (via wrapper — no install needed) |
| Helm | 3.x |
| helm-unittest plugin | latest |
Install the helm-unittest plugin once:
helm plugin install https://github.com/helm-unittest/helm-unittest# 1. Validate and test
./gradlew check
# 2. Package
./gradlew helmPackage
# → build/helm/webapp-<version>.tgz
# 3. Install
helm install my-app build/helm/webapp-*.tgz \
--namespace my-app --create-namespaceThe build system is Gradle with custom plugins in buildSrc/. All Helm operations are wrapped as Gradle tasks.
| Task | Description |
|---|---|
helmLint |
helm lint — validates chart syntax |
helmTest |
helm template --debug — renders all templates, fails on errors |
helmUnitTest |
helm unittest — runs the unit test suites in src/test/helm/ |
helmPackage |
Packages the chart → build/helm/webapp-<version>.tgz |
helmSourceArchive |
Creates a source zip → build/helm/webapp-<version>-source.zip |
helmPublish |
Uploads the chart to Artifactory |
helmPublishGitHub |
Pushes the chart to GitHub Packages (ghcr.io) |
helmPublishLocal |
Copies the chart to a local file-based Helm repo |
helmTag |
Creates an annotated git tag at the resolved version |
helmRelease |
Creates a GitHub Release with the .tgz and source zip attached |
./gradlew check → helmLint + helmTest + helmUnitTest
./gradlew assemble → helmPackage
./gradlew build → check + assemble (full quality gate)
helmLint ──► helmTest ──────────────────► helmPackage ──┬──► helmPublish
│ │ ├──► helmPublishGitHub
│ helmUnitTest ───────────────► helmPackage ├──► helmPublishLocal
│ ├──► helmTag
└─────────────────────────────────────────────────── └──► helmRelease ◄── helmSourceArchive
Tests live in src/test/helm/webapp/tests/ and are run with helm-unittest.
./gradlew helmUnitTestTest suites:
| Suite | What it covers |
|---|---|
backend_deployment_test.yaml |
Backend Deployment — image, env vars, probes, security context |
frontend_deployment_test.yaml |
Frontend Deployment — image, probes, security context |
configmap_test.yaml |
Backend ConfigMap — all config keys rendered correctly |
database_cluster_test.yaml |
CloudNativePG Cluster — instances, storage, bootstrap config |
migrations_job_test.yaml |
Migration Job hook — lifecycle, command, resource inheritance |
networkpolicy_test.yaml |
NetworkPolicy — ingress/egress selectors |
serviceaccount_test.yaml |
ServiceAccount — creation toggle, token auto-mount |
./gradlew helmPublish \
-PartifactoryUrl=https://art.example.com \
-PartifactoryUsername=deployer \
-PartifactoryPassword=<api-key>Or set environment variables ARTIFACTORY_URL, ARTIFACTORY_USERNAME, ARTIFACTORY_PASSWORD.
./gradlew helmPublishGitHub \
-PgithubToken=ghp_xxx \
-PgithubOwner=my-orgInstall from OCI:
helm install my-app oci://ghcr.io/my-org/webapp --version <version>./gradlew helmPublishLocal
helm install my-app webapp --repo file://~/.helm/repository/local./gradlew helmRelease \
-PgithubToken=ghp_xxx \
-PgithubRepo=my-org/my-repoCreates a GitHub Release and uploads webapp-<version>.tgz and webapp-<version>-source.zip.
./gradlew helmTag -PhelmTagPush=trueCreates an annotated git tag and pushes it to origin. The next build auto-increments past this tag.
helm install my-app ./build/helm/webapp-<version>.tgz \
--namespace my-app --create-namespaceThe backend requires SECRET_KEY and FIRST_SUPERUSER_PASSWORD at runtime. Create the secret before installing:
kubectl create secret generic my-backend-secrets \
--from-literal=SECRET_KEY=$(openssl rand -hex 32) \
--from-literal=FIRST_SUPERUSER_PASSWORD='<strong-password>' \
--namespace my-appThen reference it in values:
backend:
existingSecret: my-backend-secretshelm upgrade my-app ./build/helm/webapp-<version>.tgz \
--namespace my-app --values my-values.yamlIf migrations.enabled: true, the migration Job runs as a pre-upgrade hook before new backend pods roll out.
helm uninstall my-app --namespace my-appPVCs created by CloudNativePG are not deleted by Helm uninstall. Delete them manually if no longer needed.
All options are documented in src/main/helm/webapp/values.yaml. Key sections are summarised below.
| Key | Default | Description |
|---|---|---|
global.imageRegistry |
"" |
Registry prefix applied to all component images |
global.imagePullSecrets |
[] |
Pull secrets applied to all pods |
global.storageClass |
"" |
Default StorageClass for all PVCs |
global.nameOverride |
"" |
Override chart name used in resource names |
global.fullnameOverride |
"" |
Override fully-qualified release name |
| Key | Default | Description |
|---|---|---|
frontend.enabled |
true |
Deploy the frontend |
frontend.image.repository |
nginx |
Image repository |
frontend.image.tag |
stable-alpine |
Image tag |
frontend.replicaCount |
1 |
Number of pods |
frontend.resources |
50m/64Mi → 200m/128Mi |
CPU/memory requests and limits |
frontend.autoscaling.enabled |
false |
Enable HPA |
frontend.pdb.enabled |
false |
Enable PodDisruptionBudget |
frontend.service.port |
80 |
Service port |
frontend.ingress.enabled |
false |
Enable per-component Ingress |
| Key | Default | Description |
|---|---|---|
backend.enabled |
true |
Deploy the backend |
backend.image.repository |
python |
Image repository |
backend.image.tag |
3.11-slim |
Image tag |
backend.replicaCount |
1 |
Number of pods |
backend.resources |
100m/256Mi → 500m/512Mi |
CPU/memory requests and limits |
backend.autoscaling.enabled |
false |
Enable HPA |
backend.pdb.enabled |
false |
Enable PodDisruptionBudget |
backend.service.port |
8000 |
Service port |
backend.existingSecret |
"" |
Secret with SECRET_KEY and FIRST_SUPERUSER_PASSWORD |
backend.config.projectName |
Web Application |
PROJECT_NAME env var |
backend.config.environment |
production |
ENVIRONMENT env var (local/staging/production) |
backend.config.apiV1Str |
/api/v1 |
API_V1_STR env var |
backend.config.corsOrigins |
"" |
BACKEND_CORS_ORIGINS env var (comma-separated) |
backend.config.frontendHost |
http://localhost |
FRONTEND_HOST env var |
backend.config.firstSuperuser |
admin@example.com |
FIRST_SUPERUSER env var |
backend.ingress.enabled |
false |
Enable per-component Ingress |
| Key | Default | Description |
|---|---|---|
database.enabled |
true |
Deploy the CloudNativePG Cluster |
database.instances |
1 |
Number of PostgreSQL instances (use 3 for HA) |
database.imageName |
ghcr.io/cloudnative-pg/postgresql:18 |
PostgreSQL image |
database.storage.size |
10Gi |
Data PVC size |
database.storage.storageClass |
"" |
StorageClass for data PVC |
database.walStorage.enabled |
false |
Dedicated WAL PVC (recommended for production) |
database.walStorage.size |
2Gi |
WAL PVC size |
database.bootstrap.initdb.database |
app |
Database name created on bootstrap |
database.bootstrap.initdb.owner |
app |
Database owner role |
database.monitoring.enablePodMonitor |
false |
Emit PodMonitor (requires Prometheus Operator) |
The operator auto-generates a secret <cluster-name>-app with keys host, port, username, password, dbname, uri, jdbc-uri. The backend reads these automatically.
Use this when both services share the same hostname. /api routes to the backend; / routes to the frontend.
| Key | Default | Description |
|---|---|---|
ingress.enabled |
false |
Enable the combined Ingress |
ingress.className |
nginx |
IngressClass name |
ingress.host |
example.com |
Hostname |
ingress.annotations |
{} |
Ingress annotations |
ingress.tls |
[] |
TLS configuration |
Example:
ingress:
enabled: true
className: nginx
host: app.example.com
tls:
- secretName: app-tls
hosts:
- app.example.com| Key | Default | Description |
|---|---|---|
migrations.enabled |
false |
Run migration Job on install/upgrade |
migrations.command |
["bash", "scripts/prestart.sh"] |
Command to execute |
migrations.activeDeadlineSeconds |
600 |
Max Job runtime |
migrations.backoffLimit |
2 |
Pod restart attempts before failure |
The Job is a Helm hook (post-install, pre-upgrade) using the backend image, so it shares the same DB environment variables and ConfigMap.
networkPolicy:
enabled: true # default: falseWhen enabled, enforces least-privilege traffic:
- PostgreSQL port 5432: only backend and migrations pods may connect
- Backend port: only frontend pods and Ingress controller traffic may connect
- Backend egress: DNS and database only
| Key | Default | Description |
|---|---|---|
serviceAccount.create |
true |
Create a dedicated ServiceAccount |
serviceAccount.automountServiceAccountToken |
false |
Disable token auto-mount |
serviceAccount.annotations |
{} |
e.g. IRSA (AWS) or Workload Identity (GCP) annotations |
global:
imageRegistry: my-registry.example.com
frontend:
image:
repository: my-org/frontend
tag: "1.2.3"
replicaCount: 2
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
backend:
image:
repository: my-org/backend
tag: "1.2.3"
replicaCount: 2
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
existingSecret: my-backend-secrets
config:
projectName: "My App"
environment: production
corsOrigins: "https://app.example.com"
frontendHost: "https://app.example.com"
database:
instances: 3
storage:
size: 50Gi
walStorage:
enabled: true
size: 20Gi
ingress:
enabled: true
className: nginx
host: app.example.com
tls:
- secretName: app-tls
hosts:
- app.example.com
migrations:
enabled: true
networkPolicy:
enabled: trueChart versions are resolved automatically from baseVersion in gradle.properties and existing git tags. No manual version bumping is needed.
The number of components in baseVersion controls which SemVer part is auto-incremented:
baseVersion |
Increments | Example sequence |
|---|---|---|
1 |
MINOR | 1.0.0 → 1.1.0 → 1.2.0 |
0.2 |
PATCH | 0.2.0 → 0.2.1 → 0.2.2 |
0.1.0 |
PATCH (floor-clamped) | 0.1.0 → 0.1.1 → 0.1.2 |
Resolution rules:
- No matching git tags → version equals
baseVersion - Matching tags found →
max(tag) + 1 main/release/*branches → clean version (e.g.0.1.3)- Any other branch → snapshot with short git SHA (e.g.
0.1.3-a1b2c3d)
To start a new version line, change baseVersion — the counter resets automatically.
To pin a one-off version:
./gradlew helmPackage -PchartVersion=0.1.99./gradlew helmPackage # resolves version, packages chart
./gradlew helmPublishGitHub # publishes to ghcr.io
./gradlew helmTag -PhelmTagPush=true # creates git tag → next build auto-increments
./gradlew helmRelease # creates GitHub Release with artifactsThe GitHub Actions workflow at .github/workflows/helm-ci.yml runs on every push.
check — runs on all branches:
- Checks out repository with full git history (required for tag scanning)
- Sets up Java 21 and Helm with helm-unittest
- Runs
./gradlew check(lint + template render + unit tests)
publish — runs on main and release/* branches only, after check passes:
- Packages the chart and source archive
- Publishes to GitHub Packages (
ghcr.io) - Creates an annotated git tag and pushes to origin
- Creates a GitHub Release with chart and source attachments
| Secret | Used by |
|---|---|
GITHUB_TOKEN |
Auto-supplied by GitHub Actions (push tags, create releases, push to ghcr.io) |
ARTIFACTORY_URL |
helmPublish task (optional — only if publishing to Artifactory) |
ARTIFACTORY_USERNAME |
helmPublish task |
ARTIFACTORY_PASSWORD |
helmPublish task |
.
├── build.gradle.kts # Root build — applies all helm-* plugins
├── settings.gradle.kts # Project name: webapp-helm
├── gradle.properties # baseVersion, helmChartDir, helmTestDir, linting flags
├── buildSrc/ # Custom Gradle plugins (Kotlin DSL)
│ └── src/main/kotlin/
│ ├── HelmContext.kt # Extension holding chart metadata and resolved version
│ ├── HelmHelpers.kt # Utilities: git, versioning, property resolution
│ ├── helm-base.gradle.kts
│ ├── helm-lint.gradle.kts
│ ├── helm-test.gradle.kts
│ ├── helm-package.gradle.kts
│ ├── helm-publish.gradle.kts
│ ├── helm-publish-github.gradle.kts
│ ├── helm-publish-local.gradle.kts
│ ├── helm-release.gradle.kts
│ └── helm-tag.gradle.kts
├── src/
│ ├── main/helm/webapp/ # Chart source
│ │ ├── Chart.yaml
│ │ ├── values.yaml
│ │ └── templates/
│ │ ├── _helpers.tpl
│ │ ├── configmap.yaml
│ │ ├── serviceaccount.yaml
│ │ ├── networkpolicy.yaml
│ │ ├── ingress.yaml # Combined path-based ingress
│ │ ├── frontend/ # Deployment, Service, Ingress, HPA, PDB
│ │ ├── backend/ # Deployment, Service, Ingress, HPA, PDB
│ │ ├── database/ # CloudNativePG Cluster
│ │ └── migrations/ # Helm hook Job
│ └── test/helm/webapp/tests/ # helm-unittest suites
└── .github/workflows/
└── helm-ci.yml