Skip to content

xo477/voting-app

Repository files navigation

Docker Example Voting App — Helm Chart

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.


1. Overview

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]

2. Prerequisites

  • Kubernetes 1.24+
  • Helm 3.0+
  • (Optional) Ingress Controller (e.g., NGINX, Traefik) for staging/production deployments.

3. Quick Start

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 voting

4. Configuration Reference

The base values.yaml file provides safe, minimal defaults. All meaningful parameters are documented below, grouped by component.

Global / Ingress

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.

Vote / Result (apply to both; substitute vote / result)

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 (%).

Worker

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.

Redis

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.

PostgreSQL

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.

5. Environment Profiles

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.type to LoadBalancer for reliable localhost browser access in Docker Desktop.
    • Uses latest image tags.
    • Generates the DB password directly from a plaintext password value (safe only for local dev).
  • 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.existingSecret to source credentials externally.
  • Production (values-prod.yaml):
    • Scales replicaCount to 3 for stateless frontends and 2 for workers.
    • Increases CPU/Memory requests and limits.
    • Enables Ingress with TLS certificate routing.
    • Requires existingSecret for database credentials.

6. Secrets Management

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.existingSecret in 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.


7. Ingress

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.com

For TLS, pass your certificate Secret name under ingress.tls.


8. Persistence

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.


9. Limitations & HA

Scaling Stateful Services

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/redis with sentinel.enabled: true).
  • Postgres HA: Requires streaming replication with Patroni or similar (e.g., bitnami/postgresql with readReplicas.replicaCount: N).

Hardcoded Application Hostnames

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.

PostgreSQL Security Context

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.

Worker Replica Idempotency

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.


10. Upgrading

To apply configuration changes or update to a new chart version, use helm upgrade:

helm upgrade my-release . -f values-prod.yaml -n production

Note: 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.

CI/CD Tag Injection

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_SHA

Replace $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.


11. Uninstalling

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.


12. Design Decisions

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 ClusterIP with an optional Ingress rule 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 utilize initContainers executing pg_isready and redis-cli ping to 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.

13. Roadmap — Future Architecture

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:

1. Secrets Management Migration

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 ClusterSecretStore connected to AWS Secrets Manager or HashiCorp Vault. The Helm chart would no longer contain any Secret templates, instead deploying ExternalSecret custom resources (see docs/externalsecret-db.yaml for 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.

2. Network Policies (Zero Trust)

Currently, any pod in the namespace can communicate with any other pod.

  • Implementation: Introduce default-deny NetworkPolicy resources. Vote should only communicate with Redis. Worker with Redis and Postgres. Result with Postgres.

3. Pod Disruption Budgets (PDBs)

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.

4. Advanced Autoscaling

The chart currently contains an HPA stub based on CPU utilization.

  • Implementation: The worker deployment 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.

5. Observability Integration

  • Implementation: Ship a ServiceMonitor custom resource (for the Prometheus Operator) bundled alongside the app to scrape metrics. The application images would need updating to expose a /metrics endpoint.
  • The .NET worker currently lacks a /healthz endpoint, preventing proper Kubernetes liveness probes. This requires application-level codebase changes.

6. JSON Schema Validation

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.

7. StatefulSet Migration for Data Tiers

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages