App Template
The bjw-s app-template Helm chart is used for most applications in the cluster. It provides a standardized, opinionated structure for deploying containerized workloads.
Current version: 5.2.1 (pinned in each app's kustomization.yaml and bumped by Renovate)
Overview
The app-template chart abstracts common Kubernetes patterns (Deployments, Services, HTTPRoutes, PVCs) into a declarative values.yaml format. Instead of writing raw Kubernetes manifests, you define controllers, containers, services, and routes.
flowchart TD
Values[values.yaml] --> Chart[app-template chart]
Chart --> Deploy[Deployment]
Chart --> Svc[Service]
Chart --> Route[HTTPRoute]
Chart --> PVC[PersistentVolumeClaim]
Chart --> CM[ConfigMap]
Chart --> SA[ServiceAccount]
Chart Reference
The chart is referenced in kustomization.yaml using OCI:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: selfhosted
helmCharts:
- name: app-template
repo: oci://ghcr.io/bjw-s-labs/helm
version: 5.2.1
releaseName: my-app
namespace: selfhosted
valuesFile: values.yamlValues Structure
Minimal Example
A minimal application with a single container, service, and HTTP route:
controllers:
my-app:
annotations:
reloader.stakater.com/auto: "true"
containers:
app:
image:
repository: ghcr.io/example/my-app
tag: 1.0.0
env:
HTTP_PORT: 8080
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 128Mi
probes:
liveness:
enabled: true
readiness:
enabled: true
startup:
enabled: true
spec:
failureThreshold: 30
periodSeconds: 5
service:
app:
controller: my-app
ports:
http:
port: 8080
route:
app:
hostnames:
- my-app.wibrow.dev
parentRefs:
- name: envoy-external
namespace: networking
sectionName: httpsControllers
Controllers define the workload type (Deployment by default) and its containers.
controllers:
my-app:
# Optional: set replicas
replicas: 1
# Optional: set strategy
strategy: Recreate
# Annotations applied to the pod template
annotations:
reloader.stakater.com/auto: "true"
# Pod-level settings
pod:
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
app:
image:
repository: ghcr.io/example/my-app
tag: 1.0.0
env:
TZ: Europe/Zurich
PORT: "8080"
envFrom:
- secretRef:
name: my-app-secrets
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256MiMultiple Containers (Sidecars)
controllers:
my-app:
containers:
app:
image:
repository: ghcr.io/example/my-app
tag: 1.0.0
sidecar:
image:
repository: ghcr.io/example/sidecar
tag: 2.0.0
args:
- --config=/etc/sidecar/config.yamlServices
Define Kubernetes Services that expose container ports:
service:
app:
controller: my-app
ports:
http:
port: 8080
metrics:
port: 9090Multiple Services
service:
app:
controller: frigate
ports:
http:
port: 5000
webrtc-udp:
controller: frigate
type: LoadBalancer
annotations:
lbipam.cilium.io/ips: 10.20.10.235
ports:
webrtc-udp:
port: 8555
protocol: UDPLoadBalancer Services get a fixed address from the Cilium LB-IPAM pool (10.20.10.128-255) via lbipam.cilium.io/ips.
Routes (HTTPRoute)
Routes configure Gateway API HTTPRoutes for ingress:
External Route (public, via the towonel tunnel)
route:
app:
hostnames:
- my-app.wibrow.dev
parentRefs:
- name: envoy-external
namespace: networking
sectionName: httpsInternal Route (LAN and Tailscale only)
route:
app:
hostnames:
- my-app.wibrow.dev
parentRefs:
- name: envoy-internal
namespace: networking
sectionName: httpsWhen a Service exposes several ports, pin the backend with rules:
route:
app:
hostnames:
- frigate.wibrow.dev
parentRefs:
- name: envoy-internal
namespace: networking
sectionName: https
rules:
- backendRefs:
- identifier: app
port: 5000Persistence
Define persistent storage for your application:
Existing PVC (preferred)
App data PVCs are declared with the pvc kustomize component (see Adding Apps) and mounted by name:
persistence:
config:
existingClaim: my-app-config
globalMounts:
- path: /configChart-managed PVC
For disposable data such as caches, the chart can create the PVC itself:
persistence:
model-cache:
accessMode: ReadWriteOnce
size: 10Gi
storageClass: openebs-hostpath-fast
advancedMounts:
machine-learning:
app:
- path: /cacheNFS
persistence:
media:
type: nfs
server: data
path: /volume1/media
globalMounts:
- path: /data/nas-mediaEmptyDir
persistence:
tmp:
type: emptyDir
globalMounts:
- path: /tmpConfigMap Mount
persistence:
config:
type: configMap
name: my-app-config
globalMounts:
- path: /config/app.yaml
subPath: app.yaml
readOnly: trueSecret Mount
persistence:
secrets:
type: secret
name: my-app-secrets
globalMounts:
- path: /secrets
readOnly: trueHealth Probes
Always configure health probes for production workloads:
probes:
liveness:
enabled: true
custom: true
spec:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readiness:
enabled: true
startup:
enabled: true
spec:
failureThreshold: 30
periodSeconds: 5Environment Variables
Static Environment Variables
env:
TZ: Europe/Zurich
LOG_LEVEL: info
HTTP_PORT: "8080"From Secrets
envFrom:
- secretRef:
name: my-app-secretsFrom ConfigMap
envFrom:
- configMapRef:
name: my-app-configValue From (Field Reference)
env:
POD_NAME:
valueFrom:
fieldRef:
fieldPath: metadata.nameFull Example
Miniflux (selfhosted/miniflux), with OIDC, a shared CNPG database and a custom liveness probe:
controllers:
miniflux:
strategy: RollingUpdate
annotations:
reloader.stakater.com/auto: "true"
pod:
securityContext:
runAsUser: 2000
runAsGroup: 2000
containers:
app:
image:
repository: ghcr.io/miniflux/miniflux
tag: 2.3.3-distroless
env:
BASE_URL: https://miniflux.wibrow.dev
RUN_MIGRATIONS: "1"
OAUTH2_PROVIDER: oidc
OAUTH2_CLIENT_ID: miniflux
OAUTH2_OIDC_DISCOVERY_ENDPOINT: https://idm.wibrow.dev/oauth2/openid/miniflux
DATABASE_URL:
valueFrom:
secretKeyRef:
name: miniflux-db-secret
key: DB_URL
envFrom:
- secretRef:
name: miniflux-secret
probes:
liveness:
enabled: true
custom: true
spec:
httpGet:
path: /healthcheck
port: 8080
resources:
requests:
cpu: 12m
memory: 64M
limits:
memory: 256M
service:
app:
controller: miniflux
ports:
http:
port: 8080
route:
app:
hostnames:
- miniflux.wibrow.dev
parentRefs:
- name: envoy-external
namespace: networking
sectionName: httpsminiflux-db-secret comes from the cnpg-db-shared component and miniflux-secret from an Infisical ExternalSecret (abridged; see kubernetes/apps/pitower/selfhosted/miniflux/).