feat: add Helm chart for Kubernetes deployment

Includes:
- Deployment with health checks and resource limits
- Service (ClusterIP by default)
- PersistentVolumeClaim for data storage
- Optional Ingress with TLS support
- Configurable via values.yaml

Usage: helm install taskplaner ./helm/taskplaner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Richter
2026-02-01 17:16:11 +01:00
parent 37023b2605
commit 0945f01563
9 changed files with 396 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
# Patterns to ignore when building packages.
.DS_Store
*.swp
*.bak
*.tmp
*.orig
*~
.git/
.gitignore
.project
.settings/
*.log

View File

@@ -0,0 +1,14 @@
apiVersion: v2
name: taskplaner
description: Personal task and notes management with image attachments
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
- tasks
- notes
- productivity
- personal
home: https://github.com/tricnet/taskplaner
maintainers:
- name: TaskPlaner Team

View File

@@ -0,0 +1,35 @@
Thank you for installing {{ .Chart.Name }}!
Your release is named {{ .Release.Name }}.
To access TaskPlaner:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "taskplaner.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running:
kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "taskplaner.fullname" . }}
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "taskplaner.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "taskplaner.fullname" . }} 8080:{{ .Values.service.port }}
Then open http://localhost:8080 in your browser.
{{- end }}
IMPORTANT: Make sure to set config.origin to match your access URL for CSRF protection to work.
To check the health of your deployment:
kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "taskplaner.name" . }},app.kubernetes.io/instance={{ .Release.Name }}"
Data is persisted in a PersistentVolumeClaim named {{ include "taskplaner.pvcName" . }}.

View File

@@ -0,0 +1,73 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "taskplaner.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "taskplaner.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 }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "taskplaner.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "taskplaner.labels" -}}
helm.sh/chart: {{ include "taskplaner.chart" . }}
{{ include "taskplaner.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "taskplaner.selectorLabels" -}}
app.kubernetes.io/name: {{ include "taskplaner.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "taskplaner.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "taskplaner.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use
*/}}
{{- define "taskplaner.pvcName" -}}
{{- if .Values.persistence.existingClaim }}
{{- .Values.persistence.existingClaim }}
{{- else }}
{{- include "taskplaner.fullname" . }}-data
{{- end }}
{{- end }}

View File

@@ -0,0 +1,77 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "taskplaner.fullname" . }}
labels:
{{- include "taskplaner.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: Recreate
selector:
matchLabels:
{{- include "taskplaner.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "taskplaner.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "taskplaner.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: {{ .Values.config.nodeEnv | quote }}
- name: DATA_DIR
value: "/app/data"
- name: TASKPLANER_ORIGIN
value: {{ .Values.config.origin | quote }}
- name: PORT
value: "3000"
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /app/data
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "taskplaner.pvcName" . }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "taskplaner.fullname" . }}
labels:
{{- include "taskplaner.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: {{ .pathType }}
backend:
service:
name: {{ include "taskplaner.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "taskplaner.fullname" . }}-data
labels:
{{- include "taskplaner.labels" . | nindent 4 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "taskplaner.fullname" . }}
labels:
{{- include "taskplaner.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "taskplaner.selectorLabels" . | nindent 4 }}

108
helm/taskplaner/values.yaml Normal file
View File

@@ -0,0 +1,108 @@
# Default values for taskplaner
replicaCount: 1
image:
repository: taskplaner
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: false
# Annotations to add to the service account
annotations: {}
# The name of the service account to use
name: ""
podAnnotations: {}
podSecurityContext:
fsGroup: 1001
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
service:
type: ClusterIP
port: 80
targetPort: 3000
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: taskplaner.local
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: taskplaner-tls
# hosts:
# - taskplaner.local
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
persistence:
enabled: true
# Use an existing PVC instead of creating one
existingClaim: ""
# Storage class for dynamic provisioning
storageClass: ""
accessMode: ReadWriteOnce
size: 1Gi
# Annotations for the PVC
annotations: {}
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
nodeSelector: {}
tolerations: []
affinity: {}
# Application-specific configuration
config:
# The external URL where the app is accessible (required for CSRF protection)
# Set this to your ingress URL or service URL
origin: "http://localhost:3000"
# Node environment
nodeEnv: production