Create portable ComfyUI Helm chart
Validate, publish and promote ComfyUI chart / chart (push) Successful in 21s

This commit is contained in:
2026-10-21 17:10:20 +02:00
commit 227e9c97b6
14 changed files with 516 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Validate, publish and promote ComfyUI chart
on:
push:
pull_request:
jobs:
chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: azure/setup-helm@v4
with:
version: v4.3.0
- name: Validate chart
run: |
python3 -m venv "${RUNNER_TEMP}/chart-test"
"${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML
PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" ./test.sh
- name: Select immutable chart version
if: gitea.ref == 'refs/heads/main'
run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}"
- name: Package and publish chart
if: gitea.ref == 'refs/heads/main'
env:
PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}"
mkdir -p .chart-packages
helm package chart/comfyui --destination .chart-packages --version "${CHART_VERSION}"
curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \
--request POST --upload-file ".chart-packages/comfyui-${CHART_VERSION}.tgz" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
- name: Check current main
id: current-main
if: gitea.ref == 'refs/heads/main'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
git fetch --no-tags origin main
if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then
echo 'promote=true' >> "${GITEA_OUTPUT}"
else
echo 'promote=false' >> "${GITEA_OUTPUT}"
fi
- uses: actions/checkout@v4
if: steps.current-main.outputs.promote == 'true'
with:
repository: feedc0de/homelab-gitops
ref: main
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
- name: Promote chart
if: steps.current-main.outputs.promote == 'true'
working-directory: homelab-gitops
run: |
set -euo pipefail
./scripts/update-chart-version.sh comfyui "${CHART_VERSION}"
git config user.name feedc0de
git config user.email feedc0de@users.noreply.code.brunner.ninja
git add bootstrap/comfyui.yaml
if git diff --cached --quiet; then exit 0; fi
git commit -m "Deploy comfyui chart ${CHART_VERSION}"
for attempt in 1 2 3; do
git push origin HEAD:main && exit 0
test "${attempt}" = 3 && exit 1
git fetch origin main
git rebase origin/main
done
+2
View File
@@ -0,0 +1,2 @@
.chart-packages/
__pycache__/
+37
View File
@@ -0,0 +1,37 @@
# ComfyUI Helm chart
This adapter chart runs the public ComfyUI container image and keeps model,
input, output, and user data in one PVC. It avoids the image's boot-time custom
node installation path by starting ComfyUI directly. The image tag and tested
ComfyUI release are recorded in `Chart.yaml`.
Install the public chart:
```sh
helm repo add brunner.ninja https://brunner.ninja/charts
helm repo update brunner.ninja
helm upgrade --install comfyui brunner.ninja/comfyui \
--namespace comfyui --create-namespace --wait --timeout 10m
```
Set `persistence.existingClaim` to reuse a pre-existing model/data volume. For
another cluster, leave it empty and configure `persistence.size` and
`persistence.storageClass`. `args`, node placement, and resource sizing can be
overridden for the available CPU/GPU hardware. The chart creates an internal
Service on port 8188; it does not expose the editor publicly by default.
This repository owns the portable Deployment, probes, Service, data-directory
initialization, and optional PVC. A consuming GitOps repository owns only its
selected chart version and cluster values. The homelab instance is managed by
the separate `homelab-gitops` Application `comfyui` in namespace `ollama`.
## Validation and releases
Run `./test.sh` to lint and render the chart and verify that its Service,
selectors, ports, PVC, mounts, and probes fit together.
Git tags follow the application release (`v0.8.2` for ComfyUI 0.8.2). CI uses
`git describe --tags --long` for immutable package versions; later chart-only
commits retain their count and Git hash. The public chart repository is
`brunner.ninja/charts`. Gitea Actions needs `PACKAGE_USERNAME`, `PACKAGE_TOKEN`,
and `GITOPS_TOKEN`; the workflow has no Kubernetes credentials.
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v2
name: comfyui
description: A portable ComfyUI server chart using the public ComfyUI container image
type: application
version: 0.8.2
appVersion: "0.6.3-comfyui-0.8.2"
home: https://www.comfy.org/
sources:
- https://github.com/comfyanonymous/ComfyUI
- https://github.com/lecode-official/comfyui-docker
keywords:
- comfyui
- image-generation
+36
View File
@@ -0,0 +1,36 @@
{{- define "comfyui.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "comfyui.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 "comfyui.selectorLabels" -}}
app.kubernetes.io/name: {{ include "comfyui.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "comfyui.labels" -}}
{{ include "comfyui.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }}
{{- end -}}
{{- define "comfyui.dataClaim" -}}
{{- if .Values.persistence.existingClaim -}}
{{- .Values.persistence.existingClaim -}}
{{- else -}}
{{- printf "%s-data" (include "comfyui.fullname" .) -}}
{{- end -}}
{{- end -}}
+104
View File
@@ -0,0 +1,104 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "comfyui.fullname" . }}
labels:
{{- include "comfyui.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
{{- toYaml .Values.strategy | nindent 4 }}
selector:
matchLabels:
{{- include "comfyui.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "comfyui.labels" . | nindent 8 }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
- name: data-directories
image: {{ .Values.initContainer.image | quote }}
command: [sh, -ec]
args:
- mkdir -p /data/models /data/output /data/input /data/user
resources:
{{- toYaml .Values.initContainer.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /data
containers:
- name: comfyui
image: {{ printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
{{- toYaml .Values.command | nindent 12 }}
workingDir: {{ .Values.workingDir | quote }}
args:
{{- toYaml .Values.args | nindent 12 }}
ports:
- name: http
containerPort: 8188
protocol: TCP
startupProbe:
httpGet:
path: /system_stats
port: http
{{- toYaml .Values.probes.startup | nindent 12 }}
readinessProbe:
httpGet:
path: /system_stats
port: http
{{- toYaml .Values.probes.readiness | nindent 12 }}
livenessProbe:
httpGet:
path: /system_stats
port: http
{{- toYaml .Values.probes.liveness | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
volumeMounts:
- name: data
mountPath: /opt/comfyui/models
subPath: models
- name: data
mountPath: /opt/comfyui/output
subPath: output
- name: data
mountPath: /opt/comfyui/input
subPath: input
- name: data
mountPath: /opt/comfyui/user
subPath: user
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "comfyui.dataClaim" . }}
{{- else }}
emptyDir: {}
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "comfyui.dataClaim" . }}
labels:
{{- include "comfyui.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "comfyui.fullname" . }}
labels:
{{- include "comfyui.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "comfyui.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
+74
View File
@@ -0,0 +1,74 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"global": { "type": "object" },
"replicaCount": { "type": "integer", "minimum": 1 },
"image": {
"type": "object",
"additionalProperties": false,
"required": ["repository", "tag", "pullPolicy"],
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
}
},
"command": { "type": "array", "items": { "type": "string" } },
"workingDir": { "type": "string" },
"args": { "type": "array", "items": { "type": "string" } },
"strategy": {
"type": "object",
"properties": { "type": { "type": "string", "enum": ["Recreate", "RollingUpdate"] } }
},
"terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 },
"service": {
"type": "object",
"additionalProperties": false,
"required": ["type", "port"],
"properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
},
"persistence": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "existingClaim", "size", "storageClass", "accessModes"],
"properties": {
"enabled": { "type": "boolean" },
"existingClaim": { "type": "string" },
"size": { "type": "string", "minLength": 1 },
"storageClass": { "type": "string" },
"accessModes": { "type": "array", "minItems": 1, "items": { "type": "string" } }
}
},
"resources": { "type": "object" },
"nodeSelector": { "type": "object" },
"tolerations": { "type": "array", "items": { "type": "object" } },
"affinity": { "type": "object" },
"podAnnotations": { "type": "object" },
"podSecurityContext": { "type": "object" },
"containerSecurityContext": { "type": "object" },
"initContainer": {
"type": "object",
"additionalProperties": false,
"required": ["image", "resources"],
"properties": {
"image": { "type": "string", "minLength": 1 },
"resources": { "type": "object" }
}
},
"probes": {
"type": "object",
"additionalProperties": false,
"required": ["startup", "readiness", "liveness"],
"properties": {
"startup": { "type": "object" },
"readiness": { "type": "object" },
"liveness": { "type": "object" }
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
replicaCount: 1
image:
repository: ghcr.io/lecode-official/comfyui-docker
tag: ""
pullPolicy: IfNotPresent
command:
- /opt/conda/bin/python
- /opt/comfyui/main.py
workingDir: /opt/comfyui
args:
- --listen
- 0.0.0.0
- --port
- "8188"
- --disable-all-custom-nodes
- --disable-auto-launch
- --cpu
- --force-fp32
- --fp32-text-enc
- --fp32-vae
- --cache-none
strategy:
type: Recreate
terminationGracePeriodSeconds: 60
service:
type: ClusterIP
port: 8188
persistence:
enabled: true
existingClaim: ""
size: 20Gi
storageClass: ""
accessModes:
- ReadWriteOnce
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
podAnnotations: {}
podSecurityContext: {}
containerSecurityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
initContainer:
image: docker.io/library/busybox:1.38.0
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
probes:
startup:
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 60
readiness:
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 3
liveness:
periodSeconds: 30
timeoutSeconds: 20
failureThreshold: 10
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
release=${RELEASE_NAME:-comfyui}
namespace=${NAMESPACE:-default}
helm upgrade --install "$release" brunner.ninja/comfyui \
--namespace "$namespace" --create-namespace --wait --timeout 10m "$@"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
description=$(git -C "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" describe --tags --long --match 'v[0-9]*' --dirty)
case "$description" in
*-dirty) echo 'Refusing to package a dirty tree' >&2; exit 1 ;;
esac
version=${description#v}
case "$version" in
*-0-g*) version=${version%%-0-g*} ;;
esac
printf '%s\n' "$version"
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
chart_dir="$repo_dir/chart/comfyui"
rendered=$(mktemp)
trap 'rm -f "$rendered"' EXIT
helm lint "$chart_dir"
helm template comfyui "$chart_dir" > "$rendered"
python3 "$repo_dir/tests/render_test.py" "$rendered"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import pathlib
import sys
import yaml
documents = [item for item in yaml.safe_load_all(pathlib.Path(sys.argv[1]).read_text()) if item]
deployments = [item for item in documents if item.get("kind") == "Deployment"]
services = [item for item in documents if item.get("kind") == "Service"]
claims = [item for item in documents if item.get("kind") == "PersistentVolumeClaim"]
assert len(deployments) == 1
assert len(services) == 1
deployment = deployments[0]
service = services[0]
selector = deployment["spec"]["selector"]["matchLabels"]
pod = deployment["spec"]["template"]
pod_labels = pod["metadata"]["labels"]
assert all(pod_labels.get(key) == value for key, value in selector.items())
assert service["spec"]["selector"] == selector
container = pod["spec"]["containers"][0]
http_port = next(port for port in container["ports"] if port["name"] == "http")
service_port = service["spec"]["ports"][0]
assert service_port["targetPort"] == http_port["name"]
assert service_port["port"] == http_port["containerPort"]
mounts = container["volumeMounts"]
assert {mount["subPath"] for mount in mounts} == {"models", "output", "input", "user"}
data_volume = next(volume for volume in pod["spec"]["volumes"] if volume["name"] == mounts[0]["name"])
claim = data_volume.get("persistentVolumeClaim", {}).get("claimName")
assert claim
if claims:
assert len(claims) == 1
assert claims[0]["metadata"]["name"] == claim
for probe_name in ("startupProbe", "readinessProbe", "livenessProbe"):
assert container[probe_name]["httpGet"]["path"] == "/system_stats"
print("ComfyUI chart render relationships are valid")