From 3f5319384541b256e5927a2719a405abd540dcee Mon Sep 17 00:00:00 2001 From: Tuan-Dat Tran Date: Mon, 23 Feb 2026 23:08:30 +0100 Subject: [PATCH] docs: add Helm chart design and implementation plan --- docs/plans/2026-02-23-helm-chart-design.md | 491 +++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 docs/plans/2026-02-23-helm-chart-design.md diff --git a/docs/plans/2026-02-23-helm-chart-design.md b/docs/plans/2026-02-23-helm-chart-design.md new file mode 100644 index 0000000..67cd76b --- /dev/null +++ b/docs/plans/2026-02-23-helm-chart-design.md @@ -0,0 +1,491 @@ +# Helm Chart Design + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Helm chart for Kubernetes deployment as an alternative to Docker Compose. + +**Architecture:** Single Helm chart deploying frontend (nginx) and backend (Node.js) services with PVC for SQLite persistence and Ingress for external access. + +**Tech Stack:** Helm 3, Kubernetes + +--- + +## Chart Structure + +``` +helm/cv-app/ +├── Chart.yaml +├── values.yaml +├── templates/ +│ ├── _helpers.tpl +│ ├── frontend-deployment.yaml +│ ├── frontend-service.yaml +│ ├── backend-deployment.yaml +│ ├── backend-service.yaml +│ ├── ingress.yaml +│ ├── configmap.yaml +│ ├── secret.yaml +│ ├── pvc.yaml +│ └── NOTES.txt +└── .helmignore +``` + +## Components + +### Frontend + +- **Deployment**: nginx serving built React static files +- **Service**: ClusterIP on port 80 +- **Environment**: API URL from ConfigMap + +### Backend + +- **Deployment**: Node.js Express server +- **Service**: ClusterIP on port 3001 +- **Volume**: PVC mount at `/app/data` for SQLite +- **Environment**: Port, DB path, auth config from ConfigMap/Secret + +### Ingress + +- Routes `/` → frontend service +- Routes `/api` → backend service +- Configurable TLS +- Supports nginx, traefik ingress controllers + +### Persistence + +- PVC for SQLite database +- Configurable storage class +- 1Gi default size + +## Values Schema + +```yaml +frontend: + replicaCount: 1 + image: + repository: username/cv-app + tag: latest + pullPolicy: IfNotPresent + resources: {} + +backend: + replicaCount: 1 + image: + repository: username/cv-app-backend + tag: latest + pullPolicy: IfNotPresent + auth: + mode: simple + keycloak: + url: "" + realm: "" + clientId: "" + resources: {} + +persistence: + enabled: true + size: 1Gi + storageClass: "" + +ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: cv.local + paths: + - path: / + service: frontend + - path: /api + service: backend + tls: [] + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} +podSecurityContext: {} +securityContext: {} + +nodeSelector: {} +tolerations: [] +affinity: {} +``` + +## Templates + +### _helpers.tpl + +```yaml +{{- define "cv-app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "cv-app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cv-app.labels" -}} +helm.sh/chart: {{ include "cv-app.chart" . }} +{{ include "cv-app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "cv-app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cv-app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "cv-app.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cv-app.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} +``` + +### frontend-deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cv-app.fullname" . }}-config + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} +``` + +### frontend-service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-frontend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend +``` + +### backend-deployment.yaml + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "cv-app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + labels: + {{- include "cv-app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: http + containerPort: 3001 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "cv-app.fullname" . }}-config + - secretRef: + name: {{ include "cv-app.fullname" . }}-secret + volumeMounts: + - name: data + mountPath: /app/data + livenessProbe: + httpGet: + path: /health + port: http + readinessProbe: + httpGet: + path: /health + port: http + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "cv-app.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- end }} +``` + +### backend-service.yaml + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cv-app.fullname" . }}-backend + labels: + {{- include "cv-app.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - port: 3001 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cv-app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend +``` + +### ingress.yaml + +```yaml +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cv-app.fullname" . }} + labels: + {{- include "cv-app.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: Prefix + backend: + service: + name: {{ include "cv-app.fullname" $ }}-{{ .service }} + port: + number: {{ if eq .service "frontend" }}80{{ else }}3001{{ end }} + {{- end }} + {{- end }} +{{- end }} +``` + +### configmap.yaml + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cv-app.fullname" . }}-config + labels: + {{- include "cv-app.labels" . | nindent 4 }} +data: + PORT: "3001" + DB_PATH: "/app/data/cv.db" + AUTH_MODE: {{ .Values.backend.auth.mode | quote }} + {{- if eq .Values.backend.auth.mode "keycloak" }} + KEYCLOAK_URL: {{ .Values.backend.keycloak.url | quote }} + KEYCLOAK_REALM: {{ .Values.backend.keycloak.realm | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.backend.keycloak.clientId | quote }} + {{- end }} +``` + +### secret.yaml + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cv-app.fullname" . }}-secret + labels: + {{- include "cv-app.labels" . | nindent 4 }} +type: Opaque +data: + {{- if eq .Values.backend.auth.mode "simple" }} + JWT_SECRET: {{ randAlphaNum 32 | b64enc | quote }} + {{- end }} +``` + +### pvc.yaml + +```yaml +{{- if .Values.persistence.enabled -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cv-app.fullname" . }}-data + labels: + {{- include "cv-app.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} +``` + +### NOTES.txt + +``` +Your CV application has been deployed! + +Access your application at: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }} +{{- end }} +{{- else }} + Frontend: http://{{ include "cv-app.fullname" . }}-frontend.{{ .Release.Namespace }}.svc.cluster.local + Backend API: http://{{ include "cv-app.fullname" . }}-backend.{{ .Release.Namespace }}.svc.cluster.local:3001 +{{- end }} + +{{- if eq .Values.backend.auth.mode "simple" }} +Get the admin password: + kubectl logs deployment/{{ include "cv-app.fullname" . }}-backend | grep "ADMIN PASSWORD" +{{- end }} + +API Documentation: + http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/api/docs +``` + +## Usage Examples + +### Basic Installation + +```bash +helm install cv-app ./helm/cv-app +``` + +### With Custom Host + +```bash +helm install cv-app ./helm/cv-app \ + --set ingress.hosts[0].host=cv.example.com +``` + +### With TLS + +```bash +helm install cv-app ./helm/cv-app \ + --set ingress.hosts[0].host=cv.example.com \ + --set ingress.tls[0].hosts[0]=cv.example.com \ + --set ingress.tls[0].secretName=cv-app-tls +``` + +### With Keycloak + +```bash +helm install cv-app ./helm/cv-app \ + --set backend.auth.mode=keycloak \ + --set backend.keycloak.url=https://keycloak.example.com \ + --set backend.keycloak.realm=myrealm \ + --set backend.keycloak.clientId=cv-app +``` + +### With Specific Storage Class + +```bash +helm install cv-app ./helm/cv-app \ + --set persistence.storageClass=local-path \ + --set persistence.size=5Gi +```