This Helm chart deploys the complete photo-api solution to Kubernetes as a single release. It packages five workloads, their services, persistent storage, secrets, and a Dapr component into one parameterised chart.
| Component | Kind | Service Type | Purpose |
|---|---|---|---|
| photo-api | Deployment | LoadBalancer | REST API serving the SPA frontend |
| resize-api | Deployment | ClusterIP | Dapr event-driven image resize worker |
| blobemu | StatefulSet | LoadBalancer | Lightweight Azure Blob Storage emulator (browser-accessible) |
| rabbitmq | StatefulSet | ClusterIP | AMQP message broker for blob events |
| otel-collector | StatefulSet | ClusterIP | Grafana LGTM observability stack |
helm/photo-api/
├── Chart.yaml # Chart metadata (v0.1.0, appVersion 1.0.0)
├── values.yaml # Default configuration values
└── templates/
├── _helpers.tpl # Reusable template helpers
├── NOTES.txt # Post-install usage instructions
├── secrets.yaml # RabbitMQ + Grafana credentials
├── rabbitmq.yaml # StatefulSet + Service
├── blobemu.yaml # StatefulSet + Service (LoadBalancer)
├── otel-collector.yaml # StatefulSet + Service (optional)
├── photo-api.yaml # Deployment + Service (LoadBalancer)
├── resize-api.yaml # Deployment + Service + Dapr annotations
└── dapr-component.yaml # Dapr Component CRD (optional)
The following steps must be completed before installing the chart. Run all commands from the cluster control plane or a machine with kubectl access.
StatefulSets require a default StorageClass for dynamic PVC provisioning. Managed Kubernetes providers include one by default. For bare-metal / kubeadm clusters, install local-path-provisioner:
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.30/deploy/local-path-storage.yaml
kubectl patch storageclass local-path \
-p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'Dapr provides sidecar injection for resize-api and the Component CRD for the RabbitMQ binding. The Dapr Scheduler is not needed (no actors or workflow reminders are used) and can cause sidecar health-check failures if its PVC is not available — disable it:
helm repo add dapr https://dapr.github.io/helm-charts/
helm repo update
helm install dapr dapr/dapr \
--namespace dapr-system \
--create-namespace \
--set dapr_scheduler.enabled=falseVerify:
kubectl -n dapr-system get pods
# Expected: dapr-operator, dapr-sidecar-injector, dapr-sentry, dapr-placement-server — all Runningkubectl create namespace photo-apiThe container images are hosted on GitHub Container Registry (GHCR). Create a Personal Access Token with the read:packages scope and export it as GHCR_TOKEN, then create the Kubernetes secret:
kubectl -n photo-api create secret docker-registry ghcr-pull-secret \
--docker-server=ghcr.io \
--docker-username=<GITHUB_USERNAME> \
--docker-password="$GHCR_TOKEN"Run from the repository root (photo-api/):
# Authenticate Docker to GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u <GITHUB_USERNAME> --password-stdin
# Build
docker build -t ghcr.io/cbellee/photo-api/photo-api:latest \
--build-arg SERVICE_NAME=photo --build-arg SERVICE_PORT=8080 .
docker build -t ghcr.io/cbellee/photo-api/resize-api:latest \
--build-arg SERVICE_NAME=resize --build-arg SERVICE_PORT=8081 .
docker build -t ghcr.io/cbellee/photo-api/blobemu:latest \
-f blobemu/Dockerfile blobemu/
# Push
docker push ghcr.io/cbellee/photo-api/photo-api:latest
docker push ghcr.io/cbellee/photo-api/resize-api:latest
docker push ghcr.io/cbellee/photo-api/blobemu:latestCross-compilation note: If building on Apple Silicon (arm64) for amd64 nodes, the Dockerfiles already set
GOARCH=amd64. No extra flags are needed.
helm install photo-api helm/photo-api \
--namespace photo-api \
--set photoApi.imagePullSecrets[0].name=ghcr-pull-secret \
--set resizeApi.imagePullSecrets[0].name=ghcr-pull-secret \
--set blobemu.imagePullSecrets[0].name=ghcr-pull-secretThe blobemu service is exposed via LoadBalancer so the SPA (running in the browser) can fetch images directly. After installing, get the external IP:
kubectl -n photo-api get svc blobemu -w
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
# blobemu LoadBalancer 10.x.x.x 172.16.0.5 10000:xxxxx/TCPThen upgrade the release with the external URL:
export BLOBEMU_IP=$(kubectl -n photo-api get svc blobemu -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
helm upgrade photo-api helm/photo-api \
--namespace photo-api \
--set blobemu.externalUrl="http://${BLOBEMU_IP}:10000" \
--set photoApi.imagePullSecrets[0].name=ghcr-pull-secret \
--set resizeApi.imagePullSecrets[0].name=ghcr-pull-secret \
--set blobemu.imagePullSecrets[0].name=ghcr-pull-secretThis sets EMULATED_STORAGE_URL (used in API responses / image paths) to the browser-reachable address, while BLOB_EMULATOR_URL (used for backend HTTP calls) remains the cluster-internal FQDN.
# values-local.yaml
photoApi:
imagePullSecrets:
- name: ghcr-pull-secret
resizeApi:
imagePullSecrets:
- name: ghcr-pull-secret
blobemu:
externalUrl: "http://172.16.0.5:10000"
imagePullSecrets:
- name: ghcr-pull-secret
rabbitmq:
auth:
username: guest
password: guesthelm install photo-api helm/photo-api \
--namespace photo-api \
-f values-local.yamlhelm upgrade photo-api helm/photo-api \
--namespace photo-api \
--set photoApi.image.tag=v1.3.0helm uninstall photo-api --namespace photo-apiNote: PersistentVolumeClaims created by StatefulSets are not deleted on uninstall. Delete them manually if needed:
kubectl -n photo-api delete pvc -l app.kubernetes.io/part-of=photo-api
helm template my-release helm/photo-api --namespace photo-api
helm install photo-api helm/photo-api \
--namespace photo-api --dry-runhelm lint helm/photo-api| Helper | Purpose |
|---|---|
photo-api.name |
Chart name, truncated to 63 chars. Overridable via nameOverride. |
photo-api.fullname |
<release>-<chart> qualified name. Overridable via fullnameOverride. |
photo-api.labels |
Common labels: helm.sh/chart, managed-by, part-of, version. |
photo-api.selectorLabels |
Per-component selector labels: app.kubernetes.io/name + app.kubernetes.io/instance. |
photo-api.serviceFQDN |
Generates <svc>.<namespace>.svc.cluster.local for inter-service references. |
- Deployments — stateless services (
photo-api,resize-api) that scale horizontally. - StatefulSets — data-bearing services (
rabbitmq,blobemu,otel-collector) that need stable network identities and persistent volumes.
- photo-api —
LoadBalancerto expose the REST API externally. - blobemu —
LoadBalancerso the SPA (running in the user's browser) can fetch images directly. Theblobemu.externalUrlvalue controls the URL embedded in API responses. When empty, the internal FQDN is used (only works for server-side consumers). - rabbitmq, otel-collector, resize-api —
ClusterIP(internal only).
Credentials for RabbitMQ and Grafana are stored in Kubernetes Secret objects and injected via secretKeyRef. Default values (guest/admin) are suitable for development — override them for production.
Each StatefulSet uses a volumeClaimTemplate for dynamic PVC provisioning:
| Service | Mount Path | Default Size | Purpose |
|---|---|---|---|
| rabbitmq | /var/lib/rabbitmq |
1 Gi | Message queue data |
| blobemu | /data |
5 Gi | Blob files + SQLite database |
| otel-collector | /var/lib/grafana |
2 Gi | Grafana dashboards + data |
Set *.persistence.storageClass to target a specific storage class, or leave empty to use the cluster default.
| Value | Default | Effect when false |
|---|---|---|
otelCollector.enabled |
true |
Skips the OTel/Grafana StatefulSet and Service |
daprComponent.enabled |
true |
Skips the Dapr Component CRD |
The Dapr sidecar is injected into resize-api via pod annotations. This requires the Dapr operator (installed in the Prerequisites section above). The Dapr Scheduler is disabled because this application does not use actors or workflow reminders, and the scheduler's PVC requirement can cause sidecar health-check failures on bare-metal clusters.
resizeApi:
dapr:
enabled: true
appId: resize-api
appPort: "8081"
appProtocol: grpc
logLevel: infoAll service-to-service URLs are constructed at template time using the photo-api.serviceFQDN helper:
amqp://guest:guest@rabbitmq.<namespace>.svc.cluster.local:5672/
http://blobemu.<namespace>.svc.cluster.local:10000
otel-collector.<namespace>.svc.cluster.local:4317
| Service | Readiness | Liveness |
|---|---|---|
| photo-api | GET /readyz (lightweight connectivity check) |
GET /healthz |
| blobemu | GET /healthz |
GET /healthz |
| rabbitmq | rabbitmq-diagnostics -q ping |
rabbitmq-diagnostics -q ping |
| Key | Default | Description |
|---|---|---|
nameOverride |
"" |
Override the chart name |
fullnameOverride |
"" |
Override the fully qualified release name |
global.imagePullPolicy |
Always |
Image pull policy for all containers |
| Key | Default | Description |
|---|---|---|
photoApi.replicaCount |
2 |
Number of replicas |
photoApi.image.repository |
ghcr.io/cbellee/photo-api/photo-api |
Container image repository |
photoApi.image.tag |
latest |
Container image tag |
photoApi.imagePullSecrets |
[] |
List of image pull secret names (e.g. [{name: ghcr-pull-secret}]) |
photoApi.service.type |
LoadBalancer |
Kubernetes Service type |
photoApi.service.port |
8080 |
Service and container port |
photoApi.env.serviceName |
photo |
SERVICE_NAME env var |
photoApi.env.servicePort |
"8080" |
SERVICE_PORT env var |
photoApi.resources |
50m–500m CPU, 64Mi–256Mi mem | Resource requests/limits |
| Key | Default | Description |
|---|---|---|
resizeApi.replicaCount |
1 |
Number of replicas |
resizeApi.image.repository |
ghcr.io/cbellee/photo-api/resize-api |
Container image repository |
resizeApi.image.tag |
latest |
Container image tag |
resizeApi.imagePullSecrets |
[] |
List of image pull secret names (e.g. [{name: ghcr-pull-secret}]) |
resizeApi.service.type |
ClusterIP |
Kubernetes Service type |
resizeApi.service.port |
8081 |
Service and container port |
resizeApi.dapr.enabled |
true |
Enable Dapr sidecar injection |
resizeApi.dapr.appId |
resize-api |
Dapr application identity |
resizeApi.dapr.appPort |
"8081" |
Port Dapr forwards to |
resizeApi.dapr.appProtocol |
grpc |
Protocol (grpc/http) |
resizeApi.dapr.logLevel |
info |
Dapr sidecar log level |
resizeApi.env.imagesContainerName |
images |
Target container for resized images |
resizeApi.env.maxImageHeight |
"1200" |
Max resize height in pixels |
resizeApi.env.maxImageWidth |
"1600" |
Max resize width in pixels |
resizeApi.env.uploadsQueueBinding |
queue-uploads |
Dapr binding component name |
resizeApi.resources |
100m–1 CPU, 128Mi–512Mi mem | Resource requests/limits |
| Key | Default | Description |
|---|---|---|
blobemu.image.repository |
ghcr.io/cbellee/photo-api/blobemu |
Container image repository |
blobemu.image.tag |
latest |
Container image tag |
blobemu.imagePullSecrets |
[] |
List of image pull secret names (e.g. [{name: ghcr-pull-secret}]) |
blobemu.externalUrl |
"" |
Browser-reachable URL (e.g. http://172.16.0.5:10000). When empty, the cluster-internal FQDN is used. |
blobemu.service.type |
LoadBalancer |
Kubernetes Service type |
blobemu.service.port |
10000 |
Service and container port |
blobemu.rabbitmq.exchange |
blob-events |
RabbitMQ exchange name |
blobemu.rabbitmq.routingKey |
blob.created |
RabbitMQ routing key |
blobemu.rabbitmq.queue |
blob-events |
RabbitMQ queue name |
blobemu.persistence.size |
5Gi |
PVC size |
blobemu.persistence.storageClass |
"" |
Storage class (empty = default) |
blobemu.resources |
50m–250m CPU, 64Mi–256Mi mem | Resource requests/limits |
| Key | Default | Description |
|---|---|---|
rabbitmq.image.repository |
rabbitmq |
Container image repository |
rabbitmq.image.tag |
3-management-alpine |
Container image tag |
rabbitmq.service.amqpPort |
5672 |
AMQP port |
rabbitmq.service.managementPort |
15672 |
Management UI port |
rabbitmq.auth.username |
guest |
Default username |
rabbitmq.auth.password |
guest |
Default password |
rabbitmq.persistence.size |
1Gi |
PVC size |
rabbitmq.persistence.storageClass |
"" |
Storage class (empty = default) |
rabbitmq.resources |
100m–500m CPU, 256Mi–512Mi mem | Resource requests/limits |
| Key | Default | Description |
|---|---|---|
otelCollector.enabled |
true |
Deploy the OTel/Grafana stack |
otelCollector.image.repository |
grafana/otel-lgtm |
Container image repository |
otelCollector.image.tag |
latest |
Container image tag |
otelCollector.service.grafanaPort |
3000 |
Grafana UI port |
otelCollector.service.otlpGrpcPort |
4317 |
OTLP gRPC receiver port |
otelCollector.service.otlpHttpPort |
4318 |
OTLP HTTP receiver port |
otelCollector.auth.username |
admin |
Grafana admin username |
otelCollector.auth.password |
admin |
Grafana admin password |
otelCollector.persistence.size |
2Gi |
PVC size |
otelCollector.persistence.storageClass |
"" |
Storage class (empty = default) |
otelCollector.resources |
100m–500m CPU, 256Mi–1Gi mem | Resource requests/limits |
| Key | Default | Description |
|---|---|---|
daprComponent.enabled |
true |
Deploy the Dapr Component CRD |
daprComponent.queueName |
blob-events |
RabbitMQ queue name |
daprComponent.durable |
"true" |
Queue durability |
daprComponent.exclusive |
"false" |
Queue exclusivity |
daprComponent.direction |
input |
Binding direction |
After installation, get the LoadBalancer IPs:
# Photo API
kubectl -n photo-api get svc photo-api -w
export PHOTO_API_IP=$(kubectl -n photo-api get svc photo-api -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl http://$PHOTO_API_IP:8080/healthz
# Blobemu (image serving)
kubectl -n photo-api get svc blobemu -w
export BLOBEMU_IP=$(kubectl -n photo-api get svc blobemu -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl http://$BLOBEMU_IP:10000/healthz
# Grafana (port-forward)
kubectl -n photo-api port-forward svc/otel-collector 3000:3000
# RabbitMQ Management (port-forward)
kubectl -n photo-api port-forward svc/rabbitmq 15672:15672| Scenario | Override |
|---|---|
| Use ClusterIP for photo-api | --set photoApi.service.type=ClusterIP |
| Use ClusterIP for blobemu | --set blobemu.service.type=ClusterIP |
| Set blobemu external URL | --set blobemu.externalUrl=http://172.16.0.5:10000 |
| Disable observability stack | --set otelCollector.enabled=false |
| Disable Dapr component | --set daprComponent.enabled=false |
| Disable Dapr sidecar on resize-api | --set resizeApi.dapr.enabled=false |
| Use a specific storage class | --set rabbitmq.persistence.storageClass=managed-premium |
| Scale photo-api | --set photoApi.replicaCount=5 |
| Increase blob storage | --set blobemu.persistence.size=20Gi |
If the resize-api pod shows 1/2 Ready and the daprd sidecar logs show dapr initialized. Status: Running but the readiness probe fails with 500, the Dapr Scheduler is likely unhealthy. Confirm:
kubectl -n dapr-system get pods | grep schedulerFix by reinstalling Dapr without the scheduler:
helm upgrade dapr dapr/dapr -n dapr-system --set dapr_scheduler.enabled=false
kubectl -n photo-api delete pod -l app.kubernetes.io/name=resize-apiIf daprd logs show x509: certificate signed by unknown authority, the Sentry CA was rotated (e.g. after a Dapr reinstall). Restart the control plane and app pods:
kubectl -n dapr-system rollout restart deploy
kubectl -n photo-api delete pod -l app.kubernetes.io/name=resize-apiEnsure a default StorageClass exists:
kubectl get storageclassIf none is marked (default), install local-path-provisioner as described in the Prerequisites section.
On Ubuntu 24.04 nodes, the unprivileged_userns AppArmor restriction can prevent container operations. Disable AppArmor on affected worker nodes:
sudo systemctl stop apparmor && sudo systemctl disable apparmor
sudo systemctl restart containerd