Helm-chart voor je Laravel-app: structuur, values en valkuilen

Coding.nl
10 min leestijd
Inhoud van dit artikel
  1. Waarom een eigen chart en geen generieke
  2. De structuur van de chart
  3. values.yaml: één image, meerdere workloads
  4. Templates: één helper voor alle pods
  5. De web-pod: probes, poorten en verkeer
  6. Configuratie en secrets gescheiden
  7. Migraties als Job
  8. Values per omgeving
  9. environments/production/values.yaml
  10. De valkuilen op een rij
  11. 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:

text
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:

text
environments/
├── staging/values.yaml
└── production/values.yaml

Het Chart.yaml houdt het simpel. Geen dependencies op database-charts; daarover verderop meer.

yaml
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:

yaml
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. Een laravel.log in de container verdwijnt bij elke herstart.
  • REDIS_PREFIX expliciet. Anders hangt de prefix af van APP_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 horizon en 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:

yaml
{{- 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:

yaml
{{- 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.

yaml
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.

yaml
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:

yaml
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:

yaml
{{- 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:

yaml
{{- 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:

yaml
# 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:cache in de build. php artisan config:cache bakt 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-imports gaat daar snel overheen. Laat je fullname-helper inkorten met trunc 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: 1 voorkomt dat.

Testen voordat het cluster het ziet

Een chart is code, dus hij krijgt een pipeline. In CI draaien we:

bash
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.

Ruthger Idema

Geschreven door Ruthger Idema

Eigenaar van Coding.nl. 15+ jaar e-commerce met Magento, Shopify en Laravel, samen met het vaste team in Emmen.

Wil je dit voor jouw shop laten uitzoeken? Plan een half uur

Een half uur via video met de mensen die het werk ook echt doen. Kies hieronder een moment dat jou past.

  • Direct met het vaste team, geen tussenlagen
  • Concrete feedback op je huidige situatie of plannen
  • Eerlijk advies, ook als het antwoord "niet doen" is
  • Indicatie van kosten en tijdlijn
Liever bellen? 0591-239 299
Beschikbaar deze week

Plan een gratis Tech Check

Vrijblijvend jouw uitdagingen bespreken

Of bel direct: 0591-239 299