A production-ready Helm chart for the Docker Example Voting App, built to demonstrate modern Kubernetes platform engineering practices, environment-aware configuration, and sensible defaults.
The Voting App is a multi-tier microservices application consisting of five components:
- Vote (Python/Flask): Frontend UI to cast votes.
- Result (Node.js): Frontend UI to view real-time results.
- Worker (.NET): Background processor that dequeues votes and updates the database.
- Redis: In-memory cache used as a vote queue.
- PostgreSQL: Persistent relational database storing the final vote tallies.
Architecture Flow:
[Vote UI] --> (Redis Queue) --> [Worker] --> (PostgreSQL) <-- [Result UI]
- Kubernetes 1.24+
- Helm 3.0+
- (Optional) Ingress Controller (e.g., NGINX, Traefik) for staging/production deployments.
Deploy the application locally using the development overlay (exposes frontends via LoadBalancer on localhost):
git clone <repository-url>
cd example-voting-app
# Install the chart using the dev profile
helm install my-release . -f values-dev.yaml -n voting --create-namespace--create-namespace creates the target namespace if it doesn't already exist. Each environment should use its own namespace (e.g. -n staging, -n production) — this chart provisions two fixed-name Services (redis, db) that conflict if two releases share a namespace; see Limitations.
Access the applications based on the output of the Helm installation notes. To view the notes again at any time:
helm status my-release -n votingThe base values.yaml file provides safe, minimal defaults. All meaningful parameters are documented below, grouped by component.
| Key | Default | Description |
|---|---|---|
ingress.enabled |
false |
Enable to render an Ingress resource for the frontends. |
ingress.className |
"" |
Ingress controller class (e.g., nginx, traefik). |
ingress.annotations |
{} |
Arbitrary Ingress annotations (e.g., rate limiting, auth middleware). |
ingress.hosts.vote |
vote.example.com |
Hostname routed to the Vote frontend. |
ingress.hosts.result |
result.example.com |
Hostname routed to the Result frontend. |
ingress.tls |
[] |
TLS configuration; provide secretName and hosts per entry. |
| Key | Default | Description |
|---|---|---|
<svc>.replicaCount |
1 |
Number of stateless frontend pods. Ignored when autoscaling.enabled: true. |
<svc>.image.repository |
(service-specific) | Container image repository. |
<svc>.image.tag |
latest |
Image tag. Use a pinned digest or CI-injected SHA in production. |
<svc>.image.pullPolicy |
Always |
Always for floating tags; set IfNotPresent when using pinned tags. |
<svc>.service.type |
ClusterIP |
Service type. Override to LoadBalancer in dev or NodePort for bare-metal. |
<svc>.service.port |
80 |
Service port. |
<svc>.service.nodePort |
"" |
Node port (only used when service.type: NodePort). |
<svc>.rollingUpdate.maxSurge |
1 |
Extra pod headroom during a rolling update. |
<svc>.rollingUpdate.maxUnavailable |
0 |
Ensures zero-downtime rollout. |
<svc>.resources.requests.cpu |
100m |
CPU requested per pod. |
<svc>.resources.requests.memory |
128Mi |
Memory requested per pod. |
<svc>.resources.limits.cpu |
250m |
CPU cap per pod. |
<svc>.resources.limits.memory |
256Mi |
Memory cap per pod. |
<svc>.livenessProbe |
(HTTP GET /) |
Liveness probe configuration block. |
<svc>.readinessProbe |
(HTTP GET /) |
Readiness probe configuration block. |
<svc>.startupProbe |
{} |
Startup probe stub — configure for slow-starting images. |
<svc>.terminationGracePeriodSeconds |
30 |
Grace period before SIGKILL. Rendered unconditionally; 0 is honoured. |
<svc>.podSecurityContext |
runAsNonRoot: true, runAsUser: 1000 |
Pod-level security context. |
<svc>.containerSecurityContext |
allowPrivilegeEscalation: false, capabilities.drop: [ALL] |
Container-level security context. |
<svc>.extraEnv |
[] |
Additional environment variables injected into the container. |
<svc>.nodeSelector |
{} |
Node selector for scheduling. |
<svc>.tolerations |
[] |
Tolerations for tainted nodes. |
<svc>.affinity |
{} |
Affinity / anti-affinity rules. |
<svc>.autoscaling.enabled |
false |
Enable HPA. When true, replicaCount is omitted from the Deployment spec. |
<svc>.autoscaling.minReplicas |
1 |
HPA minimum replica count. |
<svc>.autoscaling.maxReplicas |
10 |
HPA maximum replica count. |
<svc>.autoscaling.targetCPUUtilisationPercentage |
80 |
HPA CPU utilisation target (%). |
| Key | Default | Description |
|---|---|---|
worker.replicaCount |
1 |
See idempotency note in Limitations. |
worker.image.tag |
latest |
Image tag. |
worker.image.pullPolicy |
Always |
Pull policy. |
worker.resources.limits.memory |
512Mi |
Minimum safe limit for the .NET CLR; do not lower below 512Mi. |
worker.extraEnv |
[] |
Additional environment variables injected into the container. |
worker.terminationGracePeriodSeconds |
30 |
Grace period before SIGKILL. |
| Key | Default | Description |
|---|---|---|
redis.enabled |
true |
Set false to use a pre-existing Redis instance (disables all Redis templates). |
redis.replicaCount |
1 |
Hard-capped at 1 by a fail guard. See Limitations. |
redis.image.tag |
7.2-alpine |
Pinned Redis version. Update deliberately; do not use the bare alpine tag. |
redis.persistence.enabled |
false |
Enable PVC. If false, emptyDir is used and data is lost on pod restart. |
redis.persistence.size |
1Gi |
PVC storage request. |
redis.persistence.storageClass |
"" |
Storage class for the PVC ("" uses the cluster default). |
redis.updateStrategy |
type: Recreate |
Used only when persistence.enabled: true. RollingUpdate is used otherwise. |
redis.resources |
(see values.yaml) | CPU/memory requests and limits. |
redis.podSecurityContext |
runAsNonRoot: true, runAsUser: 999, fsGroup: 999 |
Pod security context. |
| Key | Default | Description |
|---|---|---|
db.enabled |
true |
Set false to use a pre-existing Postgres instance. |
db.replicaCount |
1 |
Hard-capped at 1 by a fail guard. |
db.image.tag |
15.7-alpine |
Pinned Postgres version. |
db.auth.username |
postgres |
Database username. |
db.auth.database |
"" |
Database name. Defaults to db.auth.username if empty. |
db.auth.password |
postgres |
Dev-only plaintext password. Leave empty and use existingSecret in staging/prod. |
db.auth.existingSecret |
"" |
Name of a pre-existing Secret. When set, the chart skips rendering a Secret entirely. |
db.auth.secretPasswordKey |
postgres-password |
Key within the Secret that holds the password. |
db.persistence.enabled |
false |
Enable PVC. |
db.persistence.size |
8Gi |
PVC storage request. |
db.updateStrategy |
type: Recreate |
Used only when persistence.enabled: true. |
db.resources |
(see values.yaml) | CPU/memory requests and limits. |
db.podSecurityContext |
fsGroup: 70 |
Sets volume GID to 70 (postgres user). runAsNonRoot is intentionally omitted — the official postgres image must start as root to initialise the data directory before dropping privileges via gosu. See Limitations. |
Interview Question: What would you parameterise differently for prod vs dev?
This chart uses overlay values files to explicitly separate environmental concerns. The base values.yaml is intentionally safe and minimalist (1 replica, emptyDirs, ClusterIP, ingress disabled).
We parameterise three distinct environments:
- Dev (
values-dev.yaml):- Overrides
service.typetoLoadBalancerfor reliablelocalhostbrowser access in Docker Desktop. - Uses
latestimage tags. - Generates the DB password directly from a plaintext
passwordvalue (safe only for local dev).
- Overrides
- Staging (
values-staging.yaml):- Enables Ingress for host-based routing.
- Pins image tags explicitly (e.g.,
1.0.0) to ensure reproducibility. - Enables PVCs for stateful data retention.
- Sets
db.auth.existingSecretto source credentials externally.
- Production (
values-prod.yaml):- Scales
replicaCountto 3 for stateless frontends and 2 for workers. - Increases CPU/Memory requests and limits.
- Enables Ingress with TLS certificate routing.
- Requires
existingSecretfor database credentials.
- Scales
This chart enforces a strict two-tier secrets management strategy:
- Development: You may define a plaintext password in
values-dev.yaml(db.auth.password). The chart will render a Kubernetes Secret automatically. - Staging/Production: You must provide a pre-existing Secret by setting
db.auth.existingSecretin your values overlay. The chart skips creating the Secret and instead references yours directly. A fail guard prevents deploying without one or the other.
Manual Secret Creation (Prod/Staging):
kubectl create secret generic voting-app-db-secret \
--from-literal=postgres-password=<your-secure-password> \
-n <namespace>Note: For GitOps teams using the External Secrets Operator (ESO), see the reference manifest in docs/externalsecret-db.yaml.
To expose the UI to the internet, enable the Ingress resource and specify your cluster's ingress class and hostnames.
ingress:
enabled: true
className: "nginx"
hosts:
vote: vote.example.com
result: result.example.comFor TLS, pass your certificate Secret name under ingress.tls.
By default, Redis and Postgres use emptyDir volumes (data is lost if the pod restarts). This is designed to lower the barrier to evaluation on minimal local clusters.
To persist data, enable PVCs via redis.persistence.enabled and db.persistence.enabled. You can optionally specify a storageClass.
Important: PVCs survive a helm uninstall. To completely clean up data, you must manually delete the PVCs.
redis.replicaCount and db.replicaCount are strictly capped at 1.
If you attempt to increase the replica count for the database or cache in values.yaml, the Helm chart will actively reject the deployment via a fail guard.
Why? Redis and Postgres are configured in this chart as basic Deployments sharing a single Service. Scaling them to > 1 creates multiple independent, isolated instances (split-brain). Writes would be randomly distributed across instances with no replication between them, leading to silent data corruption.
The HA Upgrade Path:
This chart supports vertical scaling (increasing resources) for stateful services. If you require High Availability, the bundled deployments should be disabled (db.enabled: false, redis.enabled: false) and replaced with proper replication topologies:
- Redis HA: Requires Redis Sentinel (e.g.,
bitnami/rediswithsentinel.enabled: true). - Postgres HA: Requires streaming replication with Patroni or similar (e.g.,
bitnami/postgresqlwithreadReplicas.replicaCount: N).
The source code for the Vote, Result, and Worker applications have the hostnames redis and db hardcoded into them and do not accept environment variable overrides.
To support Helm's dynamic service naming, this chart provisions ClusterIP alias services named redis and db that forward to the real pods. Because these alias names are static, you can only deploy one instance of this chart per Kubernetes namespace. Deploy each environment to a separate namespace (e.g. staging, production) or use a separate cluster.
The official postgres:*-alpine image requires starting as root to chmod 700 the data directory and then drops to the postgres user (UID 70) via gosu. Enforcing runAsNonRoot: true or dropping CAP_SETUID prevents this privilege drop and causes an immediate CrashLoopBackOff. The chart therefore sets only fsGroup: 70 on the db pod, leaving user management to the image itself.
For a fully non-root postgres deployment, replace the bundled db with bitnami/postgresql or CloudNativePG, both of which are purpose-built to run as UID 1001 from the start — which is exactly the HA upgrade path described above.
Unlike the Redis and Postgres fail guards, worker.replicaCount is not capped. The .NET worker dequeues jobs from Redis and writes the result to Postgres. If the worker is not idempotent (i.e. processing the same vote twice produces incorrect tallies), running multiple replicas will produce duplicate writes. Verify idempotency before increasing worker.replicaCount beyond 1 in values-prod.yaml.
To apply configuration changes or update to a new chart version, use helm upgrade:
helm upgrade my-release . -f values-prod.yaml -n productionNote: This chart utilises zero-downtime rolling updates (maxSurge: 1, maxUnavailable: 0) for stateless applications, and a Recreate strategy for stateful applications to prevent ReadWriteOnce PVC conflicts.
The staging and prod overlays pin image tags to "1.0.0" as a placeholder. In CI/CD, inject the real tag at deploy time using --set:
helm upgrade my-release . \
-f values-prod.yaml \
-n production \
--set vote.image.tag=$GIT_SHA \
--set result.image.tag=$GIT_SHA \
--set worker.image.tag=$GIT_SHAReplace $GIT_SHA with your pipeline's short commit SHA (e.g. git rev-parse --short HEAD). This ensures every deployment is traceable to a specific commit and the app.kubernetes.io/version label reflects the deployed version.
helm uninstall my-release -n <namespace>Reminder: If persistence is enabled, PVCs are not automatically deleted. Run kubectl delete pvc -l app.kubernetes.io/instance=my-release -n <namespace> to clear data.
Interview Question: Walk us through your chart... Why did you structure it this way?
The architecture of this chart was driven by pragmatism, strict decoupling of environments, and standard Helm conventions. Full architectural decision records (ADRs) are available in docs/ADR.md.
- Single Chart Topology (ADR-001): The five services are tightly coupled, sharing a namespace and internal DNS. Wrapping them in a single chart rather than building complex sub-charts keeps the deployment logic flat, readable, and avoids over-engineering for a demo-scale application.
- ClusterIP & Ingress Strategy (ADR-002): We explicitly avoid LoadBalancer services. In a real platform, provisioning a cloud load balancer per service is cost-prohibitive and unscalable. Defaulting to
ClusterIPwith an optionalIngressrule ensures all traffic flows efficiently through one shared ingress controller. - InitContainers for Readiness (ADR-008): Kubernetes lacks docker-compose's
depends_on: condition: service_healthy. Instead of allowing the worker and frontends to crash-loop blindly while DBs start, we utilizeinitContainersexecutingpg_isreadyandredis-cli pingto sequence startup cleanly. - Fail Guards for Scaling (ADR-006): Rather than silently allowing users to corrupt data by scaling un-replicated databases, the chart introduces validation hooks in
_helpers.tpl. If an anti-pattern is detected, the deployment is hard-stopped with actionable CLI feedback.
Interview Question: What would you change if this was going into a shared platform used by 20+ services?
If migrating this workload to an enterprise shared platform supporting multiple developer teams, the following architectural upgrades would be required:
Plaintext passwords, even in local environments, are an anti-pattern on a mature platform.
- Recommendation: Migrate entirely to the External Secrets Operator (ESO).
- Implementation: Platform engineers would provision a
ClusterSecretStoreconnected to AWS Secrets Manager or HashiCorp Vault. The Helm chart would no longer contain any Secret templates, instead deployingExternalSecretcustom resources (seedocs/externalsecret-db.yamlfor a reference implementation). - Alternative: Mozilla SOPS can be used for simpler GitOps environments, but ESO is preferable for dynamic rotation and avoiding KMS key management in CI/CD.
Currently, any pod in the namespace can communicate with any other pod.
- Implementation: Introduce default-deny
NetworkPolicyresources. Vote should only communicate with Redis. Worker with Redis and Postgres. Result with Postgres.
To protect availability during platform-level node upgrades or cluster maintenance, we would introduce PDBs requiring minAvailable: 1 or 66% for the Vote, Result, and Worker stateless deployments.
The chart currently contains an HPA stub based on CPU utilization.
- Implementation: The
workerdeployment scales based on the depth of the Redis queue, not CPU. We would replace the native Kubernetes HPA with a KEDA (Kubernetes Event-driven Autoscaling) ScaledObject, polling the Redis list length to scale workers from 0 to N dynamically.
- Implementation: Ship a
ServiceMonitorcustom resource (for the Prometheus Operator) bundled alongside the app to scrape metrics. The application images would need updating to expose a/metricsendpoint. - The
.NET workercurrently lacks a/healthzendpoint, preventing proper Kubernetes liveness probes. This requires application-level codebase changes.
Introduce a values.schema.json file at the root of the chart to validate value types, required fields, and constraints natively during helm install, shifting error detection to the earliest possible point.
If the organization required the platform to host stateful data rather than deferring to Cloud managed services (e.g., AWS RDS, ElastiCache), the basic Deployments for Redis and Postgres would be retired. We would import community operator charts (like CloudNativePG or bitnami/redis) utilizing StatefulSets, Quorum configurations, and volumeClaimTemplates for true High Availability.