Inhoud van dit artikel
- Waarom een eigen chart en geen generieke
- De structuur van de chart
- values.yaml: één image, meerdere workloads
- Templates: één helper voor alle pods
- De web-pod: probes, poorten en verkeer
- Configuratie en secrets gescheiden
- Migraties als Job
- Values per omgeving
- environments/production/values.yaml
- De valkuilen op een rij
- Testen voordat het cluster het ziet
Een goede Helm-chart voor Laravel rolt één image uit als meerdere workloads: web, queue-workers, scheduler en een migratie-Job. Alles wat per omgeving verschilt, staat in values; secrets staan er juist niet in. De meeste problemen ontstaan niet in Helm zelf, maar in wat je in je values en je image stopt.
Waarom een eigen chart en geen generieke
Er bestaan generieke "app"-charts die elke container kunnen draaien. Voor een Laravel-app lopen die snel vast. Laravel is geen enkel proces. Hetzelfde image draait als PHP-FPM of Octane voor webverkeer, als Horizon of queue:work voor jobs, als scheduler en als eenmalige migratie. Die workloads delen configuratie, maar hebben elk een eigen command, eigen resources en een eigen manier van schalen.
Een eigen chart maakt dat expliciet. Je schrijft het patroon één keer, en elke Laravel-app in je organisatie gebruikt hetzelfde. Het grotere plaatje van zo'n opzet staat op Laravel op Kubernetes. Waarom we voor Helm of Kustomize kiezen, lees je op Helm-charts en Kustomize.
De structuur van de chart
Dit is de indeling die we aanhouden:
charts/laravel-app/
├── Chart.yaml
├── values.yaml
├── values.schema.json
└── templates/
├── _helpers.tpl
├── configmap.yaml
├── externalsecret.yaml
├── deployment-web.yaml
├── deployment-workers.yaml
├── deployment-scheduler.yaml
├── job-migrate.yaml
├── service.yaml
├── httproute.yaml
├── hpa.yaml
└── pdb.yaml
Per omgeving staat er een eigen values-bestand naast, meestal in een aparte config-repository:
environments/
├── staging/values.yaml
└── production/values.yaml
Het Chart.yaml houdt het simpel. Geen dependencies op database-charts; daarover verderop meer.
apiVersion: v2
name: laravel-app
description: Laravel web, workers, scheduler en migraties
type: application
version: 1.4.0
appVersion: "2026.10.3"
version is de versie van de chart zelf. appVersion is informatief. Welk image er draait, bepaal je in de values, niet hier.
values.yaml: één image, meerdere workloads
De kern van de chart is dat het image één keer gedefinieerd wordt en elke workload het erft:
image:
repository: ghcr.io/voorbeeld/shop-api
tag: "2026.10.3-a1b2c3d"
digest: "" # in productie: sha256:...
pullPolicy: IfNotPresent
env:
APP_ENV: production
APP_DEBUG: "false"
LOG_CHANNEL: stderr
QUEUE_CONNECTION: redis
CACHE_STORE: redis
SESSION_DRIVER: redis
REDIS_HOST: valkey.platform.svc.cluster.local
REDIS_PREFIX: shop-api-
FILESYSTEM_DISK: s3
secrets:
externalSecret:
enabled: true
secretStore: platform-secrets
remoteKey: shop-api/production
web:
replicas: 2
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { memory: 512Mi }
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 8
targetCPU: 70
workers:
- name: default
command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--max-time=3600"]
replicas: 1
resources:
requests: { cpu: 100m, memory: 192Mi }
limits: { memory: 384Mi }
- name: imports
command: ["php", "artisan", "queue:work", "redis", "--queue=imports", "--timeout=900"]
replicas: 0
scheduler:
enabled: true
migrations:
enabled: true
Een paar keuzes in dit fragment zijn bewust:
LOG_CHANNEL: stderr. Containers loggen naar stdout en stderr. Eenlaravel.login de container verdwijnt bij elke herstart.REDIS_PREFIXexpliciet. Anders hangt de prefix af vanAPP_NAME, en verandert de sleutel van je queues als iemand de naam aanpast. Dat breekt ook autoscaling op queue-lengte met KEDA.- Workers als lijst. Elke queue met een ander profiel krijgt een eigen Deployment. Een import-queue met lange jobs wil je niet in dezelfde pods als je mailqueue. Gebruik je Laravel Horizon, dan wordt dit één Deployment met
php artisan horizonen regelt Horizon de verdeling. - Geen
APP_KEY, geen wachtwoorden. Die komen uit de ExternalSecret.
Templates: één helper voor alle pods
Web, workers en scheduler delen het grootste deel van hun pod-spec: image, env, securityContext, volumes. Die zet je in een helper in _helpers.tpl:
{{- define "laravel-app.image" -}}
{{- if .Values.image.digest -}}
{{ .Values.image.repository }}@{{ .Values.image.digest }}
{{- else -}}
{{ .Values.image.repository }}:{{ .Values.image.tag }}
{{- end -}}
{{- end -}}
{{- define "laravel-app.container" -}}
image: {{ include "laravel-app.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- configMapRef:
name: {{ include "laravel-app.fullname" . }}-env
- secretRef:
name: {{ include "laravel-app.fullname" . }}-secrets
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: storage-framework
mountPath: /var/www/html/storage/framework
{{- end -}}
De worker-template loopt over de lijst uit de values:
{{- range .Values.workers }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "laravel-app.fullname" $ }}-worker-{{ .name }}
labels:
{{- include "laravel-app.labels" $ | nindent 4 }}
app.kubernetes.io/component: worker-{{ .name }}
spec:
replicas: {{ .replicas }}
selector:
matchLabels:
{{- include "laravel-app.selectorLabels" $ | nindent 6 }}
app.kubernetes.io/component: worker-{{ .name }}
template:
metadata:
labels:
{{- include "laravel-app.selectorLabels" $ | nindent 8 }}
app.kubernetes.io/component: worker-{{ .name }}
annotations:
checksum/env: {{ include (print $.Template.BasePath "/configmap.yaml") $ | sha256sum }}
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 120
containers:
- name: worker
{{- include "laravel-app.container" $ | nindent 10 }}
command: {{ toJson .command }}
resources:
{{- toYaml .resources | nindent 12 }}
volumes:
- name: storage-framework
emptyDir: {}
{{- end }}
Let op de $ binnen range. In een loop wijst . naar het huidige item uit de lijst. De globale context, met .Values en .Release, bereik je via $. Dit is de meest voorkomende fout in Helm-templates.
De terminationGracePeriodSeconds hoort bij je langste job. Kubernetes stuurt bij een deploy een SIGTERM. queue:work maakt de lopende job af en stopt dan. Is de grace period korter dan je job, dan wordt de worker halverwege afgeschoten.
De web-pod: probes, poorten en verkeer
Voor webverkeer kies je tussen PHP-FPM met nginx en Laravel Octane. Bij PHP-FPM draaien we nginx en PHP-FPM als twee containers in dezelfde pod. Ze delen het netwerk, dus nginx praat met 127.0.0.1:9000. Bij Octane met FrankenPHP of RoadRunner is het één container die zelf HTTP spreekt. De chart ondersteunt beide via een schakelaar in de values, zodat de rest van de templates gelijk blijft.
Wat in beide gevallen klopt moet zijn, zijn de probes. Een readiness probe bepaalt of een pod verkeer krijgt. Een liveness probe bepaalt of Kubernetes de pod herstart.
web:
server: fpm # of: octane
port: 8080
readinessProbe:
httpGet: { path: /up, port: 8080 }
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet: { path: /up, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 15
failureThreshold: 5
Laravel heeft sinds versie 11 standaard een health-route op /up. Die controleert of de applicatie opstart, niet of de database bereikbaar is. Dat is voor een liveness probe precies goed. Laat je liveness probe nooit afhangen van de database. Hapert de database, dan herstart Kubernetes al je pods tegelijk en maak je het probleem groter.
Nginx luistert op 8080 in plaats van 80. Een poort onder 1024 vraagt extra rechten, en met een restricted securityContext heeft je container die niet. Voor het verkeer van buiten maakt de chart een HTTPRoute voor de Gateway API in plaats van een Ingress. ingress-nginx wordt sinds maart 2026 niet meer onderhouden; een nieuwe chart bouw je daar niet meer op.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "laravel-app.fullname" . }}
spec:
parentRefs:
- name: {{ .Values.gateway.name }}
namespace: {{ .Values.gateway.namespace }}
hostnames:
{{- toYaml .Values.gateway.hostnames | nindent 4 }}
rules:
- backendRefs:
- name: {{ include "laravel-app.fullname" . }}-web
port: 80
De HPA uit hpa.yaml schaalt de web-pods op CPU. Voor workers is CPU een slechte maat; die schaal je op queue-lengte. Hoe die twee naast elkaar werken, staat op Autoscaling met HPA, KEDA en Karpenter.
Configuratie en secrets gescheiden
Niet-gevoelige configuratie gaat in een ConfigMap, gegenereerd uit env:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "laravel-app.fullname" . }}-env
data:
{{- range $key, $value := .Values.env }}
{{ $key }}: {{ $value | quote }}
{{- end }}
Secrets komen uit een kluis via External Secrets Operator. In de chart staat alleen de verwijzing:
{{- if .Values.secrets.externalSecret.enabled }}
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: {{ include "laravel-app.fullname" . }}-secrets
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: {{ .Values.secrets.externalSecret.secretStore }}
target:
name: {{ include "laravel-app.fullname" . }}-secrets
dataFrom:
- extract:
key: {{ .Values.secrets.externalSecret.remoteKey }}
{{- end }}
In de kluis staan APP_KEY, DB_PASSWORD, API-sleutels en de rest. Hoe dat verder werkt, met RBAC en policies eromheen, staat op Kubernetes security.
De checksum/env-annotatie uit de worker-template zorgt dat pods opnieuw starten als de ConfigMap verandert. Zonder die annotatie past Helm de ConfigMap aan, maar blijven je pods op de oude waarden draaien tot ze toevallig herstarten. Voor secrets uit ESO werkt de checksum niet, want de waarde staat niet in de chart. Daar gebruik je een tool als Reloader, of je rolt de pods bewust opnieuw uit na een rotatie.
Migraties als Job
Migraties draaien als Job voordat de nieuwe pods starten:
{{- if .Values.migrations.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "laravel-app.fullname" . }}-migrate
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: migrate
{{- include "laravel-app.container" . | nindent 10 }}
command: ["php", "artisan", "migrate", "--force"]
volumes:
- name: storage-framework
emptyDir: {}
{{- end }}
Deploy je met Argo CD, dan vertaalt Argo CD de Helm-hooks pre-install en pre-upgrade naar een PreSync-hook. Faalt de migratie, dan stopt de sync en blijft de huidige versie draaien. Er zit een valkuil in: een pre-install-hook draait voor alle andere resources, dus ook voordat je ExternalSecret er is. Bij een eerste installatie heeft de Job dan nog geen databasewachtwoord. De oplossing en de volgorde met sync waves staan in Argo CD sync waves en migraties.
Values per omgeving
Het values-bestand per omgeving bevat alleen wat afwijkt:
# environments/production/values.yaml
image:
tag: "2026.10.3-a1b2c3d"
digest: "sha256:..."
env:
APP_URL: https://api.voorbeeld.nl
secrets:
externalSecret:
remoteKey: shop-api/production
web:
autoscaling:
minReplicas: 3
maxReplicas: 12
workers:
- name: default
command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--max-time=3600"]
replicas: 2
resources:
requests: { cpu: 100m, memory: 192Mi }
limits: { memory: 384Mi }
Zie je de valkuil? Helm merget maps diep, maar lijsten vervangt het in zijn geheel. Door workers in productie te overschrijven, is de imports-worker uit de basis verdwenen. Twee oplossingen: maak workers een map met de naam als sleutel in plaats van een lijst, of herhaal in elke omgeving de volledige lijst. Wij kiezen voor een map, omdat je dan per worker één veld kunt overschrijven.
Zet daarnaast een values.schema.json in de chart. Helm valideert de values tegen dat schema bij install, upgrade, lint en template. Een typefout als replica: 2 in plaats van replicas geeft dan een fout in plaats van een stille standaardwaarde.
De valkuilen op een rij
- Database-charts als dependency. Veel tutorials trekken MySQL en Redis mee als Bitnami-subchart. Broadcom heeft in 2025 het grootste deel van de gratis Bitnami-images naar een legacy-repository verplaatst die geen updates meer krijgt. Een chart die daarop leunt, draait dus op ongepatchte images. Wij zetten de database liefst managed buiten het cluster, en Valkey als losse component met een officieel image.
config:cachein de build.php artisan config:cachebakt de environment-waarden in een bestand. Doe je dat in de Dockerfile, dan draait productie met de waarden van je build-omgeving. Route- en view-cache kunnen wel in de build; config-cache pas bij het opstarten.- De tag
latest. Helm ziet geen verschil als de tag gelijk blijft, dus er gebeurt niets bij een upgrade. Tag op commit-SHA of zet de digest erin. - Te lange namen. Kubernetes-namen voor onder meer Services en labels zijn beperkt tot 63 tekens. Een releasenaam plus chartnaam plus
-worker-importsgaat daar snel overheen. Laat jefullname-helper inkorten mettrunc 63 | trimSuffix "-". - Helm 4 en server-side apply. Helm 4 verscheen in november 2025 en gebruikt bij nieuwe installaties server-side apply. Bestaande releases houden bij een upgrade hun oude methode. Met server-side apply worden conflicten zichtbaar die eerder stilletjes werden overschreven, zoals een dubbele environment-variabele in dezelfde container. Test je chart daarom met de Helm-versie die je in productie gebruikt.
- Geen PodDisruptionBudget. Zonder PDB kan een node-upgrade al je web-pods tegelijk weghalen. Eén PDB met
minAvailable: 1voorkomt dat.
Testen voordat het cluster het ziet
Een chart is code, dus hij krijgt een pipeline. In CI draaien we:
helm lint charts/laravel-app -f environments/production/values.yaml
helm template shop-api charts/laravel-app \
-f environments/production/values.yaml > rendered.yaml
kubeconform -strict -summary rendered.yaml
helm template rendert de manifests zonder cluster. kubeconform controleert of ze geldig zijn tegen de Kubernetes-schema's. Draait er Kyverno in je cluster, dan kun je de gerenderde manifests ook vooraf tegen je policies testen met de Kyverno CLI. Zo vang je een ontbrekende resource limit in de pull request in plaats van bij de deploy. Hoe de chart daarna via Git in het cluster komt, staat op GitOps met Argo CD.
Veelgestelde vragen
Moet elke Laravel-app een eigen Helm-chart hebben?+
Nee. Eén gedeelde chart voor al je Laravel-apps werkt meestal beter. De verschillen tussen apps staan in de values: image, workers, resources en hostnamen. Wijkt een app echt af, bijvoorbeeld met Reverb of Octane, dan voeg je dat als optionele template toe aan dezelfde chart.
Horen secrets in values.yaml?+
Nee. Een values-bestand staat in Git en komt in de release-geschiedenis van Helm terecht. Zet in de chart alleen een verwijzing, zoals een ExternalSecret, en bewaar APP_KEY, databasewachtwoorden en API-sleutels in een kluis.
Waarom herstarten mijn pods niet als ik de configuratie wijzig?+
Kubernetes start pods niet opnieuw als alleen een ConfigMap verandert. Voeg een checksum-annotatie toe aan de pod-template met de hash van de ConfigMap. Verandert de configuratie, dan verandert de pod-spec en volgt er een rolling update.
Helm-hooks of Argo CD sync waves voor migraties?+
Als je met Argo CD deployt, werken beide: Argo CD vertaalt Helm-hooks naar zijn eigen hooks. Voor de volgorde met ExternalSecrets en workers zijn sync waves duidelijker. Deploy je met helm upgrade, dan zijn Helm-hooks de route.
Helm of Kustomize voor een Laravel-app?+
Helm past als je hetzelfde patroon voor meerdere apps wilt hergebruiken, met logica zoals een lijst workers. Kustomize past als je bestaande manifests per omgeving wilt aanpassen zonder templates. Veel teams combineren ze: een Helm-chart als basis, Kustomize voor kleine afwijkingen per cluster.





